mirror of
https://github.com/actions/runner.git
synced 2026-03-13 18:07:13 -04:00
Compare commits
36 Commits
v2.332.0
...
rentziass/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cd74b0f26 | ||
|
|
9d33c82d61 | ||
|
|
7d0f26a557 | ||
|
|
e11d6cfa59 | ||
|
|
00bde90018 | ||
|
|
5bad8cb359 | ||
|
|
8d1e06f436 | ||
|
|
649dc74be3 | ||
|
|
75760d1f34 | ||
|
|
e4406e035e | ||
|
|
a8f3b9195d | ||
|
|
8d6b38a428 | ||
|
|
860a919081 | ||
|
|
b76917a8a0 | ||
|
|
165fb90296 | ||
|
|
735dd69833 | ||
|
|
852e8721d0 | ||
|
|
2a98a8c955 | ||
|
|
f31e1c7c43 | ||
|
|
1573e36a44 | ||
|
|
0d33fd1930 | ||
|
|
2c65db137a | ||
|
|
3d8c844883 | ||
|
|
915e13c842 | ||
|
|
17b05ddaa4 | ||
|
|
9737dfadd5 | ||
|
|
cca15de3b3 | ||
|
|
8b1b23b5ce | ||
|
|
20111cbfda | ||
|
|
8f01257663 | ||
|
|
8a73bccebb | ||
|
|
a9a07a6553 | ||
|
|
60a9422599 | ||
|
|
985a06fcca | ||
|
|
ae09a9d7b5 | ||
|
|
7650fc432e |
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "Actions Runner Devcontainer",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:focal",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:noble",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:1": {},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||
"ghcr.io/devcontainers/features/dotnet": {
|
||||
"version": "8.0.418"
|
||||
},
|
||||
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -133,37 +133,37 @@ jobs:
|
||||
|
||||
# Download runner package tar.gz/zip produced by 'build' job
|
||||
- name: Download Artifact (win-x64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-win-x64
|
||||
path: ./
|
||||
- name: Download Artifact (win-arm64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-win-arm64
|
||||
path: ./
|
||||
- name: Download Artifact (osx-x64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-osx-x64
|
||||
path: ./
|
||||
- name: Download Artifact (osx-arm64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-osx-arm64
|
||||
path: ./
|
||||
- name: Download Artifact (linux-x64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-linux-x64
|
||||
path: ./
|
||||
- name: Download Artifact (linux-arm)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-linux-arm
|
||||
path: ./
|
||||
- name: Download Artifact (linux-arm64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-linux-arm64
|
||||
path: ./
|
||||
|
||||
@@ -7,7 +7,7 @@ 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.20.0"
|
||||
NODE24_VERSION="24.13.1"
|
||||
NODE24_VERSION="24.14.0"
|
||||
|
||||
get_abs_path() {
|
||||
# exploits the fact that pwd will print abs path when no args
|
||||
|
||||
@@ -172,6 +172,7 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check";
|
||||
public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check";
|
||||
public static readonly string CompareWorkflowParser = "actions_runner_compare_workflow_parser";
|
||||
public static readonly string ServiceContainerCommand = "actions_service_container_command";
|
||||
public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions";
|
||||
public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations";
|
||||
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
|
||||
|
||||
@@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
_terminal.WriteError($"Runner update failed: {ex.Message}");
|
||||
_updateTrace.Enqueue(ex.ToString());
|
||||
throw;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
_terminal.WriteError($"Runner update failed: {ex.Message}");
|
||||
_updateTrace.Enqueue(ex.ToString());
|
||||
throw;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -111,7 +111,7 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
// Log the error and fail the PrepareActionsAsync Initialization.
|
||||
Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}");
|
||||
executionContext.InfrastructureError(ex.Message, category: "resolve_action");
|
||||
executionContext.InfrastructureError(ex.InnerException?.Message ?? ex.Message, category: "resolve_action");
|
||||
executionContext.Result = TaskResult.Failed;
|
||||
throw;
|
||||
}
|
||||
@@ -818,7 +818,7 @@ namespace GitHub.Runner.Worker
|
||||
try
|
||||
{
|
||||
Trace.Info($"Found unpacked action directory '{cacheDirectory}' in cache directory '{actionArchiveCacheDir}'");
|
||||
|
||||
|
||||
// repository archive from github always contains a nested folder
|
||||
var nestedDirectories = new DirectoryInfo(cacheDirectory).GetDirectories();
|
||||
if (nestedDirectories.Length != 1)
|
||||
@@ -832,14 +832,14 @@ namespace GitHub.Runner.Worker
|
||||
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
|
||||
IOUtil.CreateSymbolicLink(destDirectory, nestedDirectories[0].FullName);
|
||||
}
|
||||
|
||||
|
||||
executionContext.Debug($"Created symlink from cached directory '{cacheDirectory}' to '{destDirectory}'");
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink"
|
||||
});
|
||||
|
||||
|
||||
Trace.Info("Finished getting action repository.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ namespace GitHub.Runner.Worker.Container
|
||||
this.ContainerImage = containerImage;
|
||||
this.ContainerDisplayName = $"{container.Alias}_{Pipelines.Validation.NameValidation.Sanitize(containerImage)}_{Guid.NewGuid().ToString("N").Substring(0, 6)}";
|
||||
this.ContainerCreateOptions = container.Options;
|
||||
this.ContainerEntryPoint = container.Entrypoint;
|
||||
this.ContainerEntryPointArgs = container.Command;
|
||||
_environmentVariables = container.Environment;
|
||||
this.IsJobContainer = isJobContainer;
|
||||
this.ContainerNetworkAlias = networkAlias;
|
||||
|
||||
906
src/Runner.Worker/Dap/DapDebugSession.cs
Normal file
906
src/Runner.Worker/Dap/DapDebugSession.cs
Normal file
@@ -0,0 +1,906 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores information about a completed step for stack trace display.
|
||||
/// </summary>
|
||||
internal sealed class CompletedStepInfo
|
||||
{
|
||||
public string DisplayName { get; set; }
|
||||
public TaskResult? Result { get; set; }
|
||||
public int FrameId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles step-level breakpoints with next/continue flow control,
|
||||
/// scope/variable inspection, client reconnection, and cancellation
|
||||
/// signal propagation.
|
||||
///
|
||||
/// REPL, step manipulation, and time-travel debugging are intentionally
|
||||
/// deferred to future iterations.
|
||||
/// </summary>
|
||||
public sealed class DapDebugSession : RunnerService, IDapDebugSession
|
||||
{
|
||||
// Thread ID for the single job execution thread
|
||||
private const int JobThreadId = 1;
|
||||
|
||||
// Frame ID for the current step (always 1)
|
||||
private const int CurrentFrameId = 1;
|
||||
|
||||
// Frame IDs for completed steps start at 1000
|
||||
private const int CompletedFrameIdBase = 1000;
|
||||
|
||||
private IDapServer _server;
|
||||
private volatile DapSessionState _state = DapSessionState.WaitingForConnection;
|
||||
|
||||
// Synchronization for step execution
|
||||
private TaskCompletionSource<DapCommand> _commandTcs;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
// Handshake completion — signaled when configurationDone is received
|
||||
private readonly TaskCompletionSource<bool> _handshakeTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// Whether to pause before the next step (set by 'next' command)
|
||||
private bool _pauseOnNextStep = true;
|
||||
|
||||
// Current execution context
|
||||
private IStep _currentStep;
|
||||
private IExecutionContext _jobContext;
|
||||
private int _currentStepIndex;
|
||||
|
||||
// Track completed steps for stack trace
|
||||
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
|
||||
private int _nextCompletedFrameId = CompletedFrameIdBase;
|
||||
|
||||
// Client connection tracking for reconnection support
|
||||
private volatile bool _isClientConnected;
|
||||
|
||||
// Scope/variable inspection provider — reusable by future DAP features
|
||||
private DapVariableProvider _variableProvider;
|
||||
|
||||
// REPL command executor for run() commands
|
||||
private DapReplExecutor _replExecutor;
|
||||
|
||||
public bool IsActive =>
|
||||
_state == DapSessionState.Ready ||
|
||||
_state == DapSessionState.Paused ||
|
||||
_state == DapSessionState.Running;
|
||||
|
||||
public DapSessionState State => _state;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_variableProvider = new DapVariableProvider(hostContext);
|
||||
Trace.Info("DapDebugSession initialized");
|
||||
}
|
||||
|
||||
public void SetDapServer(IDapServer server)
|
||||
{
|
||||
_server = server;
|
||||
_replExecutor = new DapReplExecutor(HostContext, server);
|
||||
Trace.Info("DAP server reference set");
|
||||
}
|
||||
|
||||
public async Task WaitForHandshakeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Trace.Info("Waiting for DAP handshake (configurationDone)...");
|
||||
|
||||
using (cancellationToken.Register(() => _handshakeTcs.TrySetCanceled()))
|
||||
{
|
||||
await _handshakeTcs.Task;
|
||||
}
|
||||
|
||||
Trace.Info("DAP handshake complete, session is ready");
|
||||
}
|
||||
|
||||
#region Message Dispatch
|
||||
|
||||
public async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken)
|
||||
{
|
||||
Request request = null;
|
||||
try
|
||||
{
|
||||
request = JsonConvert.DeserializeObject<Request>(messageJson);
|
||||
if (request == null)
|
||||
{
|
||||
Trace.Warning("Failed to deserialize DAP request");
|
||||
return;
|
||||
}
|
||||
|
||||
Trace.Info("Handling DAP request");
|
||||
|
||||
Response response;
|
||||
if (request.Command == "evaluate")
|
||||
{
|
||||
response = await HandleEvaluateAsync(request, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
response = request.Command switch
|
||||
{
|
||||
"initialize" => HandleInitialize(request),
|
||||
"attach" => HandleAttach(request),
|
||||
"configurationDone" => HandleConfigurationDone(request),
|
||||
"disconnect" => HandleDisconnect(request),
|
||||
"threads" => HandleThreads(request),
|
||||
"stackTrace" => HandleStackTrace(request),
|
||||
"scopes" => HandleScopes(request),
|
||||
"variables" => HandleVariables(request),
|
||||
"continue" => HandleContinue(request),
|
||||
"next" => HandleNext(request),
|
||||
"setBreakpoints" => HandleSetBreakpoints(request),
|
||||
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
|
||||
"completions" => HandleCompletions(request),
|
||||
"stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level — use 'next' to advance to the next step.", body: null),
|
||||
"stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level — use 'continue' to resume.", body: null),
|
||||
"stepBack" => CreateResponse(request, false, "Step Back is not yet supported.", body: null),
|
||||
"reverseContinue" => CreateResponse(request, false, "Reverse Continue is not yet supported.", body: null),
|
||||
"pause" => CreateResponse(request, false, "Pause is not supported. The debugger pauses automatically at step boundaries.", body: null),
|
||||
_ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null)
|
||||
};
|
||||
}
|
||||
|
||||
response.RequestSeq = request.Seq;
|
||||
response.Command = request.Command;
|
||||
|
||||
_server?.SendResponse(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Error handling DAP request ({ex.GetType().Name})");
|
||||
if (request != null)
|
||||
{
|
||||
var maskedMessage = HostContext?.SecretMasker?.MaskSecrets(ex.Message) ?? ex.Message;
|
||||
var errorResponse = CreateResponse(request, false, maskedMessage, body: null);
|
||||
errorResponse.RequestSeq = request.Seq;
|
||||
errorResponse.Command = request.Command;
|
||||
_server?.SendResponse(errorResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DAP Request Handlers
|
||||
|
||||
private Response HandleInitialize(Request request)
|
||||
{
|
||||
if (request.Arguments != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
request.Arguments.ToObject<InitializeRequestArguments>();
|
||||
Trace.Info("Initialize arguments received");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Failed to parse initialize arguments ({ex.GetType().Name})");
|
||||
}
|
||||
}
|
||||
|
||||
_state = DapSessionState.Initializing;
|
||||
|
||||
// Build capabilities — MVP only supports configurationDone
|
||||
var capabilities = new Capabilities
|
||||
{
|
||||
SupportsConfigurationDoneRequest = true,
|
||||
// All other capabilities are false for MVP
|
||||
SupportsFunctionBreakpoints = false,
|
||||
SupportsConditionalBreakpoints = false,
|
||||
SupportsEvaluateForHovers = true,
|
||||
SupportsStepBack = false,
|
||||
SupportsSetVariable = false,
|
||||
SupportsRestartFrame = false,
|
||||
SupportsGotoTargetsRequest = false,
|
||||
SupportsStepInTargetsRequest = false,
|
||||
SupportsCompletionsRequest = true,
|
||||
SupportsModulesRequest = false,
|
||||
SupportsTerminateRequest = false,
|
||||
SupportTerminateDebuggee = false,
|
||||
SupportsDelayedStackTraceLoading = false,
|
||||
SupportsLoadedSourcesRequest = false,
|
||||
SupportsProgressReporting = false,
|
||||
SupportsRunInTerminalRequest = false,
|
||||
SupportsCancelRequest = false,
|
||||
SupportsExceptionOptions = false,
|
||||
SupportsValueFormattingOptions = false,
|
||||
SupportsExceptionInfoRequest = false,
|
||||
};
|
||||
|
||||
// Send initialized event after a brief delay to ensure the
|
||||
// response is delivered first (DAP spec requirement)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(50);
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "initialized"
|
||||
});
|
||||
Trace.Info("Sent initialized event");
|
||||
});
|
||||
|
||||
Trace.Info("Initialize request handled, capabilities sent");
|
||||
return CreateResponse(request, true, body: capabilities);
|
||||
}
|
||||
|
||||
private Response HandleAttach(Request request)
|
||||
{
|
||||
Trace.Info("Attach request handled");
|
||||
return CreateResponse(request, true, body: null);
|
||||
}
|
||||
|
||||
private Response HandleConfigurationDone(Request request)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_state = DapSessionState.Ready;
|
||||
}
|
||||
|
||||
_handshakeTcs.TrySetResult(true);
|
||||
|
||||
Trace.Info("Configuration done, debug session is ready");
|
||||
return CreateResponse(request, true, body: null);
|
||||
}
|
||||
|
||||
private Response HandleDisconnect(Request request)
|
||||
{
|
||||
Trace.Info("Disconnect request received");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_state = DapSessionState.Terminated;
|
||||
|
||||
// Release any blocked step execution
|
||||
_commandTcs?.TrySetResult(DapCommand.Disconnect);
|
||||
}
|
||||
|
||||
return CreateResponse(request, true, body: null);
|
||||
}
|
||||
|
||||
private Response HandleThreads(Request request)
|
||||
{
|
||||
IExecutionContext jobContext;
|
||||
lock (_stateLock)
|
||||
{
|
||||
jobContext = _jobContext;
|
||||
}
|
||||
|
||||
var threadName = jobContext != null
|
||||
? MaskUserVisibleText($"Job: {jobContext.GetGitHubContext("job") ?? "workflow job"}")
|
||||
: "Job Thread";
|
||||
|
||||
var body = new ThreadsResponseBody
|
||||
{
|
||||
Threads = new List<Thread>
|
||||
{
|
||||
new Thread
|
||||
{
|
||||
Id = JobThreadId,
|
||||
Name = threadName
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return CreateResponse(request, true, body: body);
|
||||
}
|
||||
|
||||
private Response HandleStackTrace(Request request)
|
||||
{
|
||||
IStep currentStep;
|
||||
int currentStepIndex;
|
||||
CompletedStepInfo[] completedSteps;
|
||||
lock (_stateLock)
|
||||
{
|
||||
currentStep = _currentStep;
|
||||
currentStepIndex = _currentStepIndex;
|
||||
completedSteps = _completedSteps.ToArray();
|
||||
}
|
||||
|
||||
var frames = new List<StackFrame>();
|
||||
|
||||
// Add current step as the top frame
|
||||
if (currentStep != null)
|
||||
{
|
||||
var resultIndicator = currentStep.ExecutionContext?.Result != null
|
||||
? $" [{currentStep.ExecutionContext.Result}]"
|
||||
: " [running]";
|
||||
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = CurrentFrameId,
|
||||
Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"),
|
||||
Line = currentStepIndex + 1,
|
||||
Column = 1,
|
||||
PresentationHint = "normal"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = CurrentFrameId,
|
||||
Name = "(no step executing)",
|
||||
Line = 0,
|
||||
Column = 1,
|
||||
PresentationHint = "subtle"
|
||||
});
|
||||
}
|
||||
|
||||
// Add completed steps as additional frames (most recent first)
|
||||
for (int i = completedSteps.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var completedStep = completedSteps[i];
|
||||
var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : "";
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = completedStep.FrameId,
|
||||
Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"),
|
||||
Line = 1,
|
||||
Column = 1,
|
||||
PresentationHint = "subtle"
|
||||
});
|
||||
}
|
||||
|
||||
var body = new StackTraceResponseBody
|
||||
{
|
||||
StackFrames = frames,
|
||||
TotalFrames = frames.Count
|
||||
};
|
||||
|
||||
return CreateResponse(request, true, body: body);
|
||||
}
|
||||
|
||||
private Response HandleScopes(Request request)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<ScopesArguments>();
|
||||
var frameId = args?.FrameId ?? CurrentFrameId;
|
||||
|
||||
var context = GetExecutionContextForFrame(frameId);
|
||||
if (context == null)
|
||||
{
|
||||
return CreateResponse(request, true, body: new ScopesResponseBody
|
||||
{
|
||||
Scopes = new List<Scope>()
|
||||
});
|
||||
}
|
||||
|
||||
var scopes = _variableProvider.GetScopes(context);
|
||||
return CreateResponse(request, true, body: new ScopesResponseBody
|
||||
{
|
||||
Scopes = scopes
|
||||
});
|
||||
}
|
||||
|
||||
private Response HandleVariables(Request request)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<VariablesArguments>();
|
||||
var variablesRef = args?.VariablesReference ?? 0;
|
||||
|
||||
var context = GetCurrentExecutionContext();
|
||||
if (context == null)
|
||||
{
|
||||
return CreateResponse(request, true, body: new VariablesResponseBody
|
||||
{
|
||||
Variables = new List<Variable>()
|
||||
});
|
||||
}
|
||||
|
||||
var variables = _variableProvider.GetVariables(context, variablesRef);
|
||||
return CreateResponse(request, true, body: new VariablesResponseBody
|
||||
{
|
||||
Variables = variables
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Response> HandleEvaluateAsync(Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<EvaluateArguments>();
|
||||
var expression = args?.Expression ?? string.Empty;
|
||||
var frameId = args?.FrameId ?? CurrentFrameId;
|
||||
var evalContext = args?.Context ?? "hover";
|
||||
|
||||
Trace.Info("Evaluate request received");
|
||||
|
||||
// REPL context → route through the DSL dispatcher
|
||||
if (string.Equals(evalContext, "repl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var result = await HandleReplInputAsync(expression, frameId, cancellationToken);
|
||||
return CreateResponse(request, true, body: result);
|
||||
}
|
||||
|
||||
// Watch/hover/variables/clipboard → expression evaluation only
|
||||
var context = GetExecutionContextForFrame(frameId);
|
||||
var evalResult = _variableProvider.EvaluateExpression(expression, context);
|
||||
return CreateResponse(request, true, body: evalResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes REPL input through the DSL parser. If the input matches a
|
||||
/// known command it is dispatched; otherwise it falls through to
|
||||
/// expression evaluation.
|
||||
/// </summary>
|
||||
private async Task<EvaluateResponseBody> HandleReplInputAsync(
|
||||
string input,
|
||||
int frameId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Try to parse as a DSL command
|
||||
var command = DapReplParser.TryParse(input, out var parseError);
|
||||
|
||||
if (parseError != null)
|
||||
{
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = parseError,
|
||||
Type = "error",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
|
||||
if (command != null)
|
||||
{
|
||||
return await DispatchReplCommandAsync(command, frameId, cancellationToken);
|
||||
}
|
||||
|
||||
// Not a DSL command → evaluate as a GitHub Actions expression
|
||||
// (this lets the REPL console also work for ad-hoc expression queries)
|
||||
var context = GetExecutionContextForFrame(frameId);
|
||||
return _variableProvider.EvaluateExpression(input, context);
|
||||
}
|
||||
|
||||
private async Task<EvaluateResponseBody> DispatchReplCommandAsync(
|
||||
DapReplCommand command,
|
||||
int frameId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case HelpCommand help:
|
||||
var helpText = string.IsNullOrEmpty(help.Topic)
|
||||
? DapReplParser.GetGeneralHelp()
|
||||
: help.Topic.Equals("run", StringComparison.OrdinalIgnoreCase)
|
||||
? DapReplParser.GetRunHelp()
|
||||
: $"Unknown help topic: {help.Topic}. Try: help or help(\"run\")";
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = helpText,
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
|
||||
case RunCommand run:
|
||||
var context = GetExecutionContextForFrame(frameId);
|
||||
return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken);
|
||||
|
||||
default:
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = $"Unknown command type: {command.GetType().Name}",
|
||||
Type = "error",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private Response HandleCompletions(Request request)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<CompletionsArguments>();
|
||||
var text = args?.Text ?? string.Empty;
|
||||
|
||||
var items = new List<CompletionItem>();
|
||||
|
||||
// Offer DSL commands when the user is starting to type
|
||||
if (string.IsNullOrEmpty(text) || "help".StartsWith(text, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
items.Add(new CompletionItem
|
||||
{
|
||||
Label = "help",
|
||||
Text = "help",
|
||||
Detail = "Show available debug console commands",
|
||||
Type = "function"
|
||||
});
|
||||
}
|
||||
if (string.IsNullOrEmpty(text) || "help(\"run\")".StartsWith(text, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
items.Add(new CompletionItem
|
||||
{
|
||||
Label = "help(\"run\")",
|
||||
Text = "help(\"run\")",
|
||||
Detail = "Show help for the run command",
|
||||
Type = "function"
|
||||
});
|
||||
}
|
||||
if (string.IsNullOrEmpty(text) || "run(".StartsWith(text, System.StringComparison.OrdinalIgnoreCase)
|
||||
|| text.StartsWith("run(", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
items.Add(new CompletionItem
|
||||
{
|
||||
Label = "run(\"...\")",
|
||||
Text = "run(\"",
|
||||
Detail = "Execute a script (like a workflow run step)",
|
||||
Type = "function"
|
||||
});
|
||||
}
|
||||
|
||||
return CreateResponse(request, true, body: new CompletionsResponseBody
|
||||
{
|
||||
Targets = items
|
||||
});
|
||||
}
|
||||
|
||||
private Response HandleContinue(Request request)
|
||||
{
|
||||
Trace.Info("Continue command received");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Paused)
|
||||
{
|
||||
_state = DapSessionState.Running;
|
||||
_pauseOnNextStep = false;
|
||||
_commandTcs?.TrySetResult(DapCommand.Continue);
|
||||
}
|
||||
}
|
||||
|
||||
return CreateResponse(request, true, body: new ContinueResponseBody
|
||||
{
|
||||
AllThreadsContinued = true
|
||||
});
|
||||
}
|
||||
|
||||
private Response HandleNext(Request request)
|
||||
{
|
||||
Trace.Info("Next (step over) command received");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Paused)
|
||||
{
|
||||
_state = DapSessionState.Running;
|
||||
_pauseOnNextStep = true;
|
||||
_commandTcs?.TrySetResult(DapCommand.Next);
|
||||
}
|
||||
}
|
||||
|
||||
return CreateResponse(request, true, body: null);
|
||||
}
|
||||
|
||||
private Response HandleSetBreakpoints(Request request)
|
||||
{
|
||||
// MVP: acknowledge but don't process breakpoints
|
||||
// All steps pause automatically via _pauseOnNextStep
|
||||
return CreateResponse(request, true, body: new { breakpoints = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
private Response HandleSetExceptionBreakpoints(Request request)
|
||||
{
|
||||
// MVP: acknowledge but don't process exception breakpoints
|
||||
return CreateResponse(request, true, body: null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Step Lifecycle
|
||||
|
||||
public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken)
|
||||
{
|
||||
bool pauseOnNextStep;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state != DapSessionState.Ready &&
|
||||
_state != DapSessionState.Paused &&
|
||||
_state != DapSessionState.Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentStep = step;
|
||||
_jobContext = jobContext;
|
||||
_currentStepIndex = _completedSteps.Count;
|
||||
pauseOnNextStep = _pauseOnNextStep;
|
||||
}
|
||||
|
||||
// Reset variable references so stale nested refs from the
|
||||
// previous step are not served to the client.
|
||||
_variableProvider?.Reset();
|
||||
|
||||
// Determine if we should pause
|
||||
bool shouldPause = isFirstStep || pauseOnNextStep;
|
||||
|
||||
if (!shouldPause)
|
||||
{
|
||||
Trace.Info("Step starting without debugger pause");
|
||||
return;
|
||||
}
|
||||
|
||||
var reason = isFirstStep ? "entry" : "step";
|
||||
var description = isFirstStep
|
||||
? $"Stopped at job entry: {step.DisplayName}"
|
||||
: $"Stopped before step: {step.DisplayName}";
|
||||
|
||||
Trace.Info("Step starting with debugger pause");
|
||||
|
||||
// Send stopped event to debugger (only if client is connected)
|
||||
SendStoppedEvent(reason, description);
|
||||
|
||||
// Wait for debugger command
|
||||
await WaitForCommandAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public void OnStepCompleted(IStep step)
|
||||
{
|
||||
var result = step.ExecutionContext?.Result;
|
||||
Trace.Info("Step completed");
|
||||
|
||||
// Add to completed steps list for stack trace
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state != DapSessionState.Ready &&
|
||||
_state != DapSessionState.Paused &&
|
||||
_state != DapSessionState.Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_completedSteps.Add(new CompletedStepInfo
|
||||
{
|
||||
DisplayName = step.DisplayName,
|
||||
Result = result,
|
||||
FrameId = _nextCompletedFrameId++
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void OnJobCompleted()
|
||||
{
|
||||
Trace.Info("Job completed, sending terminated event");
|
||||
|
||||
int exitCode;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Terminated)
|
||||
{
|
||||
Trace.Info("Session already terminated, skipping OnJobCompleted events");
|
||||
return;
|
||||
}
|
||||
_state = DapSessionState.Terminated;
|
||||
exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1;
|
||||
}
|
||||
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "terminated",
|
||||
Body = new TerminatedEventBody()
|
||||
});
|
||||
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "exited",
|
||||
Body = new ExitedEventBody
|
||||
{
|
||||
ExitCode = exitCode
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void CancelSession()
|
||||
{
|
||||
Trace.Info("CancelSession called - terminating debug session");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Terminated)
|
||||
{
|
||||
Trace.Info("Session already terminated, ignoring CancelSession");
|
||||
return;
|
||||
}
|
||||
_state = DapSessionState.Terminated;
|
||||
}
|
||||
|
||||
// Send terminated event to debugger so it updates its UI
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "terminated",
|
||||
Body = new TerminatedEventBody()
|
||||
});
|
||||
|
||||
// Send exited event with cancellation exit code (130 = SIGINT convention)
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "exited",
|
||||
Body = new ExitedEventBody { ExitCode = 130 }
|
||||
});
|
||||
|
||||
// Release any pending command waits
|
||||
_commandTcs?.TrySetResult(DapCommand.Disconnect);
|
||||
|
||||
// Release handshake wait if still pending
|
||||
_handshakeTcs.TrySetCanceled();
|
||||
|
||||
Trace.Info("Debug session cancelled");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Client Connection Tracking
|
||||
|
||||
public void HandleClientConnected()
|
||||
{
|
||||
_isClientConnected = true;
|
||||
Trace.Info("Client connected to debug session");
|
||||
|
||||
// If we're paused, re-send the stopped event so the new client
|
||||
// knows the current state (important for reconnection)
|
||||
string description = null;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Paused && _currentStep != null)
|
||||
{
|
||||
description = $"Stopped before step: {_currentStep.DisplayName}";
|
||||
}
|
||||
}
|
||||
|
||||
if (description != null)
|
||||
{
|
||||
Trace.Info("Re-sending stopped event to reconnected client");
|
||||
SendStoppedEvent("step", description);
|
||||
}
|
||||
}
|
||||
|
||||
public void HandleClientDisconnected()
|
||||
{
|
||||
_isClientConnected = false;
|
||||
Trace.Info("Client disconnected from debug session");
|
||||
|
||||
// Intentionally do NOT release the command TCS here.
|
||||
// The session stays paused, waiting for a client to reconnect.
|
||||
// The server's connection loop will accept a new client and
|
||||
// call HandleClientConnected, which re-sends the stopped event.
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Blocks the step execution thread until a debugger command is received
|
||||
/// or the job is cancelled.
|
||||
/// </summary>
|
||||
private async Task WaitForCommandAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Terminated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_state = DapSessionState.Paused;
|
||||
_commandTcs = new TaskCompletionSource<DapCommand>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
Trace.Info("Waiting for debugger command...");
|
||||
|
||||
using (cancellationToken.Register(() =>
|
||||
{
|
||||
Trace.Info("Job cancellation detected, releasing debugger wait");
|
||||
_commandTcs?.TrySetResult(DapCommand.Disconnect);
|
||||
}))
|
||||
{
|
||||
var command = await _commandTcs.Task;
|
||||
|
||||
Trace.Info("Received debugger command");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Paused)
|
||||
{
|
||||
_state = DapSessionState.Running;
|
||||
}
|
||||
}
|
||||
|
||||
// Send continued event for normal flow commands
|
||||
if (!cancellationToken.IsCancellationRequested &&
|
||||
(command == DapCommand.Continue || command == DapCommand.Next))
|
||||
{
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "continued",
|
||||
Body = new ContinuedEventBody
|
||||
{
|
||||
ThreadId = JobThreadId,
|
||||
AllThreadsContinued = true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the execution context for a given stack frame ID.
|
||||
/// Frame 1 = current step; frames 1000+ = completed steps (no
|
||||
/// context available — those steps have already finished).
|
||||
/// Falls back to the job-level context when no step is active.
|
||||
/// </summary>
|
||||
private IExecutionContext GetExecutionContextForFrame(int frameId)
|
||||
{
|
||||
if (frameId == CurrentFrameId)
|
||||
{
|
||||
return GetCurrentExecutionContext();
|
||||
}
|
||||
|
||||
// Completed-step frames don't carry a live execution context.
|
||||
return null;
|
||||
}
|
||||
|
||||
private IExecutionContext GetCurrentExecutionContext()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return _currentStep?.ExecutionContext ?? _jobContext;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a stopped event to the connected client.
|
||||
/// Silently no-ops if no client is connected.
|
||||
/// </summary>
|
||||
private void SendStoppedEvent(string reason, string description)
|
||||
{
|
||||
if (!_isClientConnected)
|
||||
{
|
||||
Trace.Info("No client connected, deferring stopped event");
|
||||
return;
|
||||
}
|
||||
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "stopped",
|
||||
Body = new StoppedEventBody
|
||||
{
|
||||
Reason = reason,
|
||||
Description = MaskUserVisibleText(description),
|
||||
ThreadId = JobThreadId,
|
||||
AllThreadsStopped = true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private string MaskUserVisibleText(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
return _variableProvider?.MaskSecrets(value)
|
||||
?? HostContext?.SecretMasker?.MaskSecrets(value)
|
||||
?? value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a DAP response with common fields pre-populated.
|
||||
/// </summary>
|
||||
private Response CreateResponse(Request request, bool success, string message = null, object body = null)
|
||||
{
|
||||
return new Response
|
||||
{
|
||||
Type = "response",
|
||||
RequestSeq = request.Seq,
|
||||
Command = request.Command,
|
||||
Success = success,
|
||||
Message = success ? null : message,
|
||||
Body = body
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
1231
src/Runner.Worker/Dap/DapMessages.cs
Normal file
1231
src/Runner.Worker/Dap/DapMessages.cs
Normal file
File diff suppressed because it is too large
Load Diff
377
src/Runner.Worker/Dap/DapReplExecutor.cs
Normal file
377
src/Runner.Worker/Dap/DapReplExecutor.cs
Normal file
@@ -0,0 +1,377 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Handlers;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes <see cref="RunCommand"/> objects in the job's runtime context.
|
||||
///
|
||||
/// Mirrors the behavior of a normal workflow <c>run:</c> step as closely
|
||||
/// as possible by reusing the runner's existing shell-resolution logic,
|
||||
/// script fixup helpers, and process execution infrastructure.
|
||||
///
|
||||
/// Output is streamed to the debugger via DAP <c>output</c> events with
|
||||
/// secrets masked before emission.
|
||||
/// </summary>
|
||||
internal sealed class DapReplExecutor
|
||||
{
|
||||
private readonly IHostContext _hostContext;
|
||||
private readonly IDapServer _server;
|
||||
private readonly Tracing _trace;
|
||||
|
||||
public DapReplExecutor(IHostContext hostContext, IDapServer server)
|
||||
{
|
||||
_hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext));
|
||||
_server = server;
|
||||
_trace = hostContext.GetTrace(nameof(DapReplExecutor));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a <see cref="RunCommand"/> and returns the exit code as a
|
||||
/// formatted <see cref="EvaluateResponseBody"/>.
|
||||
/// </summary>
|
||||
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
|
||||
RunCommand command,
|
||||
IExecutionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (context == null)
|
||||
{
|
||||
return ErrorResult("No execution context available. The debugger must be paused at a step to run commands.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await ExecuteScriptAsync(command, context, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trace.Error($"REPL run command failed ({ex.GetType().Name})");
|
||||
var maskedError = _hostContext.SecretMasker.MaskSecrets(ex.Message);
|
||||
return ErrorResult($"Command failed: {maskedError}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
|
||||
RunCommand command,
|
||||
IExecutionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Resolve shell — same logic as ScriptHandler
|
||||
string shellCommand;
|
||||
string argFormat;
|
||||
|
||||
if (!string.IsNullOrEmpty(command.Shell))
|
||||
{
|
||||
// Explicit shell from the DSL
|
||||
var parsed = ScriptHandlerHelpers.ParseShellOptionString(command.Shell);
|
||||
shellCommand = parsed.shellCommand;
|
||||
argFormat = string.IsNullOrEmpty(parsed.shellArgs)
|
||||
? ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand)
|
||||
: parsed.shellArgs;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default shell — mirrors ScriptHandler platform defaults
|
||||
shellCommand = ResolveDefaultShell(context);
|
||||
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
||||
}
|
||||
|
||||
_trace.Info("Resolved REPL shell");
|
||||
|
||||
// 2. Expand ${{ }} expressions in the script body, just like
|
||||
// ActionRunner evaluates step inputs before ScriptHandler sees them
|
||||
var contents = ExpandExpressions(command.Script, context);
|
||||
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
|
||||
|
||||
// Write to a temp file (same pattern as ScriptHandler)
|
||||
var extension = ScriptHandlerHelpers.GetScriptFileExtension(shellCommand);
|
||||
var scriptFilePath = Path.Combine(
|
||||
_hostContext.GetDirectory(WellKnownDirectory.Temp),
|
||||
$"dap_repl_{Guid.NewGuid()}{extension}");
|
||||
|
||||
Encoding encoding = new UTF8Encoding(false);
|
||||
#if OS_WINDOWS
|
||||
contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n");
|
||||
encoding = Console.InputEncoding.CodePage != 65001
|
||||
? Console.InputEncoding
|
||||
: encoding;
|
||||
#endif
|
||||
File.WriteAllText(scriptFilePath, contents, encoding);
|
||||
|
||||
try
|
||||
{
|
||||
// 3. Format arguments with script path
|
||||
var resolvedPath = scriptFilePath.Replace("\"", "\\\"");
|
||||
if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}"))
|
||||
{
|
||||
return ErrorResult($"Invalid shell option '{shellCommand}'. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{{0}}'");
|
||||
}
|
||||
var arguments = string.Format(argFormat, resolvedPath);
|
||||
|
||||
// 4. Resolve shell command path
|
||||
string prependPath = string.Join(
|
||||
Path.PathSeparator.ToString(),
|
||||
Enumerable.Reverse(context.Global.PrependPath));
|
||||
var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
|
||||
?? shellCommand;
|
||||
|
||||
// 5. Build environment — merge from execution context like a real step
|
||||
var environment = BuildEnvironment(context, command.Env);
|
||||
|
||||
// 6. Resolve working directory
|
||||
var workingDirectory = command.WorkingDirectory;
|
||||
if (string.IsNullOrEmpty(workingDirectory))
|
||||
{
|
||||
var githubContext = context.ExpressionValues.TryGetValue("github", out var gh)
|
||||
? gh as DictionaryContextData
|
||||
: null;
|
||||
var workspace = githubContext?.TryGetValue("workspace", out var ws) == true
|
||||
? (ws as StringContextData)?.Value
|
||||
: null;
|
||||
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
|
||||
}
|
||||
|
||||
_trace.Info("Executing REPL command");
|
||||
|
||||
// Stream execution info to debugger
|
||||
SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n");
|
||||
|
||||
// 7. Execute via IProcessInvoker (same as DefaultStepHost)
|
||||
int exitCode;
|
||||
using (var processInvoker = _hostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
processInvoker.OutputDataReceived += (sender, args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
|
||||
SendOutput("stdout", masked + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
processInvoker.ErrorDataReceived += (sender, args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
|
||||
SendOutput("stderr", masked + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
exitCode = await processInvoker.ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: commandPath,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: false,
|
||||
outputEncoding: null,
|
||||
killProcessOnCancel: true,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
_trace.Info($"REPL command exited with code {exitCode}");
|
||||
|
||||
// 8. Return only the exit code summary (output was already streamed)
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.",
|
||||
Type = exitCode == 0 ? "string" : "error",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up temp script file
|
||||
try { File.Delete(scriptFilePath); }
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands <c>${{ }}</c> expressions in the input string using the
|
||||
/// runner's template evaluator — the same evaluation path that processes
|
||||
/// step inputs before <see cref="ScriptHandler"/> runs them.
|
||||
///
|
||||
/// Each <c>${{ expr }}</c> occurrence is individually evaluated and
|
||||
/// replaced with its masked string result, mirroring the semantics of
|
||||
/// expression interpolation in a workflow <c>run:</c> step body.
|
||||
/// </summary>
|
||||
internal string ExpandExpressions(string input, IExecutionContext context)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input) || !input.Contains("${{"))
|
||||
{
|
||||
return input ?? string.Empty;
|
||||
}
|
||||
|
||||
var result = new StringBuilder();
|
||||
int pos = 0;
|
||||
|
||||
while (pos < input.Length)
|
||||
{
|
||||
var start = input.IndexOf("${{", pos, StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
{
|
||||
result.Append(input, pos, input.Length - pos);
|
||||
break;
|
||||
}
|
||||
|
||||
// Append the literal text before the expression
|
||||
result.Append(input, pos, start - pos);
|
||||
|
||||
var end = input.IndexOf("}}", start + 3, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
{
|
||||
// Unterminated expression — keep literal
|
||||
result.Append(input, start, input.Length - start);
|
||||
break;
|
||||
}
|
||||
|
||||
var expr = input.Substring(start + 3, end - start - 3).Trim();
|
||||
end += 2; // skip past "}}"
|
||||
|
||||
// Evaluate the expression
|
||||
try
|
||||
{
|
||||
var templateEvaluator = context.ToPipelineTemplateEvaluator();
|
||||
var token = new GitHub.DistributedTask.ObjectTemplating.Tokens.BasicExpressionToken(
|
||||
null, null, null, expr);
|
||||
var evaluated = templateEvaluator.EvaluateStepDisplayName(
|
||||
token,
|
||||
context.ExpressionValues,
|
||||
context.ExpressionFunctions);
|
||||
result.Append(_hostContext.SecretMasker.MaskSecrets(evaluated ?? string.Empty));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trace.Warning($"Expression expansion failed ({ex.GetType().Name})");
|
||||
// Keep the original expression literal on failure
|
||||
result.Append(input, start, end - start);
|
||||
}
|
||||
|
||||
pos = end;
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the default shell the same way <see cref="ScriptHandler"/>
|
||||
/// does: check job defaults, then fall back to platform default.
|
||||
/// </summary>
|
||||
internal string ResolveDefaultShell(IExecutionContext context)
|
||||
{
|
||||
// Check job defaults
|
||||
if (context.Global?.JobDefaults != null &&
|
||||
context.Global.JobDefaults.TryGetValue("run", out var runDefaults) &&
|
||||
runDefaults.TryGetValue("shell", out var defaultShell) &&
|
||||
!string.IsNullOrEmpty(defaultShell))
|
||||
{
|
||||
_trace.Info("Using job default shell");
|
||||
return defaultShell;
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
string prependPath = string.Join(
|
||||
Path.PathSeparator.ToString(),
|
||||
context.Global?.PrependPath != null ? Enumerable.Reverse(context.Global.PrependPath) : Array.Empty<string>());
|
||||
var pwshPath = WhichUtil.Which("pwsh", false, _trace, prependPath);
|
||||
return !string.IsNullOrEmpty(pwshPath) ? "pwsh" : "powershell";
|
||||
#else
|
||||
return "sh";
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the job context environment with any REPL-specific overrides.
|
||||
/// </summary>
|
||||
internal Dictionary<string, string> BuildEnvironment(
|
||||
IExecutionContext context,
|
||||
Dictionary<string, string> replEnv)
|
||||
{
|
||||
var env = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer);
|
||||
|
||||
// Pull environment from the execution context (same as ActionRunner)
|
||||
if (context.ExpressionValues.TryGetValue("env", out var envData))
|
||||
{
|
||||
if (envData is DictionaryContextData dictEnv)
|
||||
{
|
||||
foreach (var pair in dictEnv)
|
||||
{
|
||||
if (pair.Value is StringContextData str)
|
||||
{
|
||||
env[pair.Key] = str.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (envData is CaseSensitiveDictionaryContextData csEnv)
|
||||
{
|
||||
foreach (var pair in csEnv)
|
||||
{
|
||||
if (pair.Value is StringContextData str)
|
||||
{
|
||||
env[pair.Key] = str.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose runtime context variables to the environment (GITHUB_*, RUNNER_*, etc.)
|
||||
foreach (var ctxPair in context.ExpressionValues)
|
||||
{
|
||||
if (ctxPair.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
|
||||
{
|
||||
foreach (var rtEnv in runtimeContext.GetRuntimeEnvironmentVariables())
|
||||
{
|
||||
env[rtEnv.Key] = rtEnv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply REPL-specific overrides last (so they win),
|
||||
// expanding any ${{ }} expressions in the values
|
||||
if (replEnv != null)
|
||||
{
|
||||
foreach (var pair in replEnv)
|
||||
{
|
||||
env[pair.Key] = ExpandExpressions(pair.Value, context);
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
private void SendOutput(string category, string text)
|
||||
{
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "output",
|
||||
Body = new OutputEventBody
|
||||
{
|
||||
Category = category,
|
||||
Output = text
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static EvaluateResponseBody ErrorResult(string message)
|
||||
{
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = message,
|
||||
Type = "error",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
409
src/Runner.Worker/Dap/DapReplParser.cs
Normal file
409
src/Runner.Worker/Dap/DapReplParser.cs
Normal file
@@ -0,0 +1,409 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Base type for all REPL DSL commands.
|
||||
/// </summary>
|
||||
internal abstract class DapReplCommand
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>help</c> or <c>help("run")</c>
|
||||
/// </summary>
|
||||
internal sealed class HelpCommand : DapReplCommand
|
||||
{
|
||||
public string Topic { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>run("echo hello")</c> or
|
||||
/// <c>run("echo hello", shell: "bash", env: { FOO: "bar" }, working_directory: "/tmp")</c>
|
||||
/// </summary>
|
||||
internal sealed class RunCommand : DapReplCommand
|
||||
{
|
||||
public string Script { get; set; }
|
||||
public string Shell { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses REPL input into typed <see cref="DapReplCommand"/> objects.
|
||||
///
|
||||
/// Grammar (intentionally minimal — extend as the DSL grows):
|
||||
/// <code>
|
||||
/// help → HelpCommand { Topic = null }
|
||||
/// help("run") → HelpCommand { Topic = "run" }
|
||||
/// run("script body") → RunCommand { Script = "script body" }
|
||||
/// run("script", shell: "bash") → RunCommand { Shell = "bash" }
|
||||
/// run("script", env: { K: "V" }) → RunCommand { Env = { K → V } }
|
||||
/// run("script", working_directory: "p")→ RunCommand { WorkingDirectory = "p" }
|
||||
/// </code>
|
||||
///
|
||||
/// Parsing is intentionally hand-rolled rather than regex-based so it can
|
||||
/// handle nested braces, quoted strings with escapes, and grow to support
|
||||
/// future commands without accumulating regex complexity.
|
||||
/// </summary>
|
||||
internal static class DapReplParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to parse REPL input into a command. Returns null if the
|
||||
/// input does not match any known DSL command (i.e. it should be
|
||||
/// treated as an expression instead).
|
||||
/// </summary>
|
||||
internal static DapReplCommand TryParse(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = input.Trim();
|
||||
|
||||
// help / help("topic")
|
||||
if (trimmed.Equals("help", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("help(", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ParseHelp(trimmed, out error);
|
||||
}
|
||||
|
||||
// run("...")
|
||||
if (trimmed.StartsWith("run(", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ParseRun(trimmed, out error);
|
||||
}
|
||||
|
||||
// Not a DSL command
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string GetGeneralHelp()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Actions Debug Console");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Commands:");
|
||||
sb.AppendLine(" help Show this help");
|
||||
sb.AppendLine(" help(\"run\") Show help for the run command");
|
||||
sb.AppendLine(" run(\"script\") Execute a script (like a workflow run step)");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Anything else is evaluated as a GitHub Actions expression.");
|
||||
sb.AppendLine(" Example: github.repository");
|
||||
sb.AppendLine(" Example: ${{ github.event_name }}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
internal static string GetRunHelp()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("run command — execute a script in the job context");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Usage:");
|
||||
sb.AppendLine(" run(\"echo hello\")");
|
||||
sb.AppendLine(" run(\"echo $FOO\", shell: \"bash\")");
|
||||
sb.AppendLine(" run(\"echo $FOO\", env: { FOO: \"bar\" })");
|
||||
sb.AppendLine(" run(\"ls\", working_directory: \"/tmp\")");
|
||||
sb.AppendLine(" run(\"echo $X\", shell: \"bash\", env: { X: \"1\" }, working_directory: \"/tmp\")");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Options:");
|
||||
sb.AppendLine(" shell: Shell to use (default: job default, e.g. bash)");
|
||||
sb.AppendLine(" env: Extra environment variables as { KEY: \"value\" }");
|
||||
sb.AppendLine(" working_directory: Working directory for the command");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Behavior:");
|
||||
sb.AppendLine(" - Equivalent to a workflow `run:` step");
|
||||
sb.AppendLine(" - Expressions in the script body are expanded (${{ ... }})");
|
||||
sb.AppendLine(" - Output is streamed in real time and secrets are masked");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
#region Parsers
|
||||
|
||||
private static HelpCommand ParseHelp(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (input.Equals("help", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HelpCommand();
|
||||
}
|
||||
|
||||
// help("topic")
|
||||
var inner = ExtractParenthesizedArgs(input, "help", out error);
|
||||
if (error != null) return null;
|
||||
|
||||
var topic = ExtractQuotedString(inner.Trim(), out error);
|
||||
if (error != null) return null;
|
||||
|
||||
return new HelpCommand { Topic = topic };
|
||||
}
|
||||
|
||||
private static RunCommand ParseRun(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
|
||||
var inner = ExtractParenthesizedArgs(input, "run", out error);
|
||||
if (error != null) return null;
|
||||
|
||||
// Split into argument list respecting quotes and braces
|
||||
var args = SplitArguments(inner, out error);
|
||||
if (error != null) return null;
|
||||
if (args.Count == 0)
|
||||
{
|
||||
error = "run() requires a script argument. Example: run(\"echo hello\")";
|
||||
return null;
|
||||
}
|
||||
|
||||
// First arg must be the script body (a quoted string)
|
||||
var script = ExtractQuotedString(args[0].Trim(), out error);
|
||||
if (error != null)
|
||||
{
|
||||
error = $"First argument to run() must be a quoted string. {error}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var cmd = new RunCommand { Script = script };
|
||||
|
||||
// Parse remaining keyword arguments
|
||||
for (int i = 1; i < args.Count; i++)
|
||||
{
|
||||
var kv = args[i].Trim();
|
||||
var colonIdx = kv.IndexOf(':');
|
||||
if (colonIdx <= 0)
|
||||
{
|
||||
error = $"Expected keyword argument (e.g. shell: \"bash\"), got: {kv}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = kv.Substring(0, colonIdx).Trim();
|
||||
var value = kv.Substring(colonIdx + 1).Trim();
|
||||
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "shell":
|
||||
cmd.Shell = ExtractQuotedString(value, out error);
|
||||
if (error != null) { error = $"shell: {error}"; return null; }
|
||||
break;
|
||||
|
||||
case "working_directory":
|
||||
cmd.WorkingDirectory = ExtractQuotedString(value, out error);
|
||||
if (error != null) { error = $"working_directory: {error}"; return null; }
|
||||
break;
|
||||
|
||||
case "env":
|
||||
cmd.Env = ParseEnvBlock(value, out error);
|
||||
if (error != null) { error = $"env: {error}"; return null; }
|
||||
break;
|
||||
|
||||
default:
|
||||
error = $"Unknown option: {key}. Valid options: shell, env, working_directory";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Low-level parsing helpers
|
||||
|
||||
/// <summary>
|
||||
/// Given "cmd(...)" returns the inner content between the outer parens.
|
||||
/// </summary>
|
||||
private static string ExtractParenthesizedArgs(string input, string prefix, out string error)
|
||||
{
|
||||
error = null;
|
||||
var start = prefix.Length; // skip "cmd"
|
||||
if (start >= input.Length || input[start] != '(')
|
||||
{
|
||||
error = $"Expected '(' after {prefix}";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input[input.Length - 1] != ')')
|
||||
{
|
||||
error = $"Expected ')' at end of {prefix}(...)";
|
||||
return null;
|
||||
}
|
||||
|
||||
return input.Substring(start + 1, input.Length - start - 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a double-quoted string value, handling escaped quotes.
|
||||
/// </summary>
|
||||
internal static string ExtractQuotedString(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
error = "Expected a quoted string, got empty input";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input[0] != '"')
|
||||
{
|
||||
error = $"Expected a quoted string starting with \", got: {Truncate(input, 40)}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 1; i < input.Length; i++)
|
||||
{
|
||||
if (input[i] == '\\' && i + 1 < input.Length)
|
||||
{
|
||||
sb.Append(input[i + 1]);
|
||||
i++;
|
||||
}
|
||||
else if (input[i] == '"')
|
||||
{
|
||||
// Check nothing meaningful follows the closing quote
|
||||
var rest = input.Substring(i + 1).Trim();
|
||||
if (rest.Length > 0)
|
||||
{
|
||||
error = $"Unexpected content after closing quote: {Truncate(rest, 40)}";
|
||||
return null;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(input[i]);
|
||||
}
|
||||
}
|
||||
|
||||
error = "Unterminated string (missing closing \")";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a comma-separated argument list, respecting quoted strings
|
||||
/// and nested braces so that <c>"a, b", env: { K: "V, W" }</c> is
|
||||
/// correctly split into two arguments.
|
||||
/// </summary>
|
||||
internal static List<string> SplitArguments(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
var result = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
int depth = 0;
|
||||
bool inQuote = false;
|
||||
|
||||
for (int i = 0; i < input.Length; i++)
|
||||
{
|
||||
var ch = input[i];
|
||||
|
||||
if (ch == '\\' && inQuote && i + 1 < input.Length)
|
||||
{
|
||||
current.Append(ch);
|
||||
current.Append(input[++i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '"')
|
||||
{
|
||||
inQuote = !inQuote;
|
||||
current.Append(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuote)
|
||||
{
|
||||
if (ch == '{')
|
||||
{
|
||||
depth++;
|
||||
current.Append(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch == '}')
|
||||
{
|
||||
depth--;
|
||||
current.Append(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch == ',' && depth == 0)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
current.Clear();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current.Append(ch);
|
||||
}
|
||||
|
||||
if (inQuote)
|
||||
{
|
||||
error = "Unterminated string in arguments";
|
||||
return null;
|
||||
}
|
||||
if (depth != 0)
|
||||
{
|
||||
error = "Unmatched braces in arguments";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>{ KEY: "value", KEY2: "value2" }</c> into a dictionary.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> ParseEnvBlock(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
var trimmed = input.Trim();
|
||||
if (!trimmed.StartsWith("{") || !trimmed.EndsWith("}"))
|
||||
{
|
||||
error = "Expected env block in the form { KEY: \"value\" }";
|
||||
return null;
|
||||
}
|
||||
|
||||
var inner = trimmed.Substring(1, trimmed.Length - 2).Trim();
|
||||
if (string.IsNullOrEmpty(inner))
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var pairs = SplitArguments(inner, out error);
|
||||
if (error != null) return null;
|
||||
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var colonIdx = pair.IndexOf(':');
|
||||
if (colonIdx <= 0)
|
||||
{
|
||||
error = $"Expected KEY: \"value\" pair, got: {Truncate(pair.Trim(), 40)}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = pair.Substring(0, colonIdx).Trim();
|
||||
var val = ExtractQuotedString(pair.Substring(colonIdx + 1).Trim(), out error);
|
||||
if (error != null) return null;
|
||||
|
||||
result[key] = val;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (value == null) return "(null)";
|
||||
return value.Length <= maxLength ? value : value.Substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
499
src/Runner.Worker/Dap/DapServer.cs
Normal file
499
src/Runner.Worker/Dap/DapServer.cs
Normal file
@@ -0,0 +1,499 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// TCP server for the Debug Adapter Protocol.
|
||||
/// Handles Content-Length message framing, JSON serialization,
|
||||
/// client reconnection, and graceful shutdown.
|
||||
/// </summary>
|
||||
public sealed class DapServer : RunnerService, IDapServer
|
||||
{
|
||||
private const string ContentLengthHeader = "Content-Length: ";
|
||||
private const int MaxMessageSize = 10 * 1024 * 1024; // 10 MB
|
||||
private const int MaxHeaderLineLength = 8192; // 8 KB
|
||||
|
||||
private TcpListener _listener;
|
||||
private TcpClient _client;
|
||||
private NetworkStream _stream;
|
||||
private IDapDebugSession _session;
|
||||
private CancellationTokenSource _cts;
|
||||
private TaskCompletionSource<bool> _connectionTcs;
|
||||
private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
|
||||
private int _nextSeq = 1;
|
||||
private Task _connectionLoopTask;
|
||||
private volatile bool _acceptConnections = true;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
Trace.Info("DapServer initialized");
|
||||
}
|
||||
|
||||
public void SetSession(IDapDebugSession session)
|
||||
{
|
||||
_session = session;
|
||||
Trace.Info("Debug session set");
|
||||
}
|
||||
|
||||
public Task StartAsync(int port, CancellationToken cancellationToken)
|
||||
{
|
||||
Trace.Info($"Starting DAP server on port {port}");
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_connectionTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||
_listener.Start();
|
||||
Trace.Info($"DAP server listening on 127.0.0.1:{port}");
|
||||
|
||||
// Start the connection loop in the background
|
||||
_connectionLoopTask = ConnectionLoopAsync(_cts.Token);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts client connections in a loop, supporting reconnection.
|
||||
/// When a client disconnects, the server waits for a new connection
|
||||
/// without blocking step execution.
|
||||
/// </summary>
|
||||
private async Task ConnectionLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (_acceptConnections && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info("Waiting for debug client connection...");
|
||||
|
||||
using (cancellationToken.Register(() =>
|
||||
{
|
||||
try { _listener?.Stop(); }
|
||||
catch { /* listener already stopped */ }
|
||||
}))
|
||||
{
|
||||
_client = await _listener.AcceptTcpClientAsync();
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_stream = _client.GetStream();
|
||||
var remoteEndPoint = _client.Client.RemoteEndPoint;
|
||||
Trace.Info($"Debug client connected from {remoteEndPoint}");
|
||||
|
||||
// Signal first connection (no-op on subsequent connections)
|
||||
_connectionTcs.TrySetResult(true);
|
||||
|
||||
// Notify session of new client
|
||||
_session?.HandleClientConnected();
|
||||
|
||||
// Process messages until client disconnects
|
||||
await ProcessMessagesAsync(cancellationToken);
|
||||
|
||||
// Client disconnected — notify session and clean up
|
||||
Trace.Info("Client disconnected, waiting for reconnection...");
|
||||
_session?.HandleClientDisconnected();
|
||||
CleanupConnection();
|
||||
}
|
||||
catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (SocketException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Connection error ({ex.GetType().Name})");
|
||||
CleanupConnection();
|
||||
|
||||
if (!_acceptConnections || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Brief delay before accepting next connection
|
||||
try
|
||||
{
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_connectionTcs.TrySetCanceled();
|
||||
Trace.Info("Connection loop ended");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the current client connection without stopping the listener.
|
||||
/// </summary>
|
||||
private void CleanupConnection()
|
||||
{
|
||||
_sendLock.Wait();
|
||||
try
|
||||
{
|
||||
try { _stream?.Close(); } catch { /* best effort */ }
|
||||
try { _client?.Close(); } catch { /* best effort */ }
|
||||
_stream = null;
|
||||
_client = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WaitForConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Trace.Info("Waiting for debug client to connect...");
|
||||
|
||||
using (cancellationToken.Register(() => _connectionTcs.TrySetCanceled()))
|
||||
{
|
||||
await _connectionTcs.Task;
|
||||
}
|
||||
|
||||
Trace.Info("Debug client connected");
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
Trace.Info("Stopping DAP server");
|
||||
|
||||
_acceptConnections = false;
|
||||
_cts?.Cancel();
|
||||
|
||||
CleanupConnection();
|
||||
|
||||
try { _listener?.Stop(); }
|
||||
catch { /* best effort */ }
|
||||
|
||||
if (_connectionLoopTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAny(_connectionLoopTask, Task.Delay(5000));
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
|
||||
Trace.Info("DAP server stopped");
|
||||
}
|
||||
|
||||
private async Task ProcessMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Trace.Info("Starting DAP message processing loop");
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested && _client?.Connected == true)
|
||||
{
|
||||
var json = await ReadMessageAsync(cancellationToken);
|
||||
if (json == null)
|
||||
{
|
||||
Trace.Info("Client disconnected (end of stream)");
|
||||
break;
|
||||
}
|
||||
|
||||
await ProcessSingleMessageAsync(json, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Message processing cancelled");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Trace.Info($"Connection closed ({ex.GetType().Name})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Error in message loop ({ex.GetType().Name})");
|
||||
}
|
||||
|
||||
Trace.Info("DAP message processing loop ended");
|
||||
}
|
||||
|
||||
private async Task ProcessSingleMessageAsync(string json, CancellationToken cancellationToken)
|
||||
{
|
||||
Request request = null;
|
||||
try
|
||||
{
|
||||
request = JsonConvert.DeserializeObject<Request>(json);
|
||||
if (request == null || request.Type != "request")
|
||||
{
|
||||
Trace.Warning("Received DAP message that was not a request");
|
||||
return;
|
||||
}
|
||||
|
||||
Trace.Info("Received DAP request");
|
||||
|
||||
if (_session == null)
|
||||
{
|
||||
Trace.Error("No debug session configured");
|
||||
SendErrorResponse(request, "No debug session configured");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass raw JSON to session — session handles deserialization, dispatch,
|
||||
// and calls back to SendResponse when done.
|
||||
await _session.HandleMessageAsync(json, cancellationToken);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Trace.Error($"Failed to parse request ({ex.GetType().Name})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Error processing request ({ex.GetType().Name})");
|
||||
if (request != null)
|
||||
{
|
||||
SendErrorResponse(request, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SendErrorResponse(Request request, string message)
|
||||
{
|
||||
var response = new Response
|
||||
{
|
||||
Type = "response",
|
||||
RequestSeq = request.Seq,
|
||||
Command = request.Command,
|
||||
Success = false,
|
||||
Message = message,
|
||||
Body = new ErrorResponseBody
|
||||
{
|
||||
Error = new Message
|
||||
{
|
||||
Id = 1,
|
||||
Format = message,
|
||||
ShowUser = true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
SendResponse(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a DAP message using Content-Length framing.
|
||||
/// Format: Content-Length: N\r\n\r\n{json}
|
||||
/// </summary>
|
||||
private async Task<string> ReadMessageAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
int contentLength = -1;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var line = await ReadLineAsync(cancellationToken);
|
||||
if (line == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.Length == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.StartsWith(ContentLengthHeader, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var lengthStr = line.Substring(ContentLengthHeader.Length).Trim();
|
||||
if (!int.TryParse(lengthStr, out contentLength))
|
||||
{
|
||||
throw new InvalidDataException($"Invalid Content-Length: {lengthStr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentLength < 0)
|
||||
{
|
||||
throw new InvalidDataException("Missing Content-Length header");
|
||||
}
|
||||
|
||||
if (contentLength > MaxMessageSize)
|
||||
{
|
||||
throw new InvalidDataException($"Message size {contentLength} exceeds maximum allowed size of {MaxMessageSize}");
|
||||
}
|
||||
|
||||
var buffer = new byte[contentLength];
|
||||
var totalRead = 0;
|
||||
while (totalRead < contentLength)
|
||||
{
|
||||
var bytesRead = await _stream.ReadAsync(buffer, totalRead, contentLength - totalRead, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
throw new EndOfStreamException("Connection closed while reading message body");
|
||||
}
|
||||
totalRead += bytesRead;
|
||||
}
|
||||
|
||||
var json = Encoding.UTF8.GetString(buffer);
|
||||
Trace.Verbose("Received DAP message body");
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a line terminated by \r\n from the network stream.
|
||||
/// </summary>
|
||||
private async Task<string> ReadLineAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var lineBuilder = new StringBuilder();
|
||||
var buffer = new byte[1];
|
||||
var previousWasCr = false;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var bytesRead = await _stream.ReadAsync(buffer, 0, 1, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
return lineBuilder.Length > 0 ? lineBuilder.ToString() : null;
|
||||
}
|
||||
|
||||
var c = (char)buffer[0];
|
||||
|
||||
if (c == '\n' && previousWasCr)
|
||||
{
|
||||
if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r')
|
||||
{
|
||||
lineBuilder.Length--;
|
||||
}
|
||||
return lineBuilder.ToString();
|
||||
}
|
||||
|
||||
previousWasCr = (c == '\r');
|
||||
lineBuilder.Append(c);
|
||||
|
||||
if (lineBuilder.Length > MaxHeaderLineLength)
|
||||
{
|
||||
throw new InvalidDataException($"Header line exceeds maximum length of {MaxHeaderLineLength}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes and writes a DAP message with Content-Length framing.
|
||||
/// Must be called within the _sendLock.
|
||||
///
|
||||
/// Secret masking is intentionally NOT applied here at the serialization
|
||||
/// layer. Masking the raw JSON would corrupt protocol envelope fields
|
||||
/// (type, event, command, seq) if a secret collides with those strings.
|
||||
/// Instead, each DAP producer masks user-visible text at the point of
|
||||
/// construction via <see cref="DapVariableProvider.MaskSecrets"/> or the
|
||||
/// runner's SecretMasker directly. See DapVariableProvider, DapReplExecutor,
|
||||
/// and DapDebugSession for the call sites.
|
||||
/// </summary>
|
||||
private void SendMessageInternal(ProtocolMessage message)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
});
|
||||
|
||||
var bodyBytes = Encoding.UTF8.GetBytes(json);
|
||||
var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n";
|
||||
var headerBytes = Encoding.ASCII.GetBytes(header);
|
||||
|
||||
_stream.Write(headerBytes, 0, headerBytes.Length);
|
||||
_stream.Write(bodyBytes, 0, bodyBytes.Length);
|
||||
_stream.Flush();
|
||||
|
||||
Trace.Verbose("Sent DAP message");
|
||||
}
|
||||
|
||||
public void SendMessage(ProtocolMessage message)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sendLock.Wait();
|
||||
try
|
||||
{
|
||||
if (_stream == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
message.Seq = _nextSeq++;
|
||||
SendMessageInternal(message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Failed to send message ({ex.GetType().Name})");
|
||||
}
|
||||
}
|
||||
|
||||
public void SendEvent(Event evt)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sendLock.Wait();
|
||||
try
|
||||
{
|
||||
if (_stream == null)
|
||||
{
|
||||
Trace.Warning("Cannot send event: no client connected");
|
||||
return;
|
||||
}
|
||||
evt.Seq = _nextSeq++;
|
||||
SendMessageInternal(evt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
Trace.Info("Sent event");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Failed to send event ({ex.GetType().Name})");
|
||||
}
|
||||
}
|
||||
|
||||
public void SendResponse(Response response)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sendLock.Wait();
|
||||
try
|
||||
{
|
||||
if (_stream == null)
|
||||
{
|
||||
Trace.Warning("Cannot send response: no client connected");
|
||||
return;
|
||||
}
|
||||
response.Seq = _nextSeq++;
|
||||
SendMessageInternal(response);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
Trace.Info("Sent response");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Failed to send response ({ex.GetType().Name})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
387
src/Runner.Worker/Dap/DapVariableProvider.cs
Normal file
387
src/Runner.Worker/Dap/DapVariableProvider.cs
Normal file
@@ -0,0 +1,387 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps runner execution context data to DAP scopes and variables.
|
||||
///
|
||||
/// This is the single point where runner context values are materialized
|
||||
/// for the debugger. All string values pass through the runner's existing
|
||||
/// <see cref="GitHub.DistributedTask.Logging.ISecretMasker"/> so the DAP
|
||||
/// surface never exposes anything beyond what a normal CI log would show.
|
||||
///
|
||||
/// The secrets scope is intentionally opaque: keys are visible but every
|
||||
/// value is replaced with a constant redaction marker.
|
||||
///
|
||||
/// Designed to be reusable by future DAP features (evaluate, hover, REPL)
|
||||
/// so that masking policy is never duplicated.
|
||||
/// </summary>
|
||||
internal sealed class DapVariableProvider
|
||||
{
|
||||
// Well-known scope names that map to top-level expression contexts.
|
||||
// Order matters: the index determines the stable variablesReference ID.
|
||||
internal static readonly string[] ScopeNames =
|
||||
{
|
||||
"github", "env", "runner", "job", "steps",
|
||||
"secrets", "inputs", "vars", "matrix", "needs"
|
||||
};
|
||||
|
||||
// Scope references occupy the range [1, ScopeReferenceMax].
|
||||
private const int ScopeReferenceBase = 1;
|
||||
private const int ScopeReferenceMax = 100;
|
||||
|
||||
// Dynamic (nested) variable references start above the scope range.
|
||||
private const int DynamicReferenceBase = 101;
|
||||
|
||||
internal const string RedactedValue = "***";
|
||||
|
||||
private readonly IHostContext _hostContext;
|
||||
|
||||
// Maps dynamic variable reference IDs to the backing data and its
|
||||
// dot-separated path (e.g. "github.event.pull_request").
|
||||
private readonly Dictionary<int, (PipelineContextData Data, string Path)> _variableReferences = new();
|
||||
private int _nextVariableReference = DynamicReferenceBase;
|
||||
|
||||
public DapVariableProvider(IHostContext hostContext)
|
||||
{
|
||||
_hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all dynamic variable references.
|
||||
/// Call this whenever the paused execution context changes (e.g. new step)
|
||||
/// so that stale nested references are not served to the client.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_variableReferences.Clear();
|
||||
_nextVariableReference = DynamicReferenceBase;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the list of DAP scopes for the given execution context.
|
||||
/// Each scope corresponds to a well-known runner expression context
|
||||
/// (github, env, secrets, …) and carries a stable variablesReference
|
||||
/// that the client can use to drill into variables.
|
||||
/// </summary>
|
||||
public List<Scope> GetScopes(IExecutionContext context)
|
||||
{
|
||||
var scopes = new List<Scope>();
|
||||
|
||||
if (context?.ExpressionValues == null)
|
||||
{
|
||||
return scopes;
|
||||
}
|
||||
|
||||
for (int i = 0; i < ScopeNames.Length; i++)
|
||||
{
|
||||
var scopeName = ScopeNames[i];
|
||||
if (!context.ExpressionValues.TryGetValue(scopeName, out var value) || value == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scope = new Scope
|
||||
{
|
||||
Name = scopeName,
|
||||
VariablesReference = ScopeReferenceBase + i,
|
||||
Expensive = false,
|
||||
PresentationHint = scopeName == "secrets" ? "registers" : null
|
||||
};
|
||||
|
||||
if (value is DictionaryContextData dict)
|
||||
{
|
||||
scope.NamedVariables = dict.Count;
|
||||
}
|
||||
else if (value is CaseSensitiveDictionaryContextData csDict)
|
||||
{
|
||||
scope.NamedVariables = csDict.Count;
|
||||
}
|
||||
|
||||
scopes.Add(scope);
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the child variables for a given variablesReference.
|
||||
/// The reference may point at a top-level scope (1–100) or a
|
||||
/// dynamically registered nested container (101+).
|
||||
/// </summary>
|
||||
public List<Variable> GetVariables(IExecutionContext context, int variablesReference)
|
||||
{
|
||||
var variables = new List<Variable>();
|
||||
|
||||
if (context?.ExpressionValues == null)
|
||||
{
|
||||
return variables;
|
||||
}
|
||||
|
||||
PipelineContextData data = null;
|
||||
string basePath = null;
|
||||
bool isSecretsScope = false;
|
||||
|
||||
if (variablesReference >= ScopeReferenceBase && variablesReference <= ScopeReferenceMax)
|
||||
{
|
||||
var scopeIndex = variablesReference - ScopeReferenceBase;
|
||||
if (scopeIndex < ScopeNames.Length)
|
||||
{
|
||||
var scopeName = ScopeNames[scopeIndex];
|
||||
isSecretsScope = scopeName == "secrets";
|
||||
if (context.ExpressionValues.TryGetValue(scopeName, out data))
|
||||
{
|
||||
basePath = scopeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (_variableReferences.TryGetValue(variablesReference, out var refData))
|
||||
{
|
||||
data = refData.Data;
|
||||
basePath = refData.Path;
|
||||
isSecretsScope = basePath?.StartsWith("secrets", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
if (data == null)
|
||||
{
|
||||
return variables;
|
||||
}
|
||||
|
||||
ConvertToVariables(data, basePath, isSecretsScope, variables);
|
||||
return variables;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the runner's secret masker to the given value.
|
||||
/// This is the single masking entry-point for all DAP-visible strings
|
||||
/// and is intentionally public so future DAP features (evaluate, REPL)
|
||||
/// can reuse it without duplicating policy.
|
||||
/// </summary>
|
||||
public string MaskSecrets(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
return _hostContext.SecretMasker.MaskSecrets(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a GitHub Actions expression (e.g. "github.repository",
|
||||
/// "${{ github.event_name }}") in the context of the current step and
|
||||
/// returns a masked result suitable for the DAP evaluate response.
|
||||
///
|
||||
/// Uses the runner's standard <see cref="GitHub.DistributedTask.Pipelines.ObjectTemplating.IPipelineTemplateEvaluator"/>
|
||||
/// so the full expression language is available (functions, operators,
|
||||
/// context access).
|
||||
/// </summary>
|
||||
public EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context)
|
||||
{
|
||||
if (context?.ExpressionValues == null)
|
||||
{
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = "(no execution context available)",
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
|
||||
// Strip ${{ }} wrapper if present
|
||||
var expr = expression?.Trim() ?? string.Empty;
|
||||
if (expr.StartsWith("${{") && expr.EndsWith("}}"))
|
||||
{
|
||||
expr = expr.Substring(3, expr.Length - 5).Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(expr))
|
||||
{
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = string.Empty,
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var templateEvaluator = context.ToPipelineTemplateEvaluator();
|
||||
var token = new BasicExpressionToken(null, null, null, expr);
|
||||
|
||||
var result = templateEvaluator.EvaluateStepDisplayName(
|
||||
token,
|
||||
context.ExpressionValues,
|
||||
context.ExpressionFunctions);
|
||||
|
||||
result = MaskSecrets(result ?? "null");
|
||||
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = result,
|
||||
Type = InferResultType(result),
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorMessage = MaskSecrets($"Evaluation error: {ex.Message}");
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = errorMessage,
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Infers a simple DAP type hint from the string representation of a result.
|
||||
/// </summary>
|
||||
internal static string InferResultType(string value)
|
||||
{
|
||||
if (value == null || value == "null")
|
||||
return "null";
|
||||
if (value == "true" || value == "false")
|
||||
return "boolean";
|
||||
if (double.TryParse(value, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||
return "number";
|
||||
if (value.StartsWith("{") || value.StartsWith("["))
|
||||
return "object";
|
||||
return "string";
|
||||
}
|
||||
|
||||
#region Private helpers
|
||||
|
||||
private void ConvertToVariables(
|
||||
PipelineContextData data,
|
||||
string basePath,
|
||||
bool isSecretsScope,
|
||||
List<Variable> variables)
|
||||
{
|
||||
switch (data)
|
||||
{
|
||||
case DictionaryContextData dict:
|
||||
foreach (var pair in dict)
|
||||
{
|
||||
variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope));
|
||||
}
|
||||
break;
|
||||
|
||||
case CaseSensitiveDictionaryContextData csDict:
|
||||
foreach (var pair in csDict)
|
||||
{
|
||||
variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope));
|
||||
}
|
||||
break;
|
||||
|
||||
case ArrayContextData array:
|
||||
for (int i = 0; i < array.Count; i++)
|
||||
{
|
||||
var variable = CreateVariable($"[{i}]", array[i], basePath, isSecretsScope);
|
||||
variables.Add(variable);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Variable CreateVariable(
|
||||
string name,
|
||||
PipelineContextData value,
|
||||
string basePath,
|
||||
bool isSecretsScope)
|
||||
{
|
||||
var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}";
|
||||
var variable = new Variable
|
||||
{
|
||||
Name = name,
|
||||
EvaluateName = $"${{{{ {childPath} }}}}"
|
||||
};
|
||||
|
||||
if (value == null)
|
||||
{
|
||||
variable.Value = isSecretsScope ? RedactedValue : "null";
|
||||
variable.Type = "null";
|
||||
variable.VariablesReference = 0;
|
||||
return variable;
|
||||
}
|
||||
|
||||
// Secrets scope: redact ALL values regardless of underlying type.
|
||||
// Keys are visible but values are always replaced with the
|
||||
// redaction marker, and nested containers are not drillable.
|
||||
if (isSecretsScope)
|
||||
{
|
||||
variable.Value = RedactedValue;
|
||||
variable.Type = "string";
|
||||
variable.VariablesReference = 0;
|
||||
return variable;
|
||||
}
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case StringContextData str:
|
||||
variable.Value = MaskSecrets(str.Value);
|
||||
variable.Type = "string";
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
|
||||
case NumberContextData num:
|
||||
variable.Value = num.Value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture);
|
||||
variable.Type = "number";
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
|
||||
case BooleanContextData boolVal:
|
||||
variable.Value = boolVal.Value ? "true" : "false";
|
||||
variable.Type = "boolean";
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
|
||||
case DictionaryContextData dict:
|
||||
variable.Value = $"Object ({dict.Count} properties)";
|
||||
variable.Type = "object";
|
||||
variable.VariablesReference = RegisterVariableReference(dict, childPath);
|
||||
variable.NamedVariables = dict.Count;
|
||||
break;
|
||||
|
||||
case CaseSensitiveDictionaryContextData csDict:
|
||||
variable.Value = $"Object ({csDict.Count} properties)";
|
||||
variable.Type = "object";
|
||||
variable.VariablesReference = RegisterVariableReference(csDict, childPath);
|
||||
variable.NamedVariables = csDict.Count;
|
||||
break;
|
||||
|
||||
case ArrayContextData array:
|
||||
variable.Value = $"Array ({array.Count} items)";
|
||||
variable.Type = "array";
|
||||
variable.VariablesReference = RegisterVariableReference(array, childPath);
|
||||
variable.IndexedVariables = array.Count;
|
||||
break;
|
||||
|
||||
default:
|
||||
var rawValue = value.ToJToken()?.ToString() ?? "unknown";
|
||||
variable.Value = MaskSecrets(rawValue);
|
||||
variable.Type = value.GetType().Name;
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return variable;
|
||||
}
|
||||
|
||||
private int RegisterVariableReference(PipelineContextData data, string path)
|
||||
{
|
||||
var reference = _nextVariableReference++;
|
||||
_variableReferences[reference] = (data, path);
|
||||
return reference;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
32
src/Runner.Worker/Dap/IDapDebugSession.cs
Normal file
32
src/Runner.Worker/Dap/IDapDebugSession.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
public enum DapSessionState
|
||||
{
|
||||
WaitingForConnection,
|
||||
Initializing,
|
||||
Ready,
|
||||
Paused,
|
||||
Running,
|
||||
Terminated
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(DapDebugSession))]
|
||||
public interface IDapDebugSession : IRunnerService
|
||||
{
|
||||
bool IsActive { get; }
|
||||
DapSessionState State { get; }
|
||||
void SetDapServer(IDapServer server);
|
||||
Task WaitForHandshakeAsync(CancellationToken cancellationToken);
|
||||
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken);
|
||||
void OnStepCompleted(IStep step);
|
||||
void OnJobCompleted();
|
||||
void CancelSession();
|
||||
void HandleClientConnected();
|
||||
void HandleClientDisconnected();
|
||||
Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
18
src/Runner.Worker/Dap/IDapServer.cs
Normal file
18
src/Runner.Worker/Dap/IDapServer.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
[ServiceLocator(Default = typeof(DapServer))]
|
||||
public interface IDapServer : IRunnerService
|
||||
{
|
||||
void SetSession(IDapDebugSession session);
|
||||
Task StartAsync(int port, CancellationToken cancellationToken);
|
||||
Task WaitForConnectionAsync(CancellationToken cancellationToken);
|
||||
Task StopAsync();
|
||||
void SendMessage(ProtocolMessage message);
|
||||
void SendEvent(Event evt);
|
||||
void SendResponse(Response response);
|
||||
}
|
||||
}
|
||||
@@ -968,6 +968,9 @@ namespace GitHub.Runner.Worker
|
||||
// Verbosity (from GitHub.Step_Debug).
|
||||
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
|
||||
|
||||
// Debugger enabled flag (from acquire response).
|
||||
Global.EnableDebugger = message.EnableDebugger;
|
||||
|
||||
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
|
||||
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;
|
||||
}
|
||||
@@ -1328,9 +1331,9 @@ namespace GitHub.Runner.Worker
|
||||
UpdateGlobalStepsContext();
|
||||
}
|
||||
|
||||
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
return new PipelineTemplateEvaluatorWrapper(HostContext, this, traceWriter);
|
||||
return new PipelineTemplateEvaluatorWrapper(HostContext, this, allowServiceContainerCommand, traceWriter);
|
||||
}
|
||||
|
||||
private static void NoOp()
|
||||
@@ -1418,10 +1421,13 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
var allowServiceContainerCommand = (context.Global.Variables.GetBoolean(Constants.Runner.Features.ServiceContainerCommand) ?? false)
|
||||
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_SERVICE_CONTAINER_COMMAND"));
|
||||
|
||||
// Create wrapper?
|
||||
if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER")))
|
||||
{
|
||||
return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter);
|
||||
return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(allowServiceContainerCommand, traceWriter);
|
||||
}
|
||||
|
||||
// Legacy
|
||||
@@ -1433,6 +1439,7 @@ namespace GitHub.Runner.Worker
|
||||
return new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable)
|
||||
{
|
||||
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||
AllowServiceContainerCommand = allowServiceContainerCommand,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace GitHub.Runner.Worker
|
||||
public StepsContext StepsContext { get; set; }
|
||||
public Variables Variables { get; set; }
|
||||
public bool WriteDebug { get; set; }
|
||||
public bool EnableDebugger { get; set; }
|
||||
public string InfrastructureFailureCategory { get; set; }
|
||||
public JObject ContainerHookState { get; set; }
|
||||
public bool HasTemplateEvaluatorMismatch { get; set; }
|
||||
|
||||
@@ -312,7 +312,14 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
// Emit start marker after full context setup so display name expressions resolve correctly
|
||||
if (emitCompositeMarkers)
|
||||
{
|
||||
step.TryUpdateDisplayName(out _);
|
||||
try
|
||||
{
|
||||
step.EvaluateDisplayName(step.ExecutionContext.ExpressionValues, step.ExecutionContext, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("Caught exception while evaluating embedded step display name. {0}", ex);
|
||||
}
|
||||
ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(markerId)}]");
|
||||
stepStopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.Services.WebApi;
|
||||
using Sdk.RSWebApi.Contracts;
|
||||
@@ -112,6 +113,9 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
IExecutionContext jobContext = null;
|
||||
CancellationTokenRegistration? runnerShutdownRegistration = null;
|
||||
IDapServer dapServer = null;
|
||||
IDapDebugSession debugSession = null;
|
||||
CancellationTokenRegistration? dapCancellationRegistration = null;
|
||||
try
|
||||
{
|
||||
// Create the job execution context.
|
||||
@@ -121,6 +125,36 @@ namespace GitHub.Runner.Worker
|
||||
jobContext.Start();
|
||||
jobContext.Debug($"Starting: {message.JobDisplayName}");
|
||||
|
||||
if (jobContext.Global.EnableDebugger)
|
||||
{
|
||||
Trace.Info("Debugger enabled for this job run");
|
||||
|
||||
try
|
||||
{
|
||||
var port = 4711;
|
||||
var portEnv = Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT");
|
||||
if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort))
|
||||
{
|
||||
port = customPort;
|
||||
}
|
||||
|
||||
dapServer = HostContext.GetService<IDapServer>();
|
||||
debugSession = HostContext.GetService<IDapDebugSession>();
|
||||
|
||||
dapServer.SetSession(debugSession);
|
||||
debugSession.SetDapServer(dapServer);
|
||||
|
||||
await dapServer.StartAsync(port, jobRequestCancellationToken);
|
||||
Trace.Info($"DAP server started on port {port}, listening for debugger client");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Failed to start DAP server: {ex.Message}. Job will continue without debugging.");
|
||||
dapServer = null;
|
||||
debugSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
runnerShutdownRegistration = HostContext.RunnerShutdownToken.Register(() =>
|
||||
{
|
||||
// log an issue, then runner get shutdown by Ctrl-C or Ctrl-Break.
|
||||
@@ -219,6 +253,41 @@ namespace GitHub.Runner.Worker
|
||||
await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000));
|
||||
}
|
||||
|
||||
// Wait for DAP debugger client connection and handshake after "Set up job"
|
||||
// so the job page shows the setup step before we block on the debugger
|
||||
if (dapServer != null && debugSession != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info("Waiting for debugger client connection...");
|
||||
await dapServer.WaitForConnectionAsync(jobRequestCancellationToken);
|
||||
Trace.Info("Debugger client connected.");
|
||||
|
||||
await debugSession.WaitForHandshakeAsync(jobRequestCancellationToken);
|
||||
Trace.Info("DAP handshake complete.");
|
||||
|
||||
dapCancellationRegistration = jobRequestCancellationToken.Register(() =>
|
||||
{
|
||||
Trace.Info("Job cancellation requested, cancelling debug session.");
|
||||
debugSession.CancelSession();
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Job was cancelled before debugger client connected. Continuing without debugger.");
|
||||
try { await dapServer.StopAsync(); } catch { }
|
||||
dapServer = null;
|
||||
debugSession = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Failed to complete DAP handshake: {ex.Message}. Job will continue without debugging.");
|
||||
try { await dapServer.StopAsync(); } catch { }
|
||||
dapServer = null;
|
||||
debugSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Run all job steps
|
||||
Trace.Info("Run all job steps.");
|
||||
var stepsRunner = HostContext.GetService<IStepsRunner>();
|
||||
@@ -259,6 +328,25 @@ namespace GitHub.Runner.Worker
|
||||
runnerShutdownRegistration = null;
|
||||
}
|
||||
|
||||
if (dapCancellationRegistration.HasValue)
|
||||
{
|
||||
dapCancellationRegistration.Value.Dispose();
|
||||
dapCancellationRegistration = null;
|
||||
}
|
||||
|
||||
if (dapServer != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info("Stopping DAP server");
|
||||
await dapServer.StopAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Error stopping DAP server: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await ShutdownQueue(throwOnFailure: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace GitHub.Runner.Worker
|
||||
public PipelineTemplateEvaluatorWrapper(
|
||||
IHostContext hostContext,
|
||||
IExecutionContext context,
|
||||
bool allowServiceContainerCommand,
|
||||
ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
ArgUtil.NotNull(hostContext, nameof(hostContext));
|
||||
@@ -40,11 +41,14 @@ namespace GitHub.Runner.Worker
|
||||
_legacyEvaluator = new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable)
|
||||
{
|
||||
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||
AllowServiceContainerCommand = allowServiceContainerCommand,
|
||||
};
|
||||
|
||||
// New evaluator
|
||||
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
|
||||
_newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features: null)
|
||||
var features = WorkflowFeatures.GetDefaults();
|
||||
features.AllowServiceContainerCommand = allowServiceContainerCommand;
|
||||
_newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features)
|
||||
{
|
||||
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||
};
|
||||
@@ -401,6 +405,18 @@ namespace GitHub.Runner.Worker
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyResult.Entrypoint, newResult.Entrypoint, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareJobContainer mismatch - Entrypoint differs (legacy='{legacyResult.Entrypoint}', new='{newResult.Entrypoint}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyResult.Command, newResult.Command, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareJobContainer mismatch - Command differs (legacy='{legacyResult.Command}', new='{newResult.Command}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CompareDictionaries(legacyResult.Environment, newResult.Environment, "Environment"))
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -10,6 +10,7 @@ using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using GitHub.Runner.Worker.Expressions;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
@@ -50,6 +51,16 @@ namespace GitHub.Runner.Worker
|
||||
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
|
||||
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
||||
bool checkPostJobActions = false;
|
||||
IDapDebugSession debugSession = null;
|
||||
try
|
||||
{
|
||||
debugSession = HostContext.GetService<IDapDebugSession>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Debug session not available — continue without debugging
|
||||
}
|
||||
bool isFirstStep = true;
|
||||
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
|
||||
{
|
||||
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
||||
@@ -226,9 +237,35 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pause for DAP debugger before step execution
|
||||
if (debugSession?.IsActive == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
await debugSession.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP OnStepStarting error: {ex.Message}");
|
||||
}
|
||||
isFirstStep = false;
|
||||
}
|
||||
|
||||
// Run the step
|
||||
await RunStepAsync(step, jobContext.CancellationToken);
|
||||
CompleteStep(step);
|
||||
|
||||
if (debugSession?.IsActive == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
debugSession.OnStepCompleted(step);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP OnStepCompleted error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -255,6 +292,18 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
Trace.Info($"Current state: job state = '{jobContext.Result}'");
|
||||
}
|
||||
|
||||
if (debugSession?.IsActive == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
debugSession.OnJobCompleted();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP OnJobCompleted error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)
|
||||
|
||||
@@ -253,6 +253,13 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool EnableDebugger
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of variables associated with the current context.
|
||||
/// </summary>
|
||||
|
||||
@@ -39,6 +39,24 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container entrypoint override.
|
||||
/// </summary>
|
||||
public String Entrypoint
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container command and args (after the image name).
|
||||
/// </summary>
|
||||
public String Command
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the volumes which are mounted into the container.
|
||||
/// </summary>
|
||||
|
||||
@@ -47,6 +47,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
public const String NumberStrategyContext = "number-strategy-context";
|
||||
public const String On = "on";
|
||||
public const String Options = "options";
|
||||
public const String Entrypoint = "entrypoint";
|
||||
public const String Command = "command";
|
||||
public const String Outputs = "outputs";
|
||||
public const String OutputsPattern = "needs.*.outputs";
|
||||
public const String Password = "password";
|
||||
|
||||
@@ -237,7 +237,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
internal static JobContainer ConvertToJobContainer(
|
||||
TemplateContext context,
|
||||
TemplateToken value,
|
||||
bool allowExpressions = false)
|
||||
bool allowExpressions = false,
|
||||
bool allowServiceContainerCommand = false)
|
||||
{
|
||||
var result = new JobContainer();
|
||||
if (allowExpressions && value.Traverse().Any(x => x is ExpressionToken))
|
||||
@@ -280,6 +281,22 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
case PipelineTemplateConstants.Options:
|
||||
result.Options = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case PipelineTemplateConstants.Entrypoint:
|
||||
if (!allowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Entrypoint}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Entrypoint = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case PipelineTemplateConstants.Command:
|
||||
if (!allowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Command}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Command = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case PipelineTemplateConstants.Ports:
|
||||
var ports = containerPropertyPair.Value.AssertSequence($"{PipelineTemplateConstants.Container} {propertyName}");
|
||||
var portList = new List<String>(ports.Count);
|
||||
@@ -326,7 +343,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
internal static List<KeyValuePair<String, JobContainer>> ConvertToJobServiceContainers(
|
||||
TemplateContext context,
|
||||
TemplateToken services,
|
||||
bool allowExpressions = false)
|
||||
bool allowExpressions = false,
|
||||
bool allowServiceContainerCommand = false)
|
||||
{
|
||||
var result = new List<KeyValuePair<String, JobContainer>>();
|
||||
|
||||
@@ -340,7 +358,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
foreach (var servicePair in servicesMapping)
|
||||
{
|
||||
var networkAlias = servicePair.Key.AssertString("services key").Value;
|
||||
var container = ConvertToJobContainer(context, servicePair.Value);
|
||||
var container = ConvertToJobContainer(context, servicePair.Value, allowExpressions, allowServiceContainerCommand);
|
||||
result.Add(new KeyValuePair<String, JobContainer>(networkAlias, container));
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
|
||||
public Int32 MaxResultSize { get; set; } = 10 * 1024 * 1024; // 10 mb
|
||||
|
||||
public bool AllowServiceContainerCommand { get; set; }
|
||||
|
||||
public Boolean EvaluateStepContinueOnError(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
@@ -357,7 +359,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
{
|
||||
token = TemplateEvaluator.Evaluate(context, PipelineTemplateConstants.Services, token, 0, null, omitHeader: true);
|
||||
context.Errors.Check();
|
||||
result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token);
|
||||
result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token, allowServiceContainerCommand: AllowServiceContainerCommand);
|
||||
}
|
||||
catch (Exception ex) when (!(ex is TemplateValidationException))
|
||||
{
|
||||
|
||||
@@ -430,6 +430,21 @@
|
||||
}
|
||||
},
|
||||
|
||||
"service-container-mapping": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"image": "string",
|
||||
"options": "string",
|
||||
"entrypoint": "string",
|
||||
"command": "string",
|
||||
"env": "container-env",
|
||||
"ports": "sequence-of-non-empty-string",
|
||||
"volumes": "sequence-of-non-empty-string",
|
||||
"credentials": "container-registry-credentials"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"services": {
|
||||
"context": [
|
||||
"github",
|
||||
@@ -454,7 +469,7 @@
|
||||
],
|
||||
"one-of": [
|
||||
"string",
|
||||
"container-mapping"
|
||||
"service-container-mapping"
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
public const String NumberStrategyContext = "number-strategy-context";
|
||||
public const String On = "on";
|
||||
public const String Options = "options";
|
||||
public const String Entrypoint = "entrypoint";
|
||||
public const String Command = "command";
|
||||
public const String Org = "org";
|
||||
public const String Organization = "organization";
|
||||
public const String Outputs = "outputs";
|
||||
|
||||
@@ -1079,7 +1079,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
internal static JobContainer ConvertToJobContainer(
|
||||
TemplateContext context,
|
||||
TemplateToken value,
|
||||
bool isEarlyValidation = false)
|
||||
bool isEarlyValidation = false,
|
||||
bool isServiceContainer = false)
|
||||
{
|
||||
var result = new JobContainer();
|
||||
if (isEarlyValidation && value.Traverse().Any(x => x is ExpressionToken))
|
||||
@@ -1089,11 +1090,34 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
|
||||
if (value is StringToken containerLiteral)
|
||||
{
|
||||
if (String.IsNullOrEmpty(containerLiteral.Value))
|
||||
// Trim "docker://"
|
||||
var trimmedImage = containerLiteral.Value;
|
||||
var hasDockerPrefix = containerLiteral.Value.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal);
|
||||
if (hasDockerPrefix)
|
||||
{
|
||||
trimmedImage = trimmedImage.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
|
||||
}
|
||||
|
||||
// Empty shorthand after trimming "docker://" ?
|
||||
if (String.IsNullOrEmpty(trimmedImage))
|
||||
{
|
||||
// Error at parse-time for:
|
||||
// 1. container: 'docker://'
|
||||
// 2. services.foo: ''
|
||||
// 3. services.foo: 'docker://'
|
||||
//
|
||||
// Do not error for:
|
||||
// 1. container: ''
|
||||
if (isEarlyValidation && (hasDockerPrefix || isServiceContainer))
|
||||
{
|
||||
context.Error(value, "Container image cannot be empty");
|
||||
}
|
||||
|
||||
// Short-circuit
|
||||
return null;
|
||||
}
|
||||
|
||||
// Store original, trimmed further below
|
||||
result.Image = containerLiteral.Value;
|
||||
}
|
||||
else
|
||||
@@ -1122,6 +1146,22 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
case WorkflowTemplateConstants.Options:
|
||||
result.Options = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case WorkflowTemplateConstants.Entrypoint:
|
||||
if (!context.GetFeatures().AllowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Entrypoint}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Entrypoint = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case WorkflowTemplateConstants.Command:
|
||||
if (!context.GetFeatures().AllowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Command}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Command = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case WorkflowTemplateConstants.Ports:
|
||||
var ports = containerPropertyPair.Value.AssertSequence($"{WorkflowTemplateConstants.Container} {propertyName}");
|
||||
var portList = new List<String>(ports.Count);
|
||||
@@ -1152,22 +1192,30 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
}
|
||||
}
|
||||
|
||||
// Trim "docker://"
|
||||
var hadDockerPrefix = false;
|
||||
if (!String.IsNullOrEmpty(result.Image) && result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
hadDockerPrefix = true;
|
||||
result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(result.Image))
|
||||
{
|
||||
// Only error during early validation (parse time)
|
||||
// At runtime (expression evaluation), empty image = no container
|
||||
if (isEarlyValidation)
|
||||
// Error at parse-time for:
|
||||
// 1. container: {image: 'docker://'}
|
||||
// 2. services.foo: {image: ''}
|
||||
// 3. services.foo: {image: 'docker://'}
|
||||
//
|
||||
// Do not error for:
|
||||
// 1. container: {image: ''}
|
||||
if (isEarlyValidation && (hadDockerPrefix || isServiceContainer))
|
||||
{
|
||||
context.Error(value, "Container image cannot be empty");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1188,7 +1236,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
foreach (var servicePair in servicesMapping)
|
||||
{
|
||||
var networkAlias = servicePair.Key.AssertString("services key").Value;
|
||||
var container = ConvertToJobContainer(context, servicePair.Value);
|
||||
var container = ConvertToJobContainer(context, servicePair.Value, isEarlyValidation, isServiceContainer: true);
|
||||
result.Add(new KeyValuePair<String, JobContainer>(networkAlias, container));
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,24 @@ namespace GitHub.Actions.WorkflowParser
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container entrypoint override.
|
||||
/// </summary>
|
||||
public String Entrypoint
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container command and args (after the image name).
|
||||
/// </summary>
|
||||
public String Command
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the volumes which are mounted into the container.
|
||||
/// </summary>
|
||||
|
||||
@@ -48,6 +48,13 @@ namespace GitHub.Actions.WorkflowParser
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool StrictJsonParsing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether service containers may specify "entrypoint" and "command".
|
||||
/// Used during parsing and evaluation.
|
||||
/// </summary>
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool AllowServiceContainerCommand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default workflow features.
|
||||
/// </summary>
|
||||
@@ -60,6 +67,7 @@ namespace GitHub.Actions.WorkflowParser
|
||||
Snapshot = false, // Default to false since this feature is still in an experimental phase
|
||||
StrictJsonParsing = false, // Default to false since this is temporary for telemetry purposes only
|
||||
AllowModelsPermission = false, // Default to false since we want this to be disabled for all non-production environments
|
||||
AllowServiceContainerCommand = false, // Default to false since this feature is gated by actions_service_container_command
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2589,21 +2589,53 @@
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "non-empty-string",
|
||||
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
|
||||
"type": "string",
|
||||
"description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name."
|
||||
},
|
||||
"options": {
|
||||
"type": "string",
|
||||
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
|
||||
"description": "Additional Docker container resource options."
|
||||
},
|
||||
"env": "container-env",
|
||||
"ports": {
|
||||
"type": "sequence-of-non-empty-string",
|
||||
"description": "Use `jobs.<job_id>.container.ports` to set an array of ports to expose on the container."
|
||||
"description": "An array of ports to expose on the container."
|
||||
},
|
||||
"volumes": {
|
||||
"type": "sequence-of-non-empty-string",
|
||||
"description": "Use `jobs.<job_id>.container.volumes` to set an array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
|
||||
"description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
|
||||
},
|
||||
"credentials": "container-registry-credentials"
|
||||
}
|
||||
}
|
||||
},
|
||||
"service-container-mapping": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name."
|
||||
},
|
||||
"options": {
|
||||
"type": "string",
|
||||
"description": "Additional Docker container resource options."
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "string",
|
||||
"description": "Override the default ENTRYPOINT in the service container image."
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Override the default CMD in the service container image."
|
||||
},
|
||||
"env": "container-env",
|
||||
"ports": {
|
||||
"type": "sequence-of-non-empty-string",
|
||||
"description": "An array of ports to expose on the container."
|
||||
},
|
||||
"volumes": {
|
||||
"type": "sequence-of-non-empty-string",
|
||||
"description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
|
||||
},
|
||||
"credentials": "container-registry-credentials"
|
||||
}
|
||||
@@ -2634,12 +2666,12 @@
|
||||
"matrix"
|
||||
],
|
||||
"one-of": [
|
||||
"non-empty-string",
|
||||
"container-mapping"
|
||||
"string",
|
||||
"service-container-mapping"
|
||||
]
|
||||
},
|
||||
"container-registry-credentials": {
|
||||
"description": "If the image's container registry requires authentication to pull the image, you can use `jobs.<job_id>.container.credentials` to set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.",
|
||||
"description": "If the container registry requires authentication to pull the image, set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.",
|
||||
"context": [
|
||||
"github",
|
||||
"inputs",
|
||||
@@ -2655,7 +2687,7 @@
|
||||
}
|
||||
},
|
||||
"container-env": {
|
||||
"description": "Use `jobs.<job_id>.container.env` to set a map of variables in the container.",
|
||||
"description": "A map of environment variables to set in the container.",
|
||||
"mapping": {
|
||||
"loose-key-type": "non-empty-string",
|
||||
"loose-value-type": "string-runner-context"
|
||||
|
||||
@@ -228,8 +228,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
.Returns(Task.FromResult(new TaskAgent()));
|
||||
|
||||
|
||||
var ex = await Assert.ThrowsAsync<TaskCanceledException>(() => updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
|
||||
Assert.Contains($"failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts", ex.Message);
|
||||
var result = await updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -281,8 +281,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
.Returns(Task.FromResult(new TaskAgent()));
|
||||
|
||||
|
||||
var ex = await Assert.ThrowsAsync<Exception>(() => updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
|
||||
Assert.Contains("did not match expected Runner Hash", ex.Message);
|
||||
var result = await updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -170,8 +170,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
DownloadUrl = "https://github.com/actions/runner/notexists"
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<TaskCanceledException>(() => updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
|
||||
Assert.Contains($"failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts", ex.Message);
|
||||
var result = await updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -220,8 +220,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
SHA256Checksum = "badhash"
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<Exception>(() => updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
|
||||
Assert.Contains("did not match expected Runner Hash", ex.Message);
|
||||
var result = await updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
76
src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs
Normal file
76
src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.Serialization.Json;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Actions.RunService.WebApi.Tests;
|
||||
|
||||
public sealed class AgentJobRequestMessageL0
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyEnableDebuggerDeserialization_WithTrue()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyEnableDebuggerDeserialization_DefaultToFalse()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyEnableDebuggerDeserialization_WithFalse()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
|
||||
}
|
||||
|
||||
private static string DoubleQuotify(string text)
|
||||
{
|
||||
return text.Replace('\'', '"');
|
||||
}
|
||||
}
|
||||
1294
src/Test/L0/Worker/DapDebugSessionL0.cs
Normal file
1294
src/Test/L0/Worker/DapDebugSessionL0.cs
Normal file
File diff suppressed because it is too large
Load Diff
233
src/Test/L0/Worker/DapMessagesL0.cs
Normal file
233
src/Test/L0/Worker/DapMessagesL0.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class DapMessagesL0
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RequestSerializesCorrectly()
|
||||
{
|
||||
var request = new Request
|
||||
{
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = "initialize",
|
||||
Arguments = JObject.FromObject(new { clientID = "test-client" })
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(request);
|
||||
var deserialized = JsonConvert.DeserializeObject<Request>(json);
|
||||
|
||||
Assert.Equal(1, deserialized.Seq);
|
||||
Assert.Equal("request", deserialized.Type);
|
||||
Assert.Equal("initialize", deserialized.Command);
|
||||
Assert.Equal("test-client", deserialized.Arguments["clientID"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ResponseSerializesCorrectly()
|
||||
{
|
||||
var response = new Response
|
||||
{
|
||||
Seq = 2,
|
||||
Type = "response",
|
||||
RequestSeq = 1,
|
||||
Success = true,
|
||||
Command = "initialize",
|
||||
Body = new Capabilities { SupportsConfigurationDoneRequest = true }
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(response);
|
||||
var deserialized = JsonConvert.DeserializeObject<Response>(json);
|
||||
|
||||
Assert.Equal(2, deserialized.Seq);
|
||||
Assert.Equal("response", deserialized.Type);
|
||||
Assert.Equal(1, deserialized.RequestSeq);
|
||||
Assert.True(deserialized.Success);
|
||||
Assert.Equal("initialize", deserialized.Command);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EventSerializesWithCorrectType()
|
||||
{
|
||||
var evt = new Event
|
||||
{
|
||||
EventType = "stopped",
|
||||
Body = new StoppedEventBody
|
||||
{
|
||||
Reason = "entry",
|
||||
Description = "Stopped at entry",
|
||||
ThreadId = 1,
|
||||
AllThreadsStopped = true
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Equal("event", evt.Type);
|
||||
|
||||
var json = JsonConvert.SerializeObject(evt);
|
||||
Assert.Contains("\"type\":\"event\"", json);
|
||||
Assert.Contains("\"event\":\"stopped\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StoppedEventBodyOmitsNullFields()
|
||||
{
|
||||
var body = new StoppedEventBody
|
||||
{
|
||||
Reason = "step"
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(body);
|
||||
Assert.Contains("\"reason\":\"step\"", json);
|
||||
Assert.DoesNotContain("\"threadId\"", json);
|
||||
Assert.DoesNotContain("\"allThreadsStopped\"", json);
|
||||
Assert.DoesNotContain("\"description\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void CapabilitiesMvpDefaults()
|
||||
{
|
||||
var caps = new Capabilities
|
||||
{
|
||||
SupportsConfigurationDoneRequest = true,
|
||||
SupportsFunctionBreakpoints = false,
|
||||
SupportsStepBack = false
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(caps);
|
||||
var deserialized = JsonConvert.DeserializeObject<Capabilities>(json);
|
||||
|
||||
Assert.True(deserialized.SupportsConfigurationDoneRequest);
|
||||
Assert.False(deserialized.SupportsFunctionBreakpoints);
|
||||
Assert.False(deserialized.SupportsStepBack);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ContinueResponseBodySerialization()
|
||||
{
|
||||
var body = new ContinueResponseBody { AllThreadsContinued = true };
|
||||
var json = JsonConvert.SerializeObject(body);
|
||||
var deserialized = JsonConvert.DeserializeObject<ContinueResponseBody>(json);
|
||||
|
||||
Assert.True(deserialized.AllThreadsContinued);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ThreadsResponseBodySerialization()
|
||||
{
|
||||
var body = new ThreadsResponseBody
|
||||
{
|
||||
Threads = new List<Thread>
|
||||
{
|
||||
new Thread { Id = 1, Name = "Job Thread" }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(body);
|
||||
var deserialized = JsonConvert.DeserializeObject<ThreadsResponseBody>(json);
|
||||
|
||||
Assert.Single(deserialized.Threads);
|
||||
Assert.Equal(1, deserialized.Threads[0].Id);
|
||||
Assert.Equal("Job Thread", deserialized.Threads[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StackFrameSerialization()
|
||||
{
|
||||
var frame = new StackFrame
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Step: Checkout",
|
||||
Line = 1,
|
||||
Column = 1,
|
||||
PresentationHint = "normal"
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(frame);
|
||||
var deserialized = JsonConvert.DeserializeObject<StackFrame>(json);
|
||||
|
||||
Assert.Equal(1, deserialized.Id);
|
||||
Assert.Equal("Step: Checkout", deserialized.Name);
|
||||
Assert.Equal("normal", deserialized.PresentationHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ExitedEventBodySerialization()
|
||||
{
|
||||
var body = new ExitedEventBody { ExitCode = 130 };
|
||||
var json = JsonConvert.SerializeObject(body);
|
||||
var deserialized = JsonConvert.DeserializeObject<ExitedEventBody>(json);
|
||||
|
||||
Assert.Equal(130, deserialized.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void DapCommandEnumValues()
|
||||
{
|
||||
Assert.Equal(0, (int)DapCommand.Continue);
|
||||
Assert.Equal(1, (int)DapCommand.Next);
|
||||
Assert.Equal(4, (int)DapCommand.Disconnect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RequestDeserializesFromRawJson()
|
||||
{
|
||||
var json = @"{""seq"":5,""type"":""request"",""command"":""continue"",""arguments"":{""threadId"":1}}";
|
||||
var request = JsonConvert.DeserializeObject<Request>(json);
|
||||
|
||||
Assert.Equal(5, request.Seq);
|
||||
Assert.Equal("request", request.Type);
|
||||
Assert.Equal("continue", request.Command);
|
||||
Assert.Equal(1, request.Arguments["threadId"].Value<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ErrorResponseBodySerialization()
|
||||
{
|
||||
var body = new ErrorResponseBody
|
||||
{
|
||||
Error = new Message
|
||||
{
|
||||
Id = 1,
|
||||
Format = "Something went wrong",
|
||||
ShowUser = true
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(body);
|
||||
var deserialized = JsonConvert.DeserializeObject<ErrorResponseBody>(json);
|
||||
|
||||
Assert.Equal(1, deserialized.Error.Id);
|
||||
Assert.Equal("Something went wrong", deserialized.Error.Format);
|
||||
Assert.True(deserialized.Error.ShowUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
230
src/Test/L0/Worker/DapReplExecutorL0.cs
Normal file
230
src/Test/L0/Worker/DapReplExecutorL0.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.Runner.Common.Tests;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class DapReplExecutorL0
|
||||
{
|
||||
private TestHostContext _hc;
|
||||
private DapReplExecutor _executor;
|
||||
private Mock<IDapServer> _mockServer;
|
||||
private List<Event> _sentEvents;
|
||||
|
||||
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
|
||||
{
|
||||
_hc = new TestHostContext(this, testName);
|
||||
_sentEvents = new List<Event>();
|
||||
_mockServer = new Mock<IDapServer>();
|
||||
_mockServer.Setup(x => x.SendEvent(It.IsAny<Event>()))
|
||||
.Callback<Event>(e => _sentEvents.Add(e));
|
||||
_executor = new DapReplExecutor(_hc, _mockServer.Object);
|
||||
return _hc;
|
||||
}
|
||||
|
||||
private Mock<IExecutionContext> CreateMockContext(
|
||||
DictionaryContextData exprValues = null,
|
||||
IDictionary<string, IDictionary<string, string>> jobDefaults = null)
|
||||
{
|
||||
var mock = new Mock<IExecutionContext>();
|
||||
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
|
||||
mock.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
|
||||
var global = new GlobalContext
|
||||
{
|
||||
PrependPath = new List<string>(),
|
||||
JobDefaults = jobDefaults
|
||||
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
mock.Setup(x => x.Global).Returns(global);
|
||||
|
||||
return mock;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ExecuteRunCommand_NullContext_ReturnsError()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var command = new RunCommand { Script = "echo hello" };
|
||||
var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("error", result.Type);
|
||||
Assert.Contains("No execution context available", result.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ExpandExpressions_NoExpressions_ReturnsInput()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var context = CreateMockContext();
|
||||
var result = _executor.ExpandExpressions("echo hello", context.Object);
|
||||
|
||||
Assert.Equal("echo hello", result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ExpandExpressions_NullInput_ReturnsEmpty()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var context = CreateMockContext();
|
||||
var result = _executor.ExpandExpressions(null, context.Object);
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ExpandExpressions_EmptyInput_ReturnsEmpty()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var context = CreateMockContext();
|
||||
var result = _executor.ExpandExpressions("", context.Object);
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ExpandExpressions_UnterminatedExpression_KeepsLiteral()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var context = CreateMockContext();
|
||||
var result = _executor.ExpandExpressions("echo ${{ github.repo", context.Object);
|
||||
|
||||
Assert.Equal("echo ${{ github.repo", result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ResolveDefaultShell_NoJobDefaults_ReturnsPlatformDefault()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var context = CreateMockContext();
|
||||
var result = _executor.ResolveDefaultShell(context.Object);
|
||||
|
||||
#if OS_WINDOWS
|
||||
Assert.True(result == "pwsh" || result == "powershell");
|
||||
#else
|
||||
Assert.Equal("sh", result);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ResolveDefaultShell_WithJobDefault_ReturnsJobDefault()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var jobDefaults = new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["run"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["shell"] = "bash"
|
||||
}
|
||||
};
|
||||
var context = CreateMockContext(jobDefaults: jobDefaults);
|
||||
var result = _executor.ResolveDefaultShell(context.Object);
|
||||
|
||||
Assert.Equal("bash", result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void BuildEnvironment_MergesEnvContextAndReplOverrides()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var envData = new DictionaryContextData
|
||||
{
|
||||
["FOO"] = new StringContextData("bar"),
|
||||
};
|
||||
exprValues["env"] = envData;
|
||||
|
||||
var context = CreateMockContext(exprValues);
|
||||
var replEnv = new Dictionary<string, string> { { "BAZ", "qux" } };
|
||||
var result = _executor.BuildEnvironment(context.Object, replEnv);
|
||||
|
||||
Assert.Equal("bar", result["FOO"]);
|
||||
Assert.Equal("qux", result["BAZ"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void BuildEnvironment_ReplOverridesWin()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var envData = new DictionaryContextData
|
||||
{
|
||||
["FOO"] = new StringContextData("original"),
|
||||
};
|
||||
exprValues["env"] = envData;
|
||||
|
||||
var context = CreateMockContext(exprValues);
|
||||
var replEnv = new Dictionary<string, string> { { "FOO", "override" } };
|
||||
var result = _executor.BuildEnvironment(context.Object, replEnv);
|
||||
|
||||
Assert.Equal("override", result["FOO"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var envData = new DictionaryContextData
|
||||
{
|
||||
["FOO"] = new StringContextData("bar"),
|
||||
};
|
||||
exprValues["env"] = envData;
|
||||
|
||||
var context = CreateMockContext(exprValues);
|
||||
var result = _executor.BuildEnvironment(context.Object, null);
|
||||
|
||||
Assert.Equal("bar", result["FOO"]);
|
||||
Assert.False(result.ContainsKey("BAZ"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
314
src/Test/L0/Worker/DapReplParserL0.cs
Normal file
314
src/Test/L0/Worker/DapReplParserL0.cs
Normal file
@@ -0,0 +1,314 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using GitHub.Runner.Common.Tests;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class DapReplParserL0
|
||||
{
|
||||
#region help command
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_HelpReturnsHelpCommand()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("help", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var help = Assert.IsType<HelpCommand>(cmd);
|
||||
Assert.Null(help.Topic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_HelpCaseInsensitive()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("Help", out var error);
|
||||
Assert.Null(error);
|
||||
Assert.IsType<HelpCommand>(cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_HelpWithTopic()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("help(\"run\")", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var help = Assert.IsType<HelpCommand>(cmd);
|
||||
Assert.Equal("run", help.Topic);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region run command — basic
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunSimpleScript()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo hello\")", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("echo hello", run.Script);
|
||||
Assert.Null(run.Shell);
|
||||
Assert.Null(run.Env);
|
||||
Assert.Null(run.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithShell()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo hello\", shell: \"bash\")", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("echo hello", run.Script);
|
||||
Assert.Equal("bash", run.Shell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithWorkingDirectory()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"ls\", working_directory: \"/tmp\")", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("ls", run.Script);
|
||||
Assert.Equal("/tmp", run.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithEnv()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo $FOO\", env: { FOO: \"bar\" })", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("echo $FOO", run.Script);
|
||||
Assert.NotNull(run.Env);
|
||||
Assert.Equal("bar", run.Env["FOO"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithMultipleEnvVars()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo\", env: { A: \"1\", B: \"2\" })", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal(2, run.Env.Count);
|
||||
Assert.Equal("1", run.Env["A"]);
|
||||
Assert.Equal("2", run.Env["B"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithAllOptions()
|
||||
{
|
||||
var input = "run(\"echo $X\", shell: \"zsh\", env: { X: \"1\" }, working_directory: \"/tmp\")";
|
||||
var cmd = DapReplParser.TryParse(input, out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("echo $X", run.Script);
|
||||
Assert.Equal("zsh", run.Shell);
|
||||
Assert.Equal("1", run.Env["X"]);
|
||||
Assert.Equal("/tmp", run.WorkingDirectory);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region run command — edge cases
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithEscapedQuotes()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo \\\"hello\\\"\")", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("echo \"hello\"", run.Script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithCommaInEnvValue()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo\", env: { CSV: \"a,b,c\" })", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("a,b,c", run.Env["CSV"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region error cases
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunEmptyArgsReturnsError()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run()", out var error);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Null(cmd);
|
||||
Assert.Contains("requires a script argument", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunUnquotedArgReturnsError()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(echo hello)", out var error);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Null(cmd);
|
||||
Assert.Contains("quoted string", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunUnknownOptionReturnsError()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo\", timeout: \"10\")", out var error);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Null(cmd);
|
||||
Assert.Contains("Unknown option", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunMissingClosingParenReturnsError()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo\"", out var error);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Null(cmd);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region non-DSL input falls through
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ExpressionReturnsNull()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("github.repository", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Null(cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_WrappedExpressionReturnsNull()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("${{ github.event_name }}", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Null(cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_EmptyInputReturnsNull()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("", out var error);
|
||||
Assert.Null(error);
|
||||
Assert.Null(cmd);
|
||||
|
||||
cmd = DapReplParser.TryParse(null, out error);
|
||||
Assert.Null(error);
|
||||
Assert.Null(cmd);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region help text
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetGeneralHelp_ContainsCommands()
|
||||
{
|
||||
var help = DapReplParser.GetGeneralHelp();
|
||||
|
||||
Assert.Contains("help", help);
|
||||
Assert.Contains("run", help);
|
||||
Assert.Contains("expression", help, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetRunHelp_ContainsOptions()
|
||||
{
|
||||
var help = DapReplParser.GetRunHelp();
|
||||
|
||||
Assert.Contains("shell", help);
|
||||
Assert.Contains("env", help);
|
||||
Assert.Contains("working_directory", help);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region internal parser helpers
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SplitArguments_HandlesNestedBraces()
|
||||
{
|
||||
var args = DapReplParser.SplitArguments("\"hello\", env: { A: \"1\", B: \"2\" }", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Equal(2, args.Count);
|
||||
Assert.Equal("\"hello\"", args[0].Trim());
|
||||
Assert.Contains("A:", args[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseEnvBlock_HandlesEmptyBlock()
|
||||
{
|
||||
var result = DapReplParser.ParseEnvBlock("{ }", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
403
src/Test/L0/Worker/DapServerL0.cs
Normal file
403
src/Test/L0/Worker/DapServerL0.cs
Normal file
@@ -0,0 +1,403 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class DapServerL0
|
||||
{
|
||||
private DapServer _server;
|
||||
|
||||
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
|
||||
{
|
||||
var hc = new TestHostContext(this, testName);
|
||||
_server = new DapServer();
|
||||
_server.Initialize(hc);
|
||||
return hc;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InitializeSucceeds()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
Assert.NotNull(_server);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SetSessionAcceptsMock()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
_server.SetSession(mockSession.Object);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SendEventNoClientDoesNotThrow()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var evt = new Event
|
||||
{
|
||||
EventType = "stopped",
|
||||
Body = new StoppedEventBody
|
||||
{
|
||||
Reason = "entry",
|
||||
ThreadId = 1,
|
||||
AllThreadsStopped = true
|
||||
}
|
||||
};
|
||||
|
||||
_server.SendEvent(evt);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SendResponseNoClientDoesNotThrow()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var response = new Response
|
||||
{
|
||||
Type = "response",
|
||||
RequestSeq = 1,
|
||||
Command = "initialize",
|
||||
Success = true
|
||||
};
|
||||
|
||||
_server.SendResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SendMessageNoClientDoesNotThrow()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var msg = new ProtocolMessage
|
||||
{
|
||||
Type = "response",
|
||||
Seq = 1
|
||||
};
|
||||
|
||||
_server.SendMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task StopWithoutStartDoesNotThrow()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
await _server.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task StartAndStopOnAvailablePort()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
await _server.StartAsync(0, cts.Token);
|
||||
await _server.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task WaitForConnectionCancelledByCancellationToken()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
await _server.StartAsync(0, cts.Token);
|
||||
|
||||
var waitTask = _server.WaitForConnectionAsync(cts.Token);
|
||||
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await waitTask;
|
||||
});
|
||||
|
||||
await _server.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task StartAndStopMultipleTimesDoesNotThrow()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var cts1 = new CancellationTokenSource();
|
||||
await _server.StartAsync(0, cts1.Token);
|
||||
await _server.StopAsync();
|
||||
}
|
||||
|
||||
using (CreateTestContext($"{nameof(StartAndStopMultipleTimesDoesNotThrow)}_SecondStart"))
|
||||
{
|
||||
var cts2 = new CancellationTokenSource();
|
||||
await _server.StartAsync(0, cts2.Token);
|
||||
await _server.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task MessageFraming_ValidMessage_ProcessedSuccessfully()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var messageReceived = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.HandleMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, CancellationToken>((json, ct) => messageReceived.TrySetResult(json))
|
||||
.Returns(Task.CompletedTask);
|
||||
_server.SetSession(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await _server.StartAsync(0, cts.Token);
|
||||
|
||||
var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var listener = (TcpListener)listenerField.GetValue(_server);
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
var connectionTask = _server.WaitForConnectionAsync(cts.Token);
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||
await connectionTask;
|
||||
var stream = client.GetStream();
|
||||
|
||||
// Send a valid DAP request with Content-Length framing
|
||||
var requestJson = "{\"seq\":1,\"type\":\"request\",\"command\":\"initialize\"}";
|
||||
var body = Encoding.UTF8.GetBytes(requestJson);
|
||||
var header = $"Content-Length: {body.Length}\r\n\r\n";
|
||||
var headerBytes = Encoding.ASCII.GetBytes(header);
|
||||
|
||||
await stream.WriteAsync(headerBytes, 0, headerBytes.Length);
|
||||
await stream.WriteAsync(body, 0, body.Length);
|
||||
await stream.FlushAsync();
|
||||
|
||||
// Wait for session to receive the message (deterministic, bounded)
|
||||
var completed = await Task.WhenAny(messageReceived.Task, Task.Delay(5000));
|
||||
Assert.Equal(messageReceived.Task, completed);
|
||||
Assert.Contains("initialize", await messageReceived.Task);
|
||||
|
||||
cts.Cancel();
|
||||
await _server.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ProtocolMetadata_PreservedWhenSecretCollidesWithKeywords()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
// Register secrets that match DAP protocol keywords
|
||||
hc.SecretMasker.AddValue("response");
|
||||
hc.SecretMasker.AddValue("output");
|
||||
hc.SecretMasker.AddValue("evaluate");
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await _server.StartAsync(0, cts.Token);
|
||||
|
||||
var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var listener = (TcpListener)listenerField.GetValue(_server);
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
var connectionTask = _server.WaitForConnectionAsync(cts.Token);
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||
await connectionTask;
|
||||
var stream = client.GetStream();
|
||||
|
||||
// Send a response whose protocol fields collide with secrets
|
||||
var response = new Response
|
||||
{
|
||||
Type = "response",
|
||||
RequestSeq = 1,
|
||||
Command = "evaluate",
|
||||
Success = true,
|
||||
Body = new EvaluateResponseBody
|
||||
{
|
||||
Result = "some result",
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
}
|
||||
};
|
||||
|
||||
_server.SendResponse(response);
|
||||
|
||||
// Read a full framed DAP message with timeout
|
||||
var received = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Protocol metadata MUST be preserved even when secrets collide
|
||||
Assert.Contains("\"type\":\"response\"", received);
|
||||
Assert.Contains("\"command\":\"evaluate\"", received);
|
||||
Assert.Contains("\"success\":true", received);
|
||||
|
||||
cts.Cancel();
|
||||
await _server.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ProtocolMetadata_EventFieldsPreservedWhenSecretCollidesWithKeywords()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue("output");
|
||||
hc.SecretMasker.AddValue("stdout");
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await _server.StartAsync(0, cts.Token);
|
||||
|
||||
var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var listener = (TcpListener)listenerField.GetValue(_server);
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
var connectionTask = _server.WaitForConnectionAsync(cts.Token);
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||
await connectionTask;
|
||||
var stream = client.GetStream();
|
||||
|
||||
_server.SendEvent(new Event
|
||||
{
|
||||
EventType = "output",
|
||||
Body = new OutputEventBody
|
||||
{
|
||||
Category = "stdout",
|
||||
Output = "hello world"
|
||||
}
|
||||
});
|
||||
|
||||
// Read a full framed DAP message with timeout
|
||||
var received = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Protocol fields MUST be preserved
|
||||
Assert.Contains("\"type\":\"event\"", received);
|
||||
Assert.Contains("\"event\":\"output\"", received);
|
||||
Assert.Contains("\"category\":\"stdout\"", received);
|
||||
|
||||
cts.Cancel();
|
||||
await _server.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task StopAsync_AwaitsConnectionLoopShutdown()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
await _server.StartAsync(0, cts.Token);
|
||||
|
||||
// Stop should complete within a reasonable time
|
||||
var stopTask = _server.StopAsync();
|
||||
var completed = await Task.WhenAny(stopTask, Task.Delay(10000));
|
||||
Assert.Equal(stopTask, completed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a single DAP-framed message from a stream with a timeout.
|
||||
/// Parses the Content-Length header, reads exactly that many bytes,
|
||||
/// and returns the JSON body. Fails with a clear error on timeout.
|
||||
/// </summary>
|
||||
private static async Task<string> ReadDapMessageAsync(NetworkStream stream, TimeSpan timeout)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
var token = cts.Token;
|
||||
|
||||
// Read headers byte-by-byte until we see \r\n\r\n
|
||||
var headerBuilder = new StringBuilder();
|
||||
var buffer = new byte[1];
|
||||
var contentLength = -1;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var readTask = stream.ReadAsync(buffer, 0, 1, token);
|
||||
var bytesRead = await readTask;
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
throw new EndOfStreamException("Connection closed while reading DAP headers");
|
||||
}
|
||||
|
||||
headerBuilder.Append((char)buffer[0]);
|
||||
var headers = headerBuilder.ToString();
|
||||
if (headers.EndsWith("\r\n\r\n"))
|
||||
{
|
||||
// Parse Content-Length
|
||||
foreach (var line in headers.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (line.StartsWith("Content-Length: ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentLength = int.Parse(line.Substring("Content-Length: ".Length).Trim());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentLength < 0)
|
||||
{
|
||||
throw new InvalidOperationException("No Content-Length header found in DAP message");
|
||||
}
|
||||
|
||||
// Read exactly contentLength bytes
|
||||
var body = new byte[contentLength];
|
||||
var totalRead = 0;
|
||||
while (totalRead < contentLength)
|
||||
{
|
||||
var bytesRead = await stream.ReadAsync(body, totalRead, contentLength - totalRead, token);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
throw new EndOfStreamException("Connection closed while reading DAP body");
|
||||
}
|
||||
totalRead += bytesRead;
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
772
src/Test/L0/Worker/DapVariableProviderL0.cs
Normal file
772
src/Test/L0/Worker/DapVariableProviderL0.cs
Normal file
@@ -0,0 +1,772 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Tests;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class DapVariableProviderL0
|
||||
{
|
||||
private TestHostContext _hc;
|
||||
private DapVariableProvider _provider;
|
||||
|
||||
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
|
||||
{
|
||||
_hc = new TestHostContext(this, testName);
|
||||
_provider = new DapVariableProvider(_hc);
|
||||
return _hc;
|
||||
}
|
||||
|
||||
private Moq.Mock<GitHub.Runner.Worker.IExecutionContext> CreateMockContext(DictionaryContextData expressionValues)
|
||||
{
|
||||
var mock = new Moq.Mock<GitHub.Runner.Worker.IExecutionContext>();
|
||||
mock.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
||||
return mock;
|
||||
}
|
||||
|
||||
#region GetScopes tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetScopes_ReturnsEmptyWhenContextIsNull()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var scopes = _provider.GetScopes(null);
|
||||
Assert.Empty(scopes);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetScopes_ReturnsOnlyPopulatedScopes()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "repository", new StringContextData("owner/repo") }
|
||||
};
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "CI", new StringContextData("true") },
|
||||
{ "HOME", new StringContextData("/home/runner") }
|
||||
};
|
||||
// "runner" is not set — should not appear in scopes
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var scopes = _provider.GetScopes(ctx.Object);
|
||||
|
||||
Assert.Equal(2, scopes.Count);
|
||||
Assert.Equal("github", scopes[0].Name);
|
||||
Assert.Equal("env", scopes[1].Name);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetScopes_ReportsNamedVariableCount()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "A", new StringContextData("1") },
|
||||
{ "B", new StringContextData("2") },
|
||||
{ "C", new StringContextData("3") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var scopes = _provider.GetScopes(ctx.Object);
|
||||
|
||||
Assert.Single(scopes);
|
||||
Assert.Equal(3, scopes[0].NamedVariables);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetScopes_SecretsGetSpecialPresentationHint()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["secrets"] = new DictionaryContextData
|
||||
{
|
||||
{ "MY_SECRET", new StringContextData("super-secret") }
|
||||
};
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "CI", new StringContextData("true") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var scopes = _provider.GetScopes(ctx.Object);
|
||||
|
||||
var envScope = scopes.Find(s => s.Name == "env");
|
||||
var secretsScope = scopes.Find(s => s.Name == "secrets");
|
||||
|
||||
Assert.NotNull(envScope);
|
||||
Assert.Null(envScope.PresentationHint);
|
||||
|
||||
Assert.NotNull(secretsScope);
|
||||
Assert.Equal("registers", secretsScope.PresentationHint);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetScopes_StableVariablesReferenceIds()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
// Populate all 10 scopes and verify their reference IDs
|
||||
// are stable and based on array position
|
||||
var exprValues = new DictionaryContextData();
|
||||
foreach (var name in DapVariableProvider.ScopeNames)
|
||||
{
|
||||
exprValues[name] = new DictionaryContextData();
|
||||
}
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var scopes = _provider.GetScopes(ctx.Object);
|
||||
|
||||
Assert.Equal(DapVariableProvider.ScopeNames.Length, scopes.Count);
|
||||
for (int i = 0; i < scopes.Count; i++)
|
||||
{
|
||||
Assert.Equal(DapVariableProvider.ScopeNames[i], scopes[i].Name);
|
||||
// Reference IDs are 1-based: index 0 -> ref 1, index 1 -> ref 2, etc.
|
||||
Assert.Equal(i + 1, scopes[i].VariablesReference);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetVariables — basic types
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_ReturnsEmptyWhenContextIsNull()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var variables = _provider.GetVariables(null, 1);
|
||||
Assert.Empty(variables);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_ReturnsStringVariables()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "CI", new StringContextData("true") },
|
||||
{ "HOME", new StringContextData("/home/runner") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
// "env" is at ScopeNames index 1 → variablesReference = 2
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
Assert.Equal(2, variables.Count);
|
||||
|
||||
var ciVar = variables.Find(v => v.Name == "CI");
|
||||
Assert.NotNull(ciVar);
|
||||
Assert.Equal("true", ciVar.Value);
|
||||
Assert.Equal("string", ciVar.Type);
|
||||
Assert.Equal(0, ciVar.VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_ReturnsBooleanVariables()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "event_name", new StringContextData("push") },
|
||||
};
|
||||
// Use a nested dict with boolean to test
|
||||
var jobDict = new DictionaryContextData();
|
||||
// BooleanContextData is a valid PipelineContextData type
|
||||
// but job context typically has strings. Use env scope instead.
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "flag", new BooleanContextData(true) }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
// "env" is at index 1 → ref 2
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
var flagVar = variables.Find(v => v.Name == "flag");
|
||||
Assert.NotNull(flagVar);
|
||||
Assert.Equal("true", flagVar.Value);
|
||||
Assert.Equal("boolean", flagVar.Type);
|
||||
Assert.Equal(0, flagVar.VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_ReturnsNumberVariables()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "count", new NumberContextData(42) }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
var countVar = variables.Find(v => v.Name == "count");
|
||||
Assert.NotNull(countVar);
|
||||
Assert.Equal("42", countVar.Value);
|
||||
Assert.Equal("number", countVar.Type);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_HandlesNullValues()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var dict = new DictionaryContextData();
|
||||
dict["present"] = new StringContextData("yes");
|
||||
dict["missing"] = null;
|
||||
exprValues["env"] = dict;
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
var nullVar = variables.Find(v => v.Name == "missing");
|
||||
Assert.NotNull(nullVar);
|
||||
Assert.Equal("null", nullVar.Value);
|
||||
Assert.Equal("null", nullVar.Type);
|
||||
Assert.Equal(0, nullVar.VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetVariables — nested expansion
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_NestedDictionaryIsExpandable()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var innerDict = new DictionaryContextData
|
||||
{
|
||||
{ "name", new StringContextData("push") },
|
||||
{ "ref", new StringContextData("refs/heads/main") }
|
||||
};
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "event", innerDict }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
// "github" is at index 0 → ref 1
|
||||
var variables = _provider.GetVariables(ctx.Object, 1);
|
||||
|
||||
var eventVar = variables.Find(v => v.Name == "event");
|
||||
Assert.NotNull(eventVar);
|
||||
Assert.Equal("object", eventVar.Type);
|
||||
Assert.True(eventVar.VariablesReference > 0, "Nested dict should have a non-zero variablesReference");
|
||||
Assert.Equal(2, eventVar.NamedVariables);
|
||||
|
||||
// Now expand it
|
||||
var children = _provider.GetVariables(ctx.Object, eventVar.VariablesReference);
|
||||
Assert.Equal(2, children.Count);
|
||||
|
||||
var nameVar = children.Find(v => v.Name == "name");
|
||||
Assert.NotNull(nameVar);
|
||||
Assert.Equal("push", nameVar.Value);
|
||||
Assert.Equal("${{ github.event.name }}", nameVar.EvaluateName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_NestedArrayIsExpandable()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var array = new ArrayContextData();
|
||||
array.Add(new StringContextData("item0"));
|
||||
array.Add(new StringContextData("item1"));
|
||||
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "list", array }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
var listVar = variables.Find(v => v.Name == "list");
|
||||
Assert.NotNull(listVar);
|
||||
Assert.Equal("array", listVar.Type);
|
||||
Assert.True(listVar.VariablesReference > 0);
|
||||
Assert.Equal(2, listVar.IndexedVariables);
|
||||
|
||||
// Expand the array
|
||||
var items = _provider.GetVariables(ctx.Object, listVar.VariablesReference);
|
||||
Assert.Equal(2, items.Count);
|
||||
Assert.Equal("[0]", items[0].Name);
|
||||
Assert.Equal("item0", items[0].Value);
|
||||
Assert.Equal("[1]", items[1].Name);
|
||||
Assert.Equal("item1", items[1].Value);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Secret masking
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SecretsScopeValuesAreRedacted()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["secrets"] = new DictionaryContextData
|
||||
{
|
||||
{ "MY_TOKEN", new StringContextData("ghp_abc123secret") },
|
||||
{ "DB_PASSWORD", new StringContextData("p@ssword!") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
// "secrets" is at index 5 → ref 6
|
||||
var variables = _provider.GetVariables(ctx.Object, 6);
|
||||
|
||||
Assert.Equal(2, variables.Count);
|
||||
foreach (var v in variables)
|
||||
{
|
||||
Assert.Equal(DapVariableProvider.RedactedValue, v.Value);
|
||||
Assert.Equal("string", v.Type);
|
||||
}
|
||||
|
||||
// Keys should still be visible
|
||||
Assert.Contains(variables, v => v.Name == "MY_TOKEN");
|
||||
Assert.Contains(variables, v => v.Name == "DB_PASSWORD");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_NonSecretScopeValuesMaskedBySecretMasker()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
// Register a known secret value with the masker
|
||||
hc.SecretMasker.AddValue("super-secret-token");
|
||||
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "SAFE", new StringContextData("hello world") },
|
||||
{ "LEAKED", new StringContextData("prefix-super-secret-token-suffix") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
var safeVar = variables.Find(v => v.Name == "SAFE");
|
||||
Assert.NotNull(safeVar);
|
||||
Assert.Equal("hello world", safeVar.Value);
|
||||
|
||||
var leakedVar = variables.Find(v => v.Name == "LEAKED");
|
||||
Assert.NotNull(leakedVar);
|
||||
Assert.DoesNotContain("super-secret-token", leakedVar.Value);
|
||||
Assert.Contains("***", leakedVar.Value);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MaskSecrets_DelegatesToHostContextSecretMasker()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue("my-secret");
|
||||
|
||||
Assert.Equal("before-***-after", _provider.MaskSecrets("before-my-secret-after"));
|
||||
Assert.Equal("no secrets here", _provider.MaskSecrets("no secrets here"));
|
||||
Assert.Equal(string.Empty, _provider.MaskSecrets(null));
|
||||
Assert.Equal(string.Empty, _provider.MaskSecrets(string.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Reset_InvalidatesNestedReferences()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var innerDict = new DictionaryContextData
|
||||
{
|
||||
{ "name", new StringContextData("push") }
|
||||
};
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "event", innerDict }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 1);
|
||||
var eventVar = variables.Find(v => v.Name == "event");
|
||||
Assert.True(eventVar.VariablesReference > 0);
|
||||
|
||||
var savedRef = eventVar.VariablesReference;
|
||||
|
||||
// Reset should clear all dynamic references
|
||||
_provider.Reset();
|
||||
|
||||
var children = _provider.GetVariables(ctx.Object, savedRef);
|
||||
Assert.Empty(children);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EvaluateName
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SetsEvaluateNameWithDotPath()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "repository", new StringContextData("owner/repo") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 1);
|
||||
|
||||
var repoVar = variables.Find(v => v.Name == "repository");
|
||||
Assert.NotNull(repoVar);
|
||||
Assert.Equal("${{ github.repository }}", repoVar.EvaluateName);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EvaluateExpression
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock execution context with Global set up so that
|
||||
/// ToPipelineTemplateEvaluator() works for real expression evaluation.
|
||||
/// </summary>
|
||||
private Moq.Mock<IExecutionContext> CreateEvaluatableContext(
|
||||
TestHostContext hc,
|
||||
DictionaryContextData expressionValues)
|
||||
{
|
||||
var mock = new Moq.Mock<IExecutionContext>();
|
||||
mock.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
||||
mock.Setup(x => x.ExpressionFunctions)
|
||||
.Returns(new List<GitHub.DistributedTask.Expressions2.IFunctionInfo>());
|
||||
mock.Setup(x => x.Global).Returns(new GlobalContext
|
||||
{
|
||||
FileTable = new List<string>(),
|
||||
Variables = new Variables(hc, new Dictionary<string, VariableValue>()),
|
||||
});
|
||||
// ToPipelineTemplateEvaluator uses ToTemplateTraceWriter which calls
|
||||
// context.Write — provide a no-op so it doesn't NRE.
|
||||
mock.Setup(x => x.Write(Moq.It.IsAny<string>(), Moq.It.IsAny<string>()));
|
||||
return mock;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_ReturnsValueForSimpleExpression()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "repository", new StringContextData("owner/repo") }
|
||||
};
|
||||
|
||||
var ctx = CreateEvaluatableContext(hc, exprValues);
|
||||
var result = _provider.EvaluateExpression("github.repository", ctx.Object);
|
||||
|
||||
Assert.Equal("owner/repo", result.Result);
|
||||
Assert.Equal("string", result.Type);
|
||||
Assert.Equal(0, result.VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_StripsWrapperSyntax()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "event_name", new StringContextData("push") }
|
||||
};
|
||||
|
||||
var ctx = CreateEvaluatableContext(hc, exprValues);
|
||||
var result = _provider.EvaluateExpression("${{ github.event_name }}", ctx.Object);
|
||||
|
||||
Assert.Equal("push", result.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_MasksSecretInResult()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue("super-secret");
|
||||
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "TOKEN", new StringContextData("super-secret") }
|
||||
};
|
||||
|
||||
var ctx = CreateEvaluatableContext(hc, exprValues);
|
||||
var result = _provider.EvaluateExpression("env.TOKEN", ctx.Object);
|
||||
|
||||
Assert.DoesNotContain("super-secret", result.Result);
|
||||
Assert.Contains("***", result.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_ReturnsErrorForInvalidExpression()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData();
|
||||
|
||||
var ctx = CreateEvaluatableContext(hc, exprValues);
|
||||
// An invalid expression syntax should not throw — it should
|
||||
// return an error result.
|
||||
var result = _provider.EvaluateExpression("!!!invalid[[", ctx.Object);
|
||||
|
||||
Assert.Contains("error", result.Result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_ReturnsMessageWhenNoContext()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var result = _provider.EvaluateExpression("github.repository", null);
|
||||
|
||||
Assert.Contains("no execution context", result.Result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_ReturnsEmptyForEmptyExpression()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var ctx = CreateEvaluatableContext(hc, exprValues);
|
||||
var result = _provider.EvaluateExpression("", ctx.Object);
|
||||
|
||||
Assert.Equal(string.Empty, result.Result);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InferResultType
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InferResultType_ClassifiesCorrectly()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
Assert.Equal("null", DapVariableProvider.InferResultType(null));
|
||||
Assert.Equal("null", DapVariableProvider.InferResultType("null"));
|
||||
Assert.Equal("boolean", DapVariableProvider.InferResultType("true"));
|
||||
Assert.Equal("boolean", DapVariableProvider.InferResultType("false"));
|
||||
Assert.Equal("number", DapVariableProvider.InferResultType("42"));
|
||||
Assert.Equal("number", DapVariableProvider.InferResultType("3.14"));
|
||||
Assert.Equal("object", DapVariableProvider.InferResultType("{\"key\":\"val\"}"));
|
||||
Assert.Equal("object", DapVariableProvider.InferResultType("[1,2,3]"));
|
||||
Assert.Equal("string", DapVariableProvider.InferResultType("hello world"));
|
||||
Assert.Equal("string", DapVariableProvider.InferResultType("owner/repo"));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Non-string secret type redaction
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SecretsScopeRedactsNumberContextData()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["secrets"] = new DictionaryContextData
|
||||
{
|
||||
{ "NUMERIC_SECRET", new NumberContextData(12345) }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 6);
|
||||
|
||||
Assert.Single(variables);
|
||||
Assert.Equal("NUMERIC_SECRET", variables[0].Name);
|
||||
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
|
||||
Assert.Equal("string", variables[0].Type);
|
||||
Assert.Equal(0, variables[0].VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SecretsScopeRedactsBooleanContextData()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["secrets"] = new DictionaryContextData
|
||||
{
|
||||
{ "BOOL_SECRET", new BooleanContextData(true) }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 6);
|
||||
|
||||
Assert.Single(variables);
|
||||
Assert.Equal("BOOL_SECRET", variables[0].Name);
|
||||
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
|
||||
Assert.Equal("string", variables[0].Type);
|
||||
Assert.Equal(0, variables[0].VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SecretsScopeRedactsNestedDictionary()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["secrets"] = new DictionaryContextData
|
||||
{
|
||||
{ "NESTED_SECRET", new DictionaryContextData
|
||||
{
|
||||
{ "inner_key", new StringContextData("inner_value") }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 6);
|
||||
|
||||
Assert.Single(variables);
|
||||
Assert.Equal("NESTED_SECRET", variables[0].Name);
|
||||
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
|
||||
Assert.Equal("string", variables[0].Type);
|
||||
// Nested container should NOT be drillable under secrets
|
||||
Assert.Equal(0, variables[0].VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SecretsScopeRedactsNullValue()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var secrets = new DictionaryContextData();
|
||||
secrets["NULL_SECRET"] = null;
|
||||
exprValues["secrets"] = secrets;
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 6);
|
||||
|
||||
Assert.Single(variables);
|
||||
Assert.Equal("NULL_SECRET", variables[0].Name);
|
||||
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
|
||||
Assert.Equal(0, variables[0].VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Handlers;
|
||||
using Moq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using DTWebApi = GitHub.DistributedTask.WebApi;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker.Handlers
|
||||
{
|
||||
@@ -250,6 +254,66 @@ namespace GitHub.Runner.Common.Tests.Worker.Handlers
|
||||
Assert.Equal("##[end-action id=failing-step;outcome=failure;conclusion=success;duration_ms=500]", marker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void PostStepMarker_UsesEvaluatedDisplayName()
|
||||
{
|
||||
// Arrange: create an ActionRunner with a RepositoryPathReference (simulating actions/cache@v4)
|
||||
// and Stage = Post. Verify that EvaluateDisplayName produces the correct display name
|
||||
// so the composite marker emits "Run actions/cache@v4" instead of the fallback "run".
|
||||
var hc = new TestHostContext(this, nameof(PostStepMarker_UsesEvaluatedDisplayName));
|
||||
var actionManifestLegacy = new ActionManifestManagerLegacy();
|
||||
actionManifestLegacy.Initialize(hc);
|
||||
hc.SetSingleton<IActionManifestManagerLegacy>(actionManifestLegacy);
|
||||
var actionManifestNew = new ActionManifestManager();
|
||||
actionManifestNew.Initialize(hc);
|
||||
hc.SetSingleton<IActionManifestManager>(actionManifestNew);
|
||||
var actionManifestManager = new ActionManifestManagerWrapper();
|
||||
actionManifestManager.Initialize(hc);
|
||||
hc.SetSingleton<IActionManifestManagerWrapper>(actionManifestManager);
|
||||
|
||||
var ec = new Mock<IExecutionContext>();
|
||||
var contextData = new DictionaryContextData();
|
||||
var githubContext = new GitHubContext();
|
||||
githubContext.Add("event", JToken.Parse("{\"foo\":\"bar\"}").ToPipelineContextData());
|
||||
contextData.Add("github", githubContext);
|
||||
#if OS_WINDOWS
|
||||
contextData["env"] = new DictionaryContextData();
|
||||
#else
|
||||
contextData["env"] = new CaseSensitiveDictionaryContextData();
|
||||
#endif
|
||||
ec.Setup(x => x.Global).Returns(new GlobalContext());
|
||||
ec.Setup(x => x.ExpressionValues).Returns(contextData);
|
||||
ec.Setup(x => x.ExpressionFunctions).Returns(new List<GitHub.DistributedTask.Expressions2.IFunctionInfo>());
|
||||
ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()));
|
||||
ec.Object.Global.Variables = new Variables(hc, new Dictionary<string, VariableValue>());
|
||||
|
||||
var actionRunner = new ActionRunner();
|
||||
actionRunner.Initialize(hc);
|
||||
actionRunner.ExecutionContext = ec.Object;
|
||||
actionRunner.Stage = ActionRunStage.Post;
|
||||
actionRunner.Action = new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "cache",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "actions/cache",
|
||||
Ref = "v4"
|
||||
}
|
||||
};
|
||||
|
||||
// Act: call EvaluateDisplayName directly, which is what CompositeActionHandler now does
|
||||
// for embedded steps (including Post stage) instead of TryUpdateDisplayName.
|
||||
var result = actionRunner.EvaluateDisplayName(contextData, ec.Object, out bool updated);
|
||||
|
||||
// Assert: display name should be "Run actions/cache@v4", not the fallback "run"
|
||||
Assert.True(result);
|
||||
Assert.True(updated);
|
||||
Assert.Equal("Run actions/cache@v4", actionRunner.DisplayName);
|
||||
}
|
||||
|
||||
// Helper methods that call the real production code
|
||||
private static string EscapeProperty(string value) =>
|
||||
CompositeActionHandler.EscapeProperty(value);
|
||||
|
||||
@@ -36,7 +36,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
var token = new StringToken(null, null, null, "test-value");
|
||||
var contextData = new DictionaryContextData();
|
||||
@@ -63,7 +63,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
// Call EvaluateAndCompare directly: the new evaluator cancels the token
|
||||
// and returns a different value, forcing hasMismatch = true.
|
||||
@@ -98,7 +98,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
// Different results without cancellation — mismatch SHOULD be recorded.
|
||||
var result = wrapper.EvaluateAndCompare<string, string>(
|
||||
@@ -130,7 +130,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new BooleanToken(null, null, null, true);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
@@ -156,7 +156,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "FOO"), new StringToken(null, null, null, "bar"));
|
||||
var contextData = new DictionaryContextData();
|
||||
@@ -184,7 +184,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new BasicExpressionToken(null, null, null, "true");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
@@ -211,7 +211,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "input1"), new StringToken(null, null, null, "val1"));
|
||||
var contextData = new DictionaryContextData();
|
||||
@@ -239,7 +239,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new NumberToken(null, null, null, 10);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
@@ -265,7 +265,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new StringToken(null, null, null, "");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
@@ -281,6 +281,140 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_DockerPrefixOnly_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new StringToken(null, null, null, "docker://");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_DockerPrefixOnlyMapping_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://"));
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_EmptyImageMapping_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, ""));
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_ValidImage_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new StringToken(null, null, null, "ubuntu:latest");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("ubuntu:latest", result.Image);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_DockerPrefixWithImage_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new StringToken(null, null, null, "docker://ubuntu:latest");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("ubuntu:latest", result.Image);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -291,7 +425,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "out1"), new StringToken(null, null, null, "val1"));
|
||||
var contextData = new DictionaryContextData();
|
||||
@@ -319,7 +453,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new StringToken(null, null, null, "https://example.com");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
@@ -348,7 +482,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "shell"), new StringToken(null, null, null, "bash"));
|
||||
var contextData = new DictionaryContextData();
|
||||
@@ -376,7 +510,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
@@ -391,6 +525,213 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_EmptyImage_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
// Build a services mapping token with one service whose image is empty string
|
||||
// Similar to: services: { db: { image: '' } }
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, ""));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
|
||||
|
||||
// Should get a list with one entry where the container is null (empty image = no container)
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("db", result[0].Key);
|
||||
Assert.Null(result[0].Value);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_DockerPrefixOnlyImage_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://"));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("db", result[0].Key);
|
||||
Assert.Null(result[0].Value);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_ExpressionEvalsToEmpty_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
// Simulates: services: { db: { image: ${{ condition && 'img' || '' }} } }
|
||||
// where the expression evaluates to '' at runtime
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new BasicExpressionToken(null, null, null, "''"));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("db", result[0].Key);
|
||||
Assert.Null(result[0].Value);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_ValidImage_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest"));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("db", result[0].Key);
|
||||
Assert.NotNull(result[0].Value);
|
||||
Assert.Equal("postgres:latest", result[0].Value.Image);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_EntrypointAndCommand_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest"));
|
||||
serviceMapping.Add(new StringToken(null, null, null, "entrypoint"), new StringToken(null, null, null, "/bin/bash"));
|
||||
serviceMapping.Add(new StringToken(null, null, null, "command"), new StringToken(null, null, null, "-lc echo hi"));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: true);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("db", result[0].Key);
|
||||
Assert.NotNull(result[0].Value);
|
||||
Assert.Equal("postgres:latest", result[0].Value.Image);
|
||||
Assert.Equal("/bin/bash", result[0].Value.Entrypoint);
|
||||
Assert.Equal("-lc echo hi", result[0].Value.Command);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_EntrypointAndCommand_FlagOff_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest"));
|
||||
serviceMapping.Add(new StringToken(null, null, null, "entrypoint"), new StringToken(null, null, null, "/bin/bash"));
|
||||
serviceMapping.Add(new StringToken(null, null, null, "command"), new StringToken(null, null, null, "-lc echo hi"));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
Assert.Throws<GitHub.DistributedTask.ObjectTemplating.TemplateValidationException>(() =>
|
||||
wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions));
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -401,7 +742,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
@@ -430,7 +771,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
// Both throw JsonReaderException with different messages — should be treated as equivalent
|
||||
var legacyEx = new Newtonsoft.Json.JsonReaderException("Error reading JToken from JsonReader. Path '', line 0, position 0.");
|
||||
@@ -461,7 +802,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
// Legacy throws Newtonsoft JsonReaderException, new throws System.Text.Json.JsonException
|
||||
var legacyEx = new Newtonsoft.Json.JsonReaderException("Error reading JToken");
|
||||
@@ -492,7 +833,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
// Both throw non-JSON exceptions with different messages — should record mismatch
|
||||
var legacyEx = new InvalidOperationException("some error");
|
||||
|
||||
Reference in New Issue
Block a user