From 296ae07d85d2f08186453e7fbf3ce6f45f125dc5 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Tue, 3 Mar 2026 19:17:26 -0800 Subject: [PATCH] Support entrypoint and command for service containers --- src/Runner.Common/Constants.cs | 1 + src/Runner.Worker/Container/ContainerInfo.cs | 2 + src/Runner.Worker/ExecutionContext.cs | 10 +- .../PipelineTemplateEvaluatorWrapper.cs | 18 ++- src/Sdk/DTPipelines/Pipelines/JobContainer.cs | 18 +++ .../PipelineTemplateConstants.cs | 2 + .../PipelineTemplateConverter.cs | 24 +++- .../PipelineTemplateEvaluator.cs | 4 +- src/Sdk/DTPipelines/workflow-v1.0.json | 17 ++- .../Conversion/WorkflowTemplateConstants.cs | 2 + .../Conversion/WorkflowTemplateConverter.cs | 16 +++ src/Sdk/WorkflowParser/JobContainer.cs | 18 +++ src/Sdk/WorkflowParser/WorkflowFeatures.cs | 8 ++ src/Sdk/WorkflowParser/workflow-v1.0.json | 46 ++++++- .../PipelineTemplateEvaluatorWrapperL0.cs | 121 ++++++++++++++---- 15 files changed, 265 insertions(+), 42 deletions(-) diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index fcb7c5b3503..583958981a9 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -172,6 +172,7 @@ public static class Features 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"; diff --git a/src/Runner.Worker/Container/ContainerInfo.cs b/src/Runner.Worker/Container/ContainerInfo.cs index 72cd0ada992..66a3daf935e 100644 --- a/src/Runner.Worker/Container/ContainerInfo.cs +++ b/src/Runner.Worker/Container/ContainerInfo.cs @@ -36,6 +36,8 @@ public ContainerInfo(IHostContext hostContext, Pipelines.JobContainer 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; diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 53484e6b603..3a3754fa7e7 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -1328,9 +1328,9 @@ public void ApplyContinueOnError(TemplateToken continueOnErrorToken) 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 +1418,13 @@ public static IEnumerable> ToExpressionState(this I 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 +1436,7 @@ public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecu 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, }; } diff --git a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs index 61dfdacce6d..d560e45c845 100644 --- a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs +++ b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs @@ -23,6 +23,7 @@ internal sealed class PipelineTemplateEvaluatorWrapper : IPipelineTemplateEvalua public PipelineTemplateEvaluatorWrapper( IHostContext hostContext, IExecutionContext context, + bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null) { ArgUtil.NotNull(hostContext, nameof(hostContext)); @@ -40,11 +41,14 @@ public PipelineTemplateEvaluatorWrapper( _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 @@ private bool CompareJobContainer( 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; diff --git a/src/Sdk/DTPipelines/Pipelines/JobContainer.cs b/src/Sdk/DTPipelines/Pipelines/JobContainer.cs index 901c4fed17d..f38066537f2 100644 --- a/src/Sdk/DTPipelines/Pipelines/JobContainer.cs +++ b/src/Sdk/DTPipelines/Pipelines/JobContainer.cs @@ -39,6 +39,24 @@ public String Options set; } + /// + /// Gets or sets the container entrypoint override. + /// + public String Entrypoint + { + get; + set; + } + + /// + /// Gets or sets the container command and args (after the image name). + /// + public String Command + { + get; + set; + } + /// /// Gets or sets the volumes which are mounted into the container. /// diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs index 8d81c7d2d53..d55a021442a 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs @@ -47,6 +47,8 @@ public sealed class PipelineTemplateConstants 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"; diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs index 6c9654074f1..87bb00baec8 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs @@ -237,7 +237,8 @@ internal static ContainerRegistryCredentials ConvertToContainerCredentials(Templ 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 @@ internal static JobContainer ConvertToJobContainer( 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(ports.Count); @@ -326,7 +343,8 @@ internal static JobContainer ConvertToJobContainer( internal static List> ConvertToJobServiceContainers( TemplateContext context, TemplateToken services, - bool allowExpressions = false) + bool allowExpressions = false, + bool allowServiceContainerCommand = false) { var result = new List>(); @@ -340,7 +358,7 @@ internal static List> ConvertToJobServiceCont 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(networkAlias, container)); } diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs index 345058997f9..55cae82f38c 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs @@ -51,6 +51,8 @@ public PipelineTemplateEvaluator( 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 @@ public IList> EvaluateJobServiceContainers( { 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)) { diff --git a/src/Sdk/DTPipelines/workflow-v1.0.json b/src/Sdk/DTPipelines/workflow-v1.0.json index bfd050ed36b..432cb75ecd3 100644 --- a/src/Sdk/DTPipelines/workflow-v1.0.json +++ b/src/Sdk/DTPipelines/workflow-v1.0.json @@ -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" ] }, diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs index fb2065f4188..9dce514a1ff 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs @@ -62,6 +62,8 @@ internal static class WorkflowTemplateConstants 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"; diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs index 7293ce16583..8ae6ea0c9f7 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs @@ -1146,6 +1146,22 @@ internal static JobContainer ConvertToJobContainer( 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(ports.Count); diff --git a/src/Sdk/WorkflowParser/JobContainer.cs b/src/Sdk/WorkflowParser/JobContainer.cs index dfa173c1030..289400b43db 100644 --- a/src/Sdk/WorkflowParser/JobContainer.cs +++ b/src/Sdk/WorkflowParser/JobContainer.cs @@ -35,6 +35,24 @@ public String Options set; } + /// + /// Gets or sets the container entrypoint override. + /// + public String Entrypoint + { + get; + set; + } + + /// + /// Gets or sets the container command and args (after the image name). + /// + public String Command + { + get; + set; + } + /// /// Gets or sets the volumes which are mounted into the container. /// diff --git a/src/Sdk/WorkflowParser/WorkflowFeatures.cs b/src/Sdk/WorkflowParser/WorkflowFeatures.cs index 8b36a5fa316..c3fa33af74b 100644 --- a/src/Sdk/WorkflowParser/WorkflowFeatures.cs +++ b/src/Sdk/WorkflowParser/WorkflowFeatures.cs @@ -48,6 +48,13 @@ public class WorkflowFeatures [DataMember(EmitDefaultValue = false)] public bool StrictJsonParsing { get; set; } + /// + /// Gets or sets a value indicating whether service containers may specify "entrypoint" and "command". + /// Used during parsing and evaluation. + /// + [DataMember(EmitDefaultValue = false)] + public bool AllowServiceContainerCommand { get; set; } + /// /// Gets the default workflow features. /// @@ -60,6 +67,7 @@ public static WorkflowFeatures GetDefaults() 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 }; } diff --git a/src/Sdk/WorkflowParser/workflow-v1.0.json b/src/Sdk/WorkflowParser/workflow-v1.0.json index 0f4c9113002..66bda31fa55 100644 --- a/src/Sdk/WorkflowParser/workflow-v1.0.json +++ b/src/Sdk/WorkflowParser/workflow-v1.0.json @@ -2590,20 +2590,52 @@ "properties": { "image": { "type": "string", - "description": "Use `jobs..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." + "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..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..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..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" } @@ -2635,11 +2667,11 @@ ], "one-of": [ "string", - "container-mapping" + "service-container-mapping" ] }, "container-registry-credentials": { - "description": "If the image's container registry requires authentication to pull the image, you can use `jobs..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..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" diff --git a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs index e6fae1fa58f..0a7427ced0e 100644 --- a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs +++ b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs @@ -36,7 +36,7 @@ public void EvaluateAndCompare_DoesNotRecordMismatch_WhenResultsMatch() 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 @@ public void EvaluateAndCompare_SkipsMismatchRecording_WhenCancellationOccursDuri 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 @@ public void EvaluateAndCompare_RecordsMismatch_WhenResultsDifferWithoutCancellat 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( @@ -130,7 +130,7 @@ public void EvaluateStepContinueOnError_BothParsersAgree() 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(); @@ -156,7 +156,7 @@ public void EvaluateStepEnvironment_BothParsersAgree() 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 @@ public void EvaluateStepIf_BothParsersAgree() 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(); @@ -211,7 +211,7 @@ public void EvaluateStepInputs_BothParsersAgree() 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 @@ public void EvaluateStepTimeout_BothParsersAgree() 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(); @@ -265,7 +265,7 @@ public void EvaluateJobContainer_EmptyImage_BothParsersAgree() 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(); @@ -291,7 +291,7 @@ public void EvaluateJobContainer_DockerPrefixOnly_BothParsersAgree() 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, "docker://"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -317,7 +317,7 @@ public void EvaluateJobContainer_DockerPrefixOnlyMapping_BothParsersAgree() 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, "image"), new StringToken(null, null, null, "docker://")); var contextData = new DictionaryContextData(); @@ -344,7 +344,7 @@ public void EvaluateJobContainer_EmptyImageMapping_BothParsersAgree() 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, "image"), new StringToken(null, null, null, "")); var contextData = new DictionaryContextData(); @@ -371,7 +371,7 @@ public void EvaluateJobContainer_ValidImage_BothParsersAgree() 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, "ubuntu:latest"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -398,7 +398,7 @@ public void EvaluateJobContainer_DockerPrefixWithImage_BothParsersAgree() 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, "docker://ubuntu:latest"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -425,7 +425,7 @@ public void EvaluateJobOutput_BothParsersAgree() 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(); @@ -453,7 +453,7 @@ public void EvaluateEnvironmentUrl_BothParsersAgree() 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(); @@ -482,7 +482,7 @@ public void EvaluateJobDefaultsRun_BothParsersAgree() 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(); @@ -510,7 +510,7 @@ public void EvaluateJobServiceContainers_Null_BothParsersAgree() 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(); @@ -542,7 +542,7 @@ public void EvaluateJobServiceContainers_EmptyImage_BothParsersAgree() 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); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -576,7 +576,7 @@ public void EvaluateJobServiceContainers_DockerPrefixOnlyImage_BothParsersAgree( 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); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -611,7 +611,7 @@ public void EvaluateJobServiceContainers_ExpressionEvalsToEmpty_BothParsersAgree 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); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -644,7 +644,7 @@ public void EvaluateJobServiceContainers_ValidImage_BothParsersAgree() 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); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -663,6 +663,75 @@ public void EvaluateJobServiceContainers_ValidImage_BothParsersAgree() } } + [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(); + + 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(); + + Assert.Throws(() => + wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions)); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -673,7 +742,7 @@ public void EvaluateJobSnapshotRequest_Null_BothParsersAgree() 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(); @@ -702,7 +771,7 @@ public void EvaluateAndCompare_JsonReaderExceptions_TreatedAsEquivalent() 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."); @@ -733,7 +802,7 @@ public void EvaluateAndCompare_MixedJsonExceptionTypes_TreatedAsEquivalent() 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"); @@ -764,7 +833,7 @@ public void EvaluateAndCompare_NonJsonExceptions_RecordsMismatch() 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");