diff --git a/source/Octopus.Tentacle.Client/EventDriven/CommandContext.cs b/source/Octopus.Tentacle.Client/EventDriven/CommandContext.cs new file mode 100644 index 000000000..0f34b8108 --- /dev/null +++ b/source/Octopus.Tentacle.Client/EventDriven/CommandContext.cs @@ -0,0 +1,26 @@ +using Octopus.Tentacle.Client.Scripts; +using Octopus.Tentacle.Contracts; + +namespace Octopus.Tentacle.Client.EventDriven +{ + /// + /// This class holds the context of where we are up to within the script execution life cycle. + /// When executing a script, there are several stages it goes through (e.g. starting the script, periodically checking status for completion, completing the script). + /// To be able to progress through these cycles in an event-driven environment, we need to remember some state, and then keep passing that state back into the script executor. + /// + public class CommandContext + { + public CommandContext(ScriptTicket scriptTicket, + long nextLogSequence, + ScriptServiceVersion scripServiceVersionUsed) + { + ScriptTicket = scriptTicket; + NextLogSequence = nextLogSequence; + ScripServiceVersionUsed = scripServiceVersionUsed; + } + + public ScriptTicket ScriptTicket { get; } + public long NextLogSequence { get; } + public ScriptServiceVersion ScripServiceVersionUsed { get; } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/ScriptExecutor.cs b/source/Octopus.Tentacle.Client/ScriptExecutor.cs new file mode 100644 index 000000000..9f61aaf6a --- /dev/null +++ b/source/Octopus.Tentacle.Client/ScriptExecutor.cs @@ -0,0 +1,118 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Octopus.Tentacle.Client.EventDriven; +using Octopus.Tentacle.Client.Execution; +using Octopus.Tentacle.Client.Observability; +using Octopus.Tentacle.Client.Scripts; +using Octopus.Tentacle.Client.Scripts.Models; +using Octopus.Tentacle.Client.ServiceHelpers; +using Octopus.Tentacle.Contracts; +using Octopus.Tentacle.Contracts.Logging; +using Octopus.Tentacle.Contracts.Observability; + +namespace Octopus.Tentacle.Client +{ + /// + /// Executes scripts, on the best available script service. + /// + public class ScriptExecutor : IScriptExecutor + { + readonly ITentacleClientTaskLog logger; + readonly ClientOperationMetricsBuilder operationMetricsBuilder; + readonly TentacleClientOptions clientOptions; + readonly AllClients allClients; + readonly RpcCallExecutor rpcCallExecutor; + readonly TimeSpan onCancellationAbandonCompleteScriptAfter; + + public ScriptExecutor(AllClients allClients, + ITentacleClientTaskLog logger, + ITentacleClientObserver tentacleClientObserver, + TentacleClientOptions clientOptions, + TimeSpan onCancellationAbandonCompleteScriptAfter) + : this( + allClients, + logger, + tentacleClientObserver, + // For now, we do not support operation based metrics when used outside the TentacleClient. So just plug in a builder to discard. + ClientOperationMetricsBuilder.Start(), + clientOptions, + onCancellationAbandonCompleteScriptAfter) + { + } + + internal ScriptExecutor(AllClients allClients, + ITentacleClientTaskLog logger, + ITentacleClientObserver tentacleClientObserver, + ClientOperationMetricsBuilder operationMetricsBuilder, + TentacleClientOptions clientOptions, + TimeSpan onCancellationAbandonCompleteScriptAfter) + { + this.allClients = allClients; + this.logger = logger; + this.clientOptions = clientOptions; + this.onCancellationAbandonCompleteScriptAfter = onCancellationAbandonCompleteScriptAfter; + this.operationMetricsBuilder = operationMetricsBuilder; + rpcCallExecutor = RpcCallExecutorFactory.Create(this.clientOptions.RpcRetrySettings.RetryDuration, tentacleClientObserver); + } + + public async Task StartScript(ExecuteScriptCommand executeScriptCommand, + StartScriptIsBeingReAttempted startScriptIsBeingReAttempted, + CancellationToken cancellationToken) + { + var scriptServiceVersionToUse = await DetermineScriptServiceVersionToUse(cancellationToken); + + var scriptExecutorFactory = CreateScriptExecutorFactory(); + var scriptExecutor = scriptExecutorFactory.CreateScriptExecutor(scriptServiceVersionToUse); + + return await scriptExecutor.StartScript(executeScriptCommand, startScriptIsBeingReAttempted, cancellationToken); + } + + public async Task GetStatus(CommandContext ticketForNextNextStatus, CancellationToken cancellationToken) + { + var scriptExecutorFactory = CreateScriptExecutorFactory(); + var scriptExecutor = scriptExecutorFactory.CreateScriptExecutor(ticketForNextNextStatus.ScripServiceVersionUsed); + + return await scriptExecutor.GetStatus(ticketForNextNextStatus, cancellationToken); + } + + public async Task CancelScript(CommandContext ticketForNextNextStatus) + { + var scriptExecutorFactory = CreateScriptExecutorFactory(); + var scriptExecutor = scriptExecutorFactory.CreateScriptExecutor(ticketForNextNextStatus.ScripServiceVersionUsed); + + return await scriptExecutor.CancelScript(ticketForNextNextStatus); + } + + public async Task CompleteScript(CommandContext ticketForNextNextStatus, CancellationToken cancellationToken) + { + var scriptExecutorFactory = CreateScriptExecutorFactory(); + var scriptExecutor = scriptExecutorFactory.CreateScriptExecutor(ticketForNextNextStatus.ScripServiceVersionUsed); + + return await scriptExecutor.CompleteScript(ticketForNextNextStatus, cancellationToken); + } + + ScriptExecutorFactory CreateScriptExecutorFactory() + { + return new ScriptExecutorFactory(allClients, + rpcCallExecutor, + operationMetricsBuilder, + onCancellationAbandonCompleteScriptAfter, + clientOptions, + logger); + } + + async Task DetermineScriptServiceVersionToUse(CancellationToken cancellationToken) + { + try + { + var scriptServiceVersionSelector = new ScriptServiceVersionSelector(allClients.CapabilitiesServiceV2, logger, rpcCallExecutor, clientOptions, operationMetricsBuilder); + return await scriptServiceVersionSelector.DetermineScriptServiceVersionToUse(cancellationToken); + } + catch (Exception ex) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException("Script execution was cancelled", ex); + } + } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/IScriptExecutor.cs b/source/Octopus.Tentacle.Client/Scripts/IScriptExecutor.cs new file mode 100644 index 000000000..a29731f65 --- /dev/null +++ b/source/Octopus.Tentacle.Client/Scripts/IScriptExecutor.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Octopus.Tentacle.Client.EventDriven; +using Octopus.Tentacle.Client.Scripts.Models; +using Octopus.Tentacle.Contracts; + +namespace Octopus.Tentacle.Client.Scripts +{ + public interface IScriptExecutor + { + /// + /// Start the script. + /// + /// The result, which includes the CommandContext for the next command + Task StartScript(ExecuteScriptCommand command, + StartScriptIsBeingReAttempted startScriptIsBeingReAttempted, + CancellationToken scriptExecutionCancellationToken); + + /// + /// Get the status. + /// + /// The CommandContext from the previous command + /// + /// The result, which includes the CommandContext for the next command + Task GetStatus(CommandContext commandContext, CancellationToken scriptExecutionCancellationToken); + + /// + /// Cancel the script. + /// + /// The CommandContext from the previous command + /// The result, which includes the CommandContext for the next command + Task CancelScript(CommandContext commandContext); + + /// + /// Complete the script. + /// + /// The CommandContext from the previous command + /// + Task CompleteScript(CommandContext commandContext, CancellationToken scriptExecutionCancellationToken); + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/IScriptOrchestrator.cs b/source/Octopus.Tentacle.Client/Scripts/IScriptOrchestrator.cs deleted file mode 100644 index c0026d809..000000000 --- a/source/Octopus.Tentacle.Client/Scripts/IScriptOrchestrator.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Octopus.Tentacle.Client.Scripts.Models; - -namespace Octopus.Tentacle.Client.Scripts -{ - interface IScriptOrchestrator - { - Task ExecuteScript(ExecuteScriptCommand command, CancellationToken scriptExecutionCancellationToken); - } -} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/IScriptOrchestratorFactory.cs b/source/Octopus.Tentacle.Client/Scripts/IScriptOrchestratorFactory.cs deleted file mode 100644 index 3e3055057..000000000 --- a/source/Octopus.Tentacle.Client/Scripts/IScriptOrchestratorFactory.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Octopus.Tentacle.Client.Scripts -{ - interface IScriptOrchestratorFactory - { - Task CreateOrchestrator(CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/KubernetesScriptServiceV1Orchestrator.cs b/source/Octopus.Tentacle.Client/Scripts/KubernetesScriptServiceV1Executor.cs similarity index 62% rename from source/Octopus.Tentacle.Client/Scripts/KubernetesScriptServiceV1Orchestrator.cs rename to source/Octopus.Tentacle.Client/Scripts/KubernetesScriptServiceV1Executor.cs index b4e614c57..28843f938 100644 --- a/source/Octopus.Tentacle.Client/Scripts/KubernetesScriptServiceV1Orchestrator.cs +++ b/source/Octopus.Tentacle.Client/Scripts/KubernetesScriptServiceV1Executor.cs @@ -1,9 +1,9 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Halibut; using Halibut.ServiceModel; +using Octopus.Tentacle.Client.EventDriven; using Octopus.Tentacle.Client.Execution; using Octopus.Tentacle.Client.Observability; using Octopus.Tentacle.Client.Scripts.Models; @@ -15,37 +15,32 @@ namespace Octopus.Tentacle.Client.Scripts { - class KubernetesScriptServiceV1Orchestrator : ObservingScriptOrchestrator + class KubernetesScriptServiceV1Executor : IScriptExecutor { readonly IAsyncClientKubernetesScriptServiceV1 clientKubernetesScriptServiceV1; readonly RpcCallExecutor rpcCallExecutor; readonly ClientOperationMetricsBuilder clientOperationMetricsBuilder; readonly TimeSpan onCancellationAbandonCompleteScriptAfter; readonly ITentacleClientTaskLog logger; + readonly TentacleClientOptions clientOptions; - public KubernetesScriptServiceV1Orchestrator( + public KubernetesScriptServiceV1Executor( IAsyncClientKubernetesScriptServiceV1 clientKubernetesScriptServiceV1, - IScriptObserverBackoffStrategy scriptObserverBackOffStrategy, RpcCallExecutor rpcCallExecutor, ClientOperationMetricsBuilder clientOperationMetricsBuilder, - OnScriptStatusResponseReceived onScriptStatusResponseReceived, - OnScriptCompleted onScriptCompleted, TimeSpan onCancellationAbandonCompleteScriptAfter, TentacleClientOptions clientOptions, ITentacleClientTaskLog logger) - : base(scriptObserverBackOffStrategy, - onScriptStatusResponseReceived, - onScriptCompleted, - clientOptions) { this.clientKubernetesScriptServiceV1 = clientKubernetesScriptServiceV1; this.rpcCallExecutor = rpcCallExecutor; this.clientOperationMetricsBuilder = clientOperationMetricsBuilder; this.onCancellationAbandonCompleteScriptAfter = onCancellationAbandonCompleteScriptAfter; + this.clientOptions = clientOptions; this.logger = logger; } - protected override StartKubernetesScriptCommandV1 Map(ExecuteScriptCommand command) + StartKubernetesScriptCommandV1 Map(ExecuteScriptCommand command) { if (command is not ExecuteKubernetesScriptCommand kubernetesScriptCommand) throw new InvalidOperationException($"Invalid execute script command received. Expected {nameof(ExecuteKubernetesScriptCommand)}, but received {command.GetType().Name}."); @@ -73,17 +68,18 @@ protected override StartKubernetesScriptCommandV1 Map(ExecuteScriptCommand comma kubernetesScriptCommand.IsRawScript); } - protected override ScriptExecutionStatus MapToStatus(KubernetesScriptStatusResponseV1 response) - => new(response.Logs); - - protected override ScriptExecutionResult MapToResult(KubernetesScriptStatusResponseV1 response) - => new(response.State, response.ExitCode); - - protected override ProcessState GetState(KubernetesScriptStatusResponseV1 response) => response.State; + static ScriptOperationExecutionResult Map(KubernetesScriptStatusResponseV1 scriptStatusResponse) + { + return new( + new ScriptStatus(scriptStatusResponse.State, scriptStatusResponse.ExitCode, scriptStatusResponse.Logs), + new CommandContext(scriptStatusResponse.ScriptTicket, scriptStatusResponse.NextLogSequence, ScriptServiceVersion.KubernetesScriptServiceVersion1)); + } - protected override async Task StartScript(StartKubernetesScriptCommandV1 command, CancellationToken scriptExecutionCancellationToken) + public async Task StartScript(ExecuteScriptCommand executeScriptCommand, + StartScriptIsBeingReAttempted startScriptIsBeingReAttempted, + CancellationToken scriptExecutionCancellationToken) { - KubernetesScriptStatusResponseV1 scriptStatusResponse; + var command = Map(executeScriptCommand); var startScriptCallsConnectedCount = 0; try { @@ -104,14 +100,16 @@ void OnErrorAction(Exception ex) } } - scriptStatusResponse = await rpcCallExecutor.Execute( - retriesEnabled: ClientOptions.RpcRetrySettings.RetriesEnabled, + var scriptStatusResponse = await rpcCallExecutor.Execute( + retriesEnabled: clientOptions.RpcRetrySettings.RetriesEnabled, RpcCall.Create(nameof(IKubernetesScriptServiceV1.StartScript)), StartScriptAction, OnErrorAction, logger, clientOperationMetricsBuilder, scriptExecutionCancellationToken).ConfigureAwait(false); + + return Map(scriptStatusResponse); } catch (Exception ex) when (scriptExecutionCancellationToken.IsCancellationRequested) { @@ -125,65 +123,37 @@ void OnErrorAction(Exception ex) if (!startScriptCallIsConnecting || startScriptCallIsBeingRetried) { - // We have to assume the script started executing and call CancelScript and CompleteScript - // We don't have a response so we need to create one to continue the execution flow - scriptStatusResponse = new KubernetesScriptStatusResponseV1( - command.ScriptTicket, - ProcessState.Pending, - ScriptExitCodes.RunningExitCode, - new List(), - 0); - - try - { - await ObserveUntilCompleteThenFinish(scriptStatusResponse, scriptExecutionCancellationToken).ConfigureAwait(false); - } - catch (Exception observerUntilCompleteException) - { - // Throw an error so the caller knows that execution of the script was cancelled - throw new OperationCanceledException("Script execution was cancelled", observerUntilCompleteException); - } - - // Throw an error so the caller knows that execution of the script was cancelled - throw new OperationCanceledException("Script execution was cancelled"); + // We want to cancel the potentially started script, and wait till it finishes. By returning a result, the outer orchestration will take care of this. + return ScriptOperationExecutionResult.CreateScriptStartedResult(command.ScriptTicket, ScriptServiceVersion.KubernetesScriptServiceVersion1); } // If the StartScript call was not in-flight or being retries then we know the script has not started executing on Tentacle // So can exit without calling CancelScript or CompleteScript throw new OperationCanceledException("Script execution was cancelled", ex); } - - return scriptStatusResponse; } - protected override async Task GetStatus(KubernetesScriptStatusResponseV1 lastStatusResponse, CancellationToken scriptExecutionCancellationToken) + public async Task GetStatus(CommandContext commandContext, CancellationToken scriptExecutionCancellationToken) { - try + async Task GetStatusAction(CancellationToken ct) { - async Task GetStatusAction(CancellationToken ct) - { - var request = new KubernetesScriptStatusRequestV1(lastStatusResponse.ScriptTicket, lastStatusResponse.NextLogSequence); - var result = await clientKubernetesScriptServiceV1.GetStatusAsync(request, new HalibutProxyRequestOptions(ct)); + var request = new KubernetesScriptStatusRequestV1(commandContext.ScriptTicket, commandContext.NextLogSequence); + var result = await clientKubernetesScriptServiceV1.GetStatusAsync(request, new HalibutProxyRequestOptions(ct)); - return result; - } - - return await rpcCallExecutor.Execute( - retriesEnabled: ClientOptions.RpcRetrySettings.RetriesEnabled, - RpcCall.Create(nameof(IKubernetesScriptServiceV1.GetStatus)), - GetStatusAction, - logger, - clientOperationMetricsBuilder, - scriptExecutionCancellationToken).ConfigureAwait(false); - } - catch (Exception e) when (e is OperationCanceledException && scriptExecutionCancellationToken.IsCancellationRequested) - { - // Return the last known response without logs when cancellation occurs and let the script execution go into the CancelScript and CompleteScript flow - return new KubernetesScriptStatusResponseV1(lastStatusResponse.ScriptTicket, lastStatusResponse.State, lastStatusResponse.ExitCode, new List(), lastStatusResponse.NextLogSequence); + return result; } + + var kubernetesScriptStatusResponseV1 = await rpcCallExecutor.Execute( + retriesEnabled: clientOptions.RpcRetrySettings.RetriesEnabled, + RpcCall.Create(nameof(IKubernetesScriptServiceV1.GetStatus)), + GetStatusAction, + logger, + clientOperationMetricsBuilder, + scriptExecutionCancellationToken).ConfigureAwait(false); + return Map(kubernetesScriptStatusResponseV1); } - protected override async Task Cancel(KubernetesScriptStatusResponseV1 lastStatusResponse, CancellationToken scriptExecutionCancellationToken) + public async Task CancelScript(CommandContext lastStatusResponse) { async Task CancelScriptAction(CancellationToken ct) { @@ -197,17 +167,18 @@ async Task CancelScriptAction(CancellationToke // If script execution is already triggering RPC Retries and then the script execution is cancelled there is a high chance that the cancel RPC call will fail as well and go into RPC retries. // We could potentially reduce the time to failure by not retrying the cancel RPC Call if the previous RPC call was already triggering RPC Retries. - return await rpcCallExecutor.Execute( - retriesEnabled: ClientOptions.RpcRetrySettings.RetriesEnabled, + var kubernetesScriptStatusResponseV1 = await rpcCallExecutor.Execute( + retriesEnabled: clientOptions.RpcRetrySettings.RetriesEnabled, RpcCall.Create(nameof(IKubernetesScriptServiceV1.CancelScript)), CancelScriptAction, logger, clientOperationMetricsBuilder, // We don't want to cancel this operation as it is responsible for stopping the script executing on the Tentacle CancellationToken.None).ConfigureAwait(false); + return Map(kubernetesScriptStatusResponseV1); } - protected override async Task Finish(KubernetesScriptStatusResponseV1 lastStatusResponse, CancellationToken scriptExecutionCancellationToken) + public async Task CompleteScript(CommandContext lastStatusResponse, CancellationToken scriptExecutionCancellationToken) { try { @@ -234,7 +205,7 @@ await rpcCallExecutor.ExecuteWithNoRetries( logger.Verbose(ex); } - return lastStatusResponse; + return null; } } } \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/ObservingScriptOrchestrator.cs b/source/Octopus.Tentacle.Client/Scripts/ObservingScriptOrchestrator.cs index 47d7fd64c..4aa6882aa 100644 --- a/source/Octopus.Tentacle.Client/Scripts/ObservingScriptOrchestrator.cs +++ b/source/Octopus.Tentacle.Client/Scripts/ObservingScriptOrchestrator.cs @@ -6,21 +6,20 @@ namespace Octopus.Tentacle.Client.Scripts { - abstract class ObservingScriptOrchestrator : IScriptOrchestrator + public sealed class ObservingScriptOrchestrator { readonly IScriptObserverBackoffStrategy scriptObserverBackOffStrategy; readonly OnScriptStatusResponseReceived onScriptStatusResponseReceived; readonly OnScriptCompleted onScriptCompleted; + readonly IScriptExecutor scriptExecutor; - protected TentacleClientOptions ClientOptions { get; } - - protected ObservingScriptOrchestrator( + public ObservingScriptOrchestrator( IScriptObserverBackoffStrategy scriptObserverBackOffStrategy, OnScriptStatusResponseReceived onScriptStatusResponseReceived, OnScriptCompleted onScriptCompleted, - TentacleClientOptions clientOptions) + IScriptExecutor scriptExecutor) { - ClientOptions = clientOptions; + this.scriptExecutor = scriptExecutor; this.scriptObserverBackOffStrategy = scriptObserverBackOffStrategy; this.onScriptStatusResponseReceived = onScriptStatusResponseReceived; this.onScriptCompleted = onScriptCompleted; @@ -28,11 +27,11 @@ protected ObservingScriptOrchestrator( public async Task ExecuteScript(ExecuteScriptCommand command, CancellationToken scriptExecutionCancellationToken) { - var mappedStartCommand = Map(command); - - var scriptStatusResponse = await StartScript(mappedStartCommand, scriptExecutionCancellationToken).ConfigureAwait(false); + var startScriptResult = await scriptExecutor.StartScript(command, + StartScriptIsBeingReAttempted.FirstAttempt, // This is not re-entrant so this should be true. + scriptExecutionCancellationToken).ConfigureAwait(false); - scriptStatusResponse = await ObserveUntilCompleteThenFinish(scriptStatusResponse, scriptExecutionCancellationToken).ConfigureAwait(false); + var scriptStatus = await ObserveUntilCompleteThenFinish(startScriptResult, scriptExecutionCancellationToken).ConfigureAwait(false); if (scriptExecutionCancellationToken.IsCancellationRequested) { @@ -40,45 +39,49 @@ public async Task ExecuteScript(ExecuteScriptCommand comm throw new OperationCanceledException("Script execution was cancelled"); } - var mappedResponse = MapToResult(scriptStatusResponse); - - return new ScriptExecutionResult(mappedResponse.State, mappedResponse.ExitCode); + return new ScriptExecutionResult(scriptStatus.State, scriptStatus.ExitCode!.Value); } - protected async Task ObserveUntilCompleteThenFinish( - TScriptStatusResponse scriptStatusResponse, + async Task ObserveUntilCompleteThenFinish( + ScriptOperationExecutionResult startScriptResult, CancellationToken scriptExecutionCancellationToken) { - OnScriptStatusResponseReceived(scriptStatusResponse); + OnScriptStatusResponseReceived(startScriptResult.ScriptStatus); - var lastScriptStatus = await ObserveUntilComplete(scriptStatusResponse, scriptExecutionCancellationToken).ConfigureAwait(false); + var observingUntilCompleteResult = await ObserveUntilComplete(startScriptResult, scriptExecutionCancellationToken).ConfigureAwait(false); await onScriptCompleted(scriptExecutionCancellationToken).ConfigureAwait(false); + + var completeScriptResponse = await scriptExecutor.CompleteScript(observingUntilCompleteResult.ContextForNextCommand, scriptExecutionCancellationToken).ConfigureAwait(false); - lastScriptStatus = await Finish(lastScriptStatus, scriptExecutionCancellationToken).ConfigureAwait(false); + // V1 can return a result when completing. But other versions do not. + // The behaviour we are maintaining is that the result to use for V1 is that of "complete" + // but the result to use for other versions is the last observing result. + var scriptStatusResponse = completeScriptResponse ?? observingUntilCompleteResult.ScriptStatus; + OnScriptStatusResponseReceived(scriptStatusResponse); - return lastScriptStatus; + return scriptStatusResponse; } - async Task ObserveUntilComplete( - TScriptStatusResponse scriptStatusResponse, + async Task ObserveUntilComplete( + ScriptOperationExecutionResult startScriptResult, CancellationToken scriptExecutionCancellationToken) { - var lastStatusResponse = scriptStatusResponse; var iteration = 0; var cancellationIteration = 0; + var lastResult = startScriptResult; - while (GetState(lastStatusResponse) != ProcessState.Complete) + while (lastResult.ScriptStatus.State != ProcessState.Complete) { if (scriptExecutionCancellationToken.IsCancellationRequested) { - lastStatusResponse = await Cancel(lastStatusResponse, scriptExecutionCancellationToken).ConfigureAwait(false); + lastResult = await scriptExecutor.CancelScript(lastResult.ContextForNextCommand).ConfigureAwait(false); } else { try { - lastStatusResponse = await GetStatus(lastStatusResponse, scriptExecutionCancellationToken).ConfigureAwait(false); + lastResult = await scriptExecutor.GetStatus(lastResult.ContextForNextCommand, scriptExecutionCancellationToken).ConfigureAwait(false); } catch (Exception) { @@ -91,9 +94,9 @@ async Task ObserveUntilComplete( } } - OnScriptStatusResponseReceived(lastStatusResponse); + OnScriptStatusResponseReceived(lastResult.ScriptStatus); - if (GetState(lastStatusResponse) == ProcessState.Complete) + if (lastResult.ScriptStatus.State == ProcessState.Complete) { continue; } @@ -112,28 +115,13 @@ await Task.Delay(scriptObserverBackOffStrategy.GetBackoff(++iteration), scriptEx } } - return lastStatusResponse; + return lastResult; } - protected abstract TStartCommand Map(ExecuteScriptCommand command); - - protected abstract ScriptExecutionStatus MapToStatus(TScriptStatusResponse response); - - protected abstract ScriptExecutionResult MapToResult(TScriptStatusResponse response); - - protected abstract ProcessState GetState(TScriptStatusResponse response); - - protected abstract Task StartScript(TStartCommand command, CancellationToken scriptExecutionCancellationToken); - - protected abstract Task GetStatus(TScriptStatusResponse lastStatusResponse, CancellationToken scriptExecutionCancellationToken); - - protected abstract Task Cancel(TScriptStatusResponse lastStatusResponse, CancellationToken scriptExecutionCancellationToken); - - protected abstract Task Finish(TScriptStatusResponse lastStatusResponse, CancellationToken scriptExecutionCancellationToken); - - protected void OnScriptStatusResponseReceived(TScriptStatusResponse scriptStatusResponse) + void OnScriptStatusResponseReceived(ScriptStatus scriptStatusResponse) { - onScriptStatusResponseReceived(MapToStatus(scriptStatusResponse)); + var scriptExecutionStatus = new ScriptExecutionStatus(scriptStatusResponse.Logs); + onScriptStatusResponseReceived(scriptExecutionStatus); } } } \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/ScriptExecutorFactory.cs b/source/Octopus.Tentacle.Client/Scripts/ScriptExecutorFactory.cs new file mode 100644 index 000000000..a67824d6f --- /dev/null +++ b/source/Octopus.Tentacle.Client/Scripts/ScriptExecutorFactory.cs @@ -0,0 +1,73 @@ +using System; +using Octopus.Tentacle.Client.Execution; +using Octopus.Tentacle.Client.Observability; +using Octopus.Tentacle.Client.ServiceHelpers; +using Octopus.Tentacle.Contracts.Logging; + +namespace Octopus.Tentacle.Client.Scripts +{ + class ScriptExecutorFactory + { + readonly RpcCallExecutor rpcCallExecutor; + readonly ClientOperationMetricsBuilder clientOperationMetricsBuilder; + readonly TimeSpan onCancellationAbandonCompleteScriptAfter; + readonly ITentacleClientTaskLog logger; + + readonly AllClients allClients; + readonly TentacleClientOptions clientOptions; + + public ScriptExecutorFactory( + AllClients allClients, + RpcCallExecutor rpcCallExecutor, + ClientOperationMetricsBuilder clientOperationMetricsBuilder, + TimeSpan onCancellationAbandonCompleteScriptAfter, + TentacleClientOptions clientOptions, + ITentacleClientTaskLog logger) + { + this.allClients = allClients; + this.rpcCallExecutor = rpcCallExecutor; + this.clientOperationMetricsBuilder = clientOperationMetricsBuilder; + this.onCancellationAbandonCompleteScriptAfter = onCancellationAbandonCompleteScriptAfter; + this.clientOptions = clientOptions; + this.logger = logger; + } + + public IScriptExecutor CreateScriptExecutor(ScriptServiceVersion scriptServiceToUse) + { + if (scriptServiceToUse == ScriptServiceVersion.ScriptServiceVersion1) + { + return new ScriptServiceV1Executor( + allClients.ScriptServiceV1, + rpcCallExecutor, + clientOperationMetricsBuilder, + logger); + } + + if (scriptServiceToUse == ScriptServiceVersion.ScriptServiceVersion2) + { + return new ScriptServiceV2Executor( + allClients.ScriptServiceV2, + rpcCallExecutor, + clientOperationMetricsBuilder, + onCancellationAbandonCompleteScriptAfter, + clientOptions, + logger); + } + + if (scriptServiceToUse == ScriptServiceVersion.KubernetesScriptServiceVersion1) + { + return new KubernetesScriptServiceV1Executor( + allClients.KubernetesScriptServiceV1, + rpcCallExecutor, + clientOperationMetricsBuilder, + onCancellationAbandonCompleteScriptAfter, + clientOptions, + logger); + } + + throw new InvalidOperationException($"Unknown ScriptServiceVersion {scriptServiceToUse}"); + } + + + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/ScriptOperationExecutionResult.cs b/source/Octopus.Tentacle.Client/Scripts/ScriptOperationExecutionResult.cs new file mode 100644 index 000000000..a2b16b858 --- /dev/null +++ b/source/Octopus.Tentacle.Client/Scripts/ScriptOperationExecutionResult.cs @@ -0,0 +1,29 @@ +using Octopus.Tentacle.Client.EventDriven; +using Octopus.Tentacle.Contracts; +using System.Collections.Generic; + +namespace Octopus.Tentacle.Client.Scripts +{ + public class ScriptOperationExecutionResult + { + public ScriptStatus ScriptStatus { get; } + public CommandContext ContextForNextCommand { get; } + + public ScriptOperationExecutionResult(ScriptStatus scriptStatus, CommandContext contextForNextCommand) + { + ScriptStatus = scriptStatus; + ContextForNextCommand = contextForNextCommand; + } + + /// + /// Create a result object for when we have most likely started a script, but cancellation has started, and we want to wait for + /// this script to finish. + /// + internal static ScriptOperationExecutionResult CreateScriptStartedResult(ScriptTicket scriptTicket, ScriptServiceVersion scripServiceVersionUsed) + { + var scriptStatus = new ScriptStatus(ProcessState.Pending, null, new List()); + var contextForNextCommand = new CommandContext(scriptTicket, 0, scripServiceVersionUsed); + return new(scriptStatus, contextForNextCommand); + } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/ScriptOrchestratorFactory.cs b/source/Octopus.Tentacle.Client/Scripts/ScriptOrchestratorFactory.cs deleted file mode 100644 index e0cfe0697..000000000 --- a/source/Octopus.Tentacle.Client/Scripts/ScriptOrchestratorFactory.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Halibut.ServiceModel; -using Octopus.Tentacle.Client.Capabilities; -using Octopus.Tentacle.Client.Execution; -using Octopus.Tentacle.Client.Kubernetes; -using Octopus.Tentacle.Client.Observability; -using Octopus.Tentacle.Contracts.Capabilities; -using Octopus.Tentacle.Contracts.ClientServices; -using Octopus.Tentacle.Contracts.Logging; -using Octopus.Tentacle.Contracts.Observability; - -namespace Octopus.Tentacle.Client.Scripts -{ - class ScriptOrchestratorFactory : IScriptOrchestratorFactory - { - readonly IScriptObserverBackoffStrategy scriptObserverBackOffStrategy; - readonly RpcCallExecutor rpcCallExecutor; - readonly ClientOperationMetricsBuilder clientOperationMetricsBuilder; - readonly OnScriptStatusResponseReceived onScriptStatusResponseReceived; - readonly OnScriptCompleted onScriptCompleted; - readonly TimeSpan onCancellationAbandonCompleteScriptAfter; - readonly ITentacleClientTaskLog logger; - - readonly IAsyncClientScriptService clientScriptServiceV1; - readonly IAsyncClientScriptServiceV2 clientScriptServiceV2; - readonly IAsyncClientKubernetesScriptServiceV1 clientKubernetesScriptServiceV1; - readonly IAsyncClientCapabilitiesServiceV2 clientCapabilitiesServiceV2; - readonly TentacleClientOptions clientOptions; - - public ScriptOrchestratorFactory( - IAsyncClientScriptService clientScriptServiceV1, - IAsyncClientScriptServiceV2 clientScriptServiceV2, - IAsyncClientKubernetesScriptServiceV1 clientKubernetesScriptServiceV1, - IAsyncClientCapabilitiesServiceV2 clientCapabilitiesServiceV2, - IScriptObserverBackoffStrategy scriptObserverBackOffStrategy, - RpcCallExecutor rpcCallExecutor, - ClientOperationMetricsBuilder clientOperationMetricsBuilder, - OnScriptStatusResponseReceived onScriptStatusResponseReceived, - OnScriptCompleted onScriptCompleted, - TimeSpan onCancellationAbandonCompleteScriptAfter, - TentacleClientOptions clientOptions, - ITentacleClientTaskLog logger) - { - this.clientScriptServiceV1 = clientScriptServiceV1; - this.clientScriptServiceV2 = clientScriptServiceV2; - this.clientKubernetesScriptServiceV1 = clientKubernetesScriptServiceV1; - this.clientCapabilitiesServiceV2 = clientCapabilitiesServiceV2; - this.scriptObserverBackOffStrategy = scriptObserverBackOffStrategy; - this.rpcCallExecutor = rpcCallExecutor; - this.clientOperationMetricsBuilder = clientOperationMetricsBuilder; - this.onScriptStatusResponseReceived = onScriptStatusResponseReceived; - this.onScriptCompleted = onScriptCompleted; - this.onCancellationAbandonCompleteScriptAfter = onCancellationAbandonCompleteScriptAfter; - this.clientOptions = clientOptions; - this.logger = logger; - } - - public async Task CreateOrchestrator(CancellationToken cancellationToken) - { - ScriptServiceVersion scriptServiceToUse; - try - { - scriptServiceToUse = await DetermineScriptServiceVersionToUse(cancellationToken); - } - catch (Exception ex) when (cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException("Script execution was cancelled", ex); - } - - if (scriptServiceToUse == ScriptServiceVersion.ScriptServiceVersion1) - { - return new ScriptServiceV1Orchestrator( - clientScriptServiceV1, - scriptObserverBackOffStrategy, - rpcCallExecutor, - clientOperationMetricsBuilder, - onScriptStatusResponseReceived, - onScriptCompleted, - clientOptions, - logger); - } - - if (scriptServiceToUse == ScriptServiceVersion.ScriptServiceVersion2) - { - return new ScriptServiceV2Orchestrator( - clientScriptServiceV2, - scriptObserverBackOffStrategy, - rpcCallExecutor, - clientOperationMetricsBuilder, - onScriptStatusResponseReceived, - onScriptCompleted, - onCancellationAbandonCompleteScriptAfter, - clientOptions, - logger); - } - - if (scriptServiceToUse == ScriptServiceVersion.KubernetesScriptServiceVersion1) - { - return new KubernetesScriptServiceV1Orchestrator( - clientKubernetesScriptServiceV1, - scriptObserverBackOffStrategy, - rpcCallExecutor, - clientOperationMetricsBuilder, - onScriptStatusResponseReceived, - onScriptCompleted, - onCancellationAbandonCompleteScriptAfter, - clientOptions, - logger); - } - - throw new InvalidOperationException($"Unknown ScriptServiceVersion {scriptServiceToUse}"); - } - - async Task DetermineScriptServiceVersionToUse(CancellationToken cancellationToken) - { - logger.Verbose("Determining ScriptService version to use"); - - async Task GetCapabilitiesFunc(CancellationToken ct) - { - var result = await clientCapabilitiesServiceV2.GetCapabilitiesAsync(new HalibutProxyRequestOptions(ct)); - - return result; - } - - var tentacleCapabilities = await rpcCallExecutor.Execute( - retriesEnabled: clientOptions.RpcRetrySettings.RetriesEnabled, - RpcCall.Create(nameof(ICapabilitiesServiceV2.GetCapabilities)), - GetCapabilitiesFunc, - logger, - clientOperationMetricsBuilder, - cancellationToken); - - logger.Verbose($"Discovered Tentacle capabilities: {string.Join(",", tentacleCapabilities.SupportedCapabilities)}"); - - // Check if we support any kubernetes script service. - // It's implied (and tested) that GetCapabilities will only return Kubernetes or non-Kubernetes script services, never a mix - if (tentacleCapabilities.HasAnyKubernetesScriptService()) - { - return DetermineKubernetesScriptServiceVersionToUse(); - } - - return DetermineShellScriptServiceVersionToUse(tentacleCapabilities); - } - - ScriptServiceVersion DetermineShellScriptServiceVersionToUse(CapabilitiesResponseV2 tentacleCapabilities) - { - if (tentacleCapabilities.HasScriptServiceV2()) - { - logger.Verbose("Using ScriptServiceV2"); - logger.Verbose(clientOptions.RpcRetrySettings.RetriesEnabled - ? $"RPC call retries are enabled. Retry timeout {rpcCallExecutor.RetryTimeout.TotalSeconds} seconds" - : "RPC call retries are disabled."); - return ScriptServiceVersion.ScriptServiceVersion2; - } - - logger.Verbose("RPC call retries are enabled but will not be used for Script Execution as a compatible ScriptService was not found. Please upgrade Tentacle to enable this feature."); - logger.Verbose("Using ScriptServiceV1"); - return ScriptServiceVersion.ScriptServiceVersion1; - } - - ScriptServiceVersion DetermineKubernetesScriptServiceVersionToUse() - { - logger.Verbose($"Using KubernetesScriptServiceV1"); - logger.Verbose(clientOptions.RpcRetrySettings.RetriesEnabled - ? $"RPC call retries are enabled. Retry timeout {rpcCallExecutor.RetryTimeout.TotalSeconds} seconds" - : "RPC call retries are disabled."); - - return ScriptServiceVersion.KubernetesScriptServiceVersion1; - } - } -} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/ScriptServiceV1Orchestrator.cs b/source/Octopus.Tentacle.Client/Scripts/ScriptServiceV1Executor.cs similarity index 62% rename from source/Octopus.Tentacle.Client/Scripts/ScriptServiceV1Orchestrator.cs rename to source/Octopus.Tentacle.Client/Scripts/ScriptServiceV1Executor.cs index 12b42f64f..2445edfb3 100644 --- a/source/Octopus.Tentacle.Client/Scripts/ScriptServiceV1Orchestrator.cs +++ b/source/Octopus.Tentacle.Client/Scripts/ScriptServiceV1Executor.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Halibut.ServiceModel; +using Octopus.Tentacle.Client.EventDriven; using Octopus.Tentacle.Client.Execution; using Octopus.Tentacle.Client.Observability; using Octopus.Tentacle.Client.Scripts.Models; @@ -13,28 +14,19 @@ namespace Octopus.Tentacle.Client.Scripts { - class ScriptServiceV1Orchestrator : ObservingScriptOrchestrator + class ScriptServiceV1Executor : IScriptExecutor { - readonly RpcCallExecutor rpcCallExecutor; readonly ClientOperationMetricsBuilder clientOperationMetricsBuilder; readonly ITentacleClientTaskLog logger; readonly IAsyncClientScriptService clientScriptServiceV1; - public ScriptServiceV1Orchestrator( + public ScriptServiceV1Executor( IAsyncClientScriptService clientScriptServiceV1, - IScriptObserverBackoffStrategy scriptObserverBackOffStrategy, RpcCallExecutor rpcCallExecutor, ClientOperationMetricsBuilder clientOperationMetricsBuilder, - OnScriptStatusResponseReceived onScriptStatusResponseReceived, - OnScriptCompleted onScriptCompleted, - TentacleClientOptions clientOptions, ITentacleClientTaskLog logger) - : base(scriptObserverBackOffStrategy, - onScriptStatusResponseReceived, - onScriptCompleted, - clientOptions) { this.clientScriptServiceV1 = clientScriptServiceV1; this.rpcCallExecutor = rpcCallExecutor; @@ -42,10 +34,10 @@ public ScriptServiceV1Orchestrator( this.logger = logger; } - protected override StartScriptCommand Map(ExecuteScriptCommand command) + StartScriptCommand Map(ExecuteScriptCommand command) { if (command is not ExecuteShellScriptCommand shellScriptCommand) - throw new InvalidOperationException($"{nameof(ScriptServiceV2Orchestrator)} only supports commands of type {nameof(ExecuteShellScriptCommand)}."); + throw new InvalidOperationException($"{nameof(ScriptServiceV2Executor)} only supports commands of type {nameof(ExecuteShellScriptCommand)}."); return new StartScriptCommand( shellScriptCommand.ScriptBody, @@ -58,16 +50,29 @@ protected override StartScriptCommand Map(ExecuteScriptCommand command) shellScriptCommand.Files.ToArray()); } - protected override ScriptExecutionStatus MapToStatus(ScriptStatusResponse response) - => new(response.Logs); - - protected override ScriptExecutionResult MapToResult(ScriptStatusResponse response) - => new(response.State, response.ExitCode); + static ScriptOperationExecutionResult Map(ScriptStatusResponse scriptStatusResponse) + { + return new ( + MapToScriptStatus(scriptStatusResponse), + new CommandContext(scriptStatusResponse.Ticket, scriptStatusResponse.NextLogSequence, ScriptServiceVersion.ScriptServiceVersion1)); + } - protected override ProcessState GetState(ScriptStatusResponse response) => response.State; + static ScriptStatus MapToScriptStatus(ScriptStatusResponse scriptStatusResponse) + { + return new ScriptStatus(scriptStatusResponse.State, scriptStatusResponse.ExitCode, scriptStatusResponse.Logs); + } - protected override async Task StartScript(StartScriptCommand command, CancellationToken scriptExecutionCancellationToken) + public async Task StartScript(ExecuteScriptCommand executeScriptCommand, + StartScriptIsBeingReAttempted startScriptIsBeingReAttempted, + CancellationToken scriptExecutionCancellationToken) { + // Script Service v1 is not idempotent, do not allow it to be re-attempted as it may run a second time. + if (startScriptIsBeingReAttempted == StartScriptIsBeingReAttempted.PossiblyBeingReAttempted) + { + throw new UnsafeStartAttemptException("Cannot start V1 script service if there is a chance it has been attempted before."); + } + + var command = Map(executeScriptCommand); var scriptTicket = await rpcCallExecutor.ExecuteWithNoRetries( RpcCall.Create(nameof(IScriptService.StartScript)), async ct => @@ -80,16 +85,16 @@ protected override async Task StartScript(StartScriptComma clientOperationMetricsBuilder, scriptExecutionCancellationToken).ConfigureAwait(false); - return new ScriptStatusResponse(scriptTicket, ProcessState.Pending, 0, new List(), 0); + return Map(new ScriptStatusResponse(scriptTicket, ProcessState.Pending, 0, new List(), 0)); } - protected override async Task GetStatus(ScriptStatusResponse lastStatusResponse, CancellationToken scriptExecutionCancellationToken) + public async Task GetStatus(CommandContext commandContext, CancellationToken scriptExecutionCancellationToken) { var scriptStatusResponseV1 = await rpcCallExecutor.ExecuteWithNoRetries( RpcCall.Create(nameof(IScriptService.GetStatus)), async ct => { - var request = new ScriptStatusRequest(lastStatusResponse.Ticket, lastStatusResponse.NextLogSequence); + var request = new ScriptStatusRequest(commandContext.ScriptTicket, commandContext.NextLogSequence); var result = await clientScriptServiceV1.GetStatusAsync(request, new HalibutProxyRequestOptions(ct)); return result; @@ -98,16 +103,16 @@ protected override async Task GetStatus(ScriptStatusRespon clientOperationMetricsBuilder, scriptExecutionCancellationToken).ConfigureAwait(false); - return scriptStatusResponseV1; + return Map(scriptStatusResponseV1); } - protected override async Task Cancel(ScriptStatusResponse lastStatusResponse, CancellationToken scriptExecutionCancellationToken) + public async Task CancelScript(CommandContext lastStatusResponse) { var response = await rpcCallExecutor.ExecuteWithNoRetries( RpcCall.Create(nameof(IScriptService.CancelScript)), async ct => { - var request = new CancelScriptCommand(lastStatusResponse.Ticket, lastStatusResponse.NextLogSequence); + var request = new CancelScriptCommand(lastStatusResponse.ScriptTicket, lastStatusResponse.NextLogSequence); var result = await clientScriptServiceV1.CancelScriptAsync(request, new HalibutProxyRequestOptions(ct)); return result; @@ -116,16 +121,16 @@ protected override async Task Cancel(ScriptStatusResponse clientOperationMetricsBuilder, CancellationToken.None).ConfigureAwait(false); - return response; + return Map(response); } - protected override async Task Finish(ScriptStatusResponse lastStatusResponse, CancellationToken scriptExecutionCancellationToken) + public async Task CompleteScript(CommandContext lastStatusResponse, CancellationToken scriptExecutionCancellationToken) { var response = await rpcCallExecutor.ExecuteWithNoRetries( RpcCall.Create(nameof(IScriptService.CompleteScript)), async ct => { - var request = new CompleteScriptCommand(lastStatusResponse.Ticket, lastStatusResponse.NextLogSequence); + var request = new CompleteScriptCommand(lastStatusResponse.ScriptTicket, lastStatusResponse.NextLogSequence); var result = await clientScriptServiceV1.CompleteScriptAsync(request, new HalibutProxyRequestOptions(ct)); return result; @@ -134,9 +139,7 @@ protected override async Task Finish(ScriptStatusResponse clientOperationMetricsBuilder, CancellationToken.None).ConfigureAwait(false); - OnScriptStatusResponseReceived(response); - - return response; + return MapToScriptStatus(response); } } } \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/ScriptServiceV2Orchestrator.cs b/source/Octopus.Tentacle.Client/Scripts/ScriptServiceV2Executor.cs similarity index 61% rename from source/Octopus.Tentacle.Client/Scripts/ScriptServiceV2Orchestrator.cs rename to source/Octopus.Tentacle.Client/Scripts/ScriptServiceV2Executor.cs index 149f7a6ae..51a79f410 100644 --- a/source/Octopus.Tentacle.Client/Scripts/ScriptServiceV2Orchestrator.cs +++ b/source/Octopus.Tentacle.Client/Scripts/ScriptServiceV2Executor.cs @@ -1,9 +1,9 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Halibut; using Halibut.ServiceModel; +using Octopus.Tentacle.Client.EventDriven; using Octopus.Tentacle.Client.Execution; using Octopus.Tentacle.Client.Observability; using Octopus.Tentacle.Client.Scripts.Models; @@ -15,40 +15,35 @@ namespace Octopus.Tentacle.Client.Scripts { - class ScriptServiceV2Orchestrator : ObservingScriptOrchestrator + class ScriptServiceV2Executor : IScriptExecutor { readonly IAsyncClientScriptServiceV2 clientScriptServiceV2; readonly RpcCallExecutor rpcCallExecutor; readonly ClientOperationMetricsBuilder clientOperationMetricsBuilder; readonly TimeSpan onCancellationAbandonCompleteScriptAfter; readonly ITentacleClientTaskLog logger; + readonly TentacleClientOptions clientOptions; - public ScriptServiceV2Orchestrator( + public ScriptServiceV2Executor( IAsyncClientScriptServiceV2 clientScriptServiceV2, - IScriptObserverBackoffStrategy scriptObserverBackOffStrategy, RpcCallExecutor rpcCallExecutor, ClientOperationMetricsBuilder clientOperationMetricsBuilder, - OnScriptStatusResponseReceived onScriptStatusResponseReceived, - OnScriptCompleted onScriptCompleted, TimeSpan onCancellationAbandonCompleteScriptAfter, TentacleClientOptions clientOptions, ITentacleClientTaskLog logger) - : base(scriptObserverBackOffStrategy, - onScriptStatusResponseReceived, - onScriptCompleted, - clientOptions) { this.clientScriptServiceV2 = clientScriptServiceV2; this.rpcCallExecutor = rpcCallExecutor; this.clientOperationMetricsBuilder = clientOperationMetricsBuilder; this.onCancellationAbandonCompleteScriptAfter = onCancellationAbandonCompleteScriptAfter; + this.clientOptions = clientOptions; this.logger = logger; } - protected override StartScriptCommandV2 Map(ExecuteScriptCommand command) + StartScriptCommandV2 Map(ExecuteScriptCommand command) { if (command is not ExecuteShellScriptCommand shellScriptCommand) - throw new InvalidOperationException($"{nameof(ScriptServiceV2Orchestrator)} only supports commands of type {nameof(ExecuteShellScriptCommand)}."); + throw new InvalidOperationException($"{nameof(ScriptServiceV2Executor)} only supports commands of type {nameof(ExecuteShellScriptCommand)}."); return new StartScriptCommandV2( shellScriptCommand.ScriptBody, @@ -62,17 +57,19 @@ protected override StartScriptCommandV2 Map(ExecuteScriptCommand command) shellScriptCommand.Scripts, shellScriptCommand.Files.ToArray()); } - - protected override ScriptExecutionStatus MapToStatus(ScriptStatusResponseV2 response) - => new(response.Logs); - - protected override ScriptExecutionResult MapToResult(ScriptStatusResponseV2 response) - => new(response.State, response.ExitCode); - - protected override ProcessState GetState(ScriptStatusResponseV2 response) => response.State; - - protected override async Task StartScript(StartScriptCommandV2 command, CancellationToken scriptExecutionCancellationToken) + + static ScriptOperationExecutionResult Map(ScriptStatusResponseV2 scriptStatusResponse) { + return new ( + new ScriptStatus(scriptStatusResponse.State, scriptStatusResponse.ExitCode, scriptStatusResponse.Logs), + new CommandContext(scriptStatusResponse.Ticket, scriptStatusResponse.NextLogSequence, ScriptServiceVersion.ScriptServiceVersion2)); + } + + public async Task StartScript(ExecuteScriptCommand executeScriptCommand, + StartScriptIsBeingReAttempted startScriptIsBeingReAttempted, + CancellationToken scriptExecutionCancellationToken) + { + var command = Map(executeScriptCommand); ScriptStatusResponseV2 scriptStatusResponse; var startScriptCallsConnectedCount = 0; try @@ -95,13 +92,15 @@ void OnErrorAction(Exception ex) } scriptStatusResponse = await rpcCallExecutor.Execute( - retriesEnabled: ClientOptions.RpcRetrySettings.RetriesEnabled, + retriesEnabled: clientOptions.RpcRetrySettings.RetriesEnabled, RpcCall.Create(nameof(IScriptServiceV2.StartScript)), StartScriptAction, OnErrorAction, logger, clientOperationMetricsBuilder, scriptExecutionCancellationToken).ConfigureAwait(false); + + return Map(scriptStatusResponse); } catch (Exception ex) when (scriptExecutionCancellationToken.IsCancellationRequested) { @@ -115,69 +114,43 @@ void OnErrorAction(Exception ex) if (!startScriptCallIsConnecting || startScriptCallIsBeingRetried) { - // We have to assume the script started executing and call CancelScript and CompleteScript - // We don't have a response so we need to create one to continue the execution flow - scriptStatusResponse = new ScriptStatusResponseV2( - command.ScriptTicket, - ProcessState.Pending, - ScriptExitCodes.RunningExitCode, - new List(), - 0); - - try - { - await ObserveUntilCompleteThenFinish(scriptStatusResponse, scriptExecutionCancellationToken).ConfigureAwait(false); - } - catch (Exception observerUntilCompleteException) - { - // Throw an error so the caller knows that execution of the script was cancelled - throw new OperationCanceledException("Script execution was cancelled", observerUntilCompleteException); - } - - // Throw an error so the caller knows that execution of the script was cancelled - throw new OperationCanceledException("Script execution was cancelled"); + // We want to cancel the potentially started script, and wait till it finishes. By returning a result, the outer orchestration will take care of this. + return ScriptOperationExecutionResult.CreateScriptStartedResult(command.ScriptTicket, ScriptServiceVersion.ScriptServiceVersion2); } // If the StartScript call was not in-flight or being retries then we know the script has not started executing on Tentacle // So can exit without calling CancelScript or CompleteScript throw new OperationCanceledException("Script execution was cancelled", ex); } - - return scriptStatusResponse; } - protected override async Task GetStatus(ScriptStatusResponseV2 lastStatusResponse, CancellationToken scriptExecutionCancellationToken) + + + public async Task GetStatus(CommandContext commandContext, CancellationToken scriptExecutionCancellationToken) { - try + async Task GetStatusAction(CancellationToken ct) { - async Task GetStatusAction(CancellationToken ct) - { - var request = new ScriptStatusRequestV2(lastStatusResponse.Ticket, lastStatusResponse.NextLogSequence); - var result = await clientScriptServiceV2.GetStatusAsync(request, new HalibutProxyRequestOptions(ct)); + var request = new ScriptStatusRequestV2(commandContext.ScriptTicket, commandContext.NextLogSequence); + var result = await clientScriptServiceV2.GetStatusAsync(request, new HalibutProxyRequestOptions(ct)); - return result; - } - - return await rpcCallExecutor.Execute( - retriesEnabled: ClientOptions.RpcRetrySettings.RetriesEnabled, - RpcCall.Create(nameof(IScriptServiceV2.GetStatus)), - GetStatusAction, - logger, - clientOperationMetricsBuilder, - scriptExecutionCancellationToken).ConfigureAwait(false); - } - catch (Exception e) when (e is OperationCanceledException && scriptExecutionCancellationToken.IsCancellationRequested) - { - // Return the last known response without logs when cancellation occurs and let the script execution go into the CancelScript and CompleteScript flow - return new ScriptStatusResponseV2(lastStatusResponse.Ticket, lastStatusResponse.State, lastStatusResponse.ExitCode, new List(), lastStatusResponse.NextLogSequence); + return result; } + + var scriptStatusResponseV2 = await rpcCallExecutor.Execute( + retriesEnabled: clientOptions.RpcRetrySettings.RetriesEnabled, + RpcCall.Create(nameof(IScriptServiceV2.GetStatus)), + GetStatusAction, + logger, + clientOperationMetricsBuilder, + scriptExecutionCancellationToken).ConfigureAwait(false); + return Map(scriptStatusResponseV2); } - protected override async Task Cancel(ScriptStatusResponseV2 lastStatusResponse, CancellationToken scriptExecutionCancellationToken) + public async Task CancelScript(CommandContext lastStatusResponse) { async Task CancelScriptAction(CancellationToken ct) { - var request = new CancelScriptCommandV2(lastStatusResponse.Ticket, lastStatusResponse.NextLogSequence); + var request = new CancelScriptCommandV2(lastStatusResponse.ScriptTicket, lastStatusResponse.NextLogSequence); var result = await clientScriptServiceV2.CancelScriptAsync(request, new HalibutProxyRequestOptions(ct)); return result; @@ -187,17 +160,18 @@ async Task CancelScriptAction(CancellationToken ct) // If script execution is already triggering RPC Retries and then the script execution is cancelled there is a high chance that the cancel RPC call will fail as well and go into RPC retries. // We could potentially reduce the time to failure by not retrying the cancel RPC Call if the previous RPC call was already triggering RPC Retries. - return await rpcCallExecutor.Execute( - retriesEnabled: ClientOptions.RpcRetrySettings.RetriesEnabled, + var scriptStatusResponseV2 = await rpcCallExecutor.Execute( + retriesEnabled: clientOptions.RpcRetrySettings.RetriesEnabled, RpcCall.Create(nameof(IScriptServiceV2.CancelScript)), CancelScriptAction, logger, clientOperationMetricsBuilder, // We don't want to cancel this operation as it is responsible for stopping the script executing on the Tentacle CancellationToken.None).ConfigureAwait(false); + return Map(scriptStatusResponseV2); } - protected override async Task Finish(ScriptStatusResponseV2 lastStatusResponse, CancellationToken scriptExecutionCancellationToken) + public async Task CompleteScript(CommandContext lastStatusResponse, CancellationToken scriptExecutionCancellationToken) { try { @@ -212,7 +186,7 @@ await rpcCallExecutor.ExecuteWithNoRetries( RpcCall.Create(nameof(IScriptServiceV2.CompleteScript)), async ct => { - var request = new CompleteScriptCommandV2(lastStatusResponse.Ticket); + var request = new CompleteScriptCommandV2(lastStatusResponse.ScriptTicket); await clientScriptServiceV2.CompleteScriptAsync(request, new HalibutProxyRequestOptions(ct)); }, logger, @@ -225,7 +199,7 @@ await rpcCallExecutor.ExecuteWithNoRetries( logger.Verbose(ex); } - return lastStatusResponse; + return null; } } } \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/ScriptServiceVersion.cs b/source/Octopus.Tentacle.Client/Scripts/ScriptServiceVersion.cs index 035ea0f53..972256b9b 100644 --- a/source/Octopus.Tentacle.Client/Scripts/ScriptServiceVersion.cs +++ b/source/Octopus.Tentacle.Client/Scripts/ScriptServiceVersion.cs @@ -1,10 +1,9 @@ namespace Octopus.Tentacle.Client.Scripts { - record ScriptServiceVersion(string Value) + public record ScriptServiceVersion(string Value) { public static ScriptServiceVersion ScriptServiceVersion1 = new(nameof(ScriptServiceVersion1)); public static ScriptServiceVersion ScriptServiceVersion2 = new(nameof(ScriptServiceVersion2)); - public static ScriptServiceVersion KubernetesScriptServiceVersion1Alpha = new(nameof(KubernetesScriptServiceVersion1Alpha)); public static ScriptServiceVersion KubernetesScriptServiceVersion1 = new(nameof(KubernetesScriptServiceVersion1)); public override string ToString() => Value; diff --git a/source/Octopus.Tentacle.Client/Scripts/ScriptServiceVersionSelector.cs b/source/Octopus.Tentacle.Client/Scripts/ScriptServiceVersionSelector.cs new file mode 100644 index 000000000..c8d7c57e2 --- /dev/null +++ b/source/Octopus.Tentacle.Client/Scripts/ScriptServiceVersionSelector.cs @@ -0,0 +1,94 @@ +using System.Threading; +using System.Threading.Tasks; +using Halibut.ServiceModel; +using Octopus.Tentacle.Client.Capabilities; +using Octopus.Tentacle.Client.Execution; +using Octopus.Tentacle.Client.Kubernetes; +using Octopus.Tentacle.Client.Observability; +using Octopus.Tentacle.Contracts.Capabilities; +using Octopus.Tentacle.Contracts.ClientServices; +using Octopus.Tentacle.Contracts.Logging; +using Octopus.Tentacle.Contracts.Observability; + +namespace Octopus.Tentacle.Client.Scripts +{ + public class ScriptServiceVersionSelector + { + readonly ITentacleClientTaskLog logger; + readonly IAsyncClientCapabilitiesServiceV2 clientCapabilitiesServiceV2; + readonly RpcCallExecutor rpcCallExecutor; + readonly TentacleClientOptions clientOptions; + readonly ClientOperationMetricsBuilder clientOperationMetricsBuilder; + + internal ScriptServiceVersionSelector( + IAsyncClientCapabilitiesServiceV2 clientCapabilitiesServiceV2, + ITentacleClientTaskLog logger, + RpcCallExecutor rpcCallExecutor, + TentacleClientOptions clientOptions, + ClientOperationMetricsBuilder clientOperationMetricsBuilder) + { + this.clientCapabilitiesServiceV2 = clientCapabilitiesServiceV2; + this.logger = logger; + this.rpcCallExecutor = rpcCallExecutor; + this.clientOptions = clientOptions; + this.clientOperationMetricsBuilder = clientOperationMetricsBuilder; + } + + public async Task DetermineScriptServiceVersionToUse(CancellationToken cancellationToken) + { + logger.Verbose("Determining ScriptService version to use"); + + async Task GetCapabilitiesFunc(CancellationToken ct) + { + var result = await clientCapabilitiesServiceV2.GetCapabilitiesAsync(new HalibutProxyRequestOptions(ct)); + + return result; + } + + var tentacleCapabilities = await rpcCallExecutor.Execute( + retriesEnabled: clientOptions.RpcRetrySettings.RetriesEnabled, + RpcCall.Create(nameof(ICapabilitiesServiceV2.GetCapabilities)), + GetCapabilitiesFunc, + logger, + clientOperationMetricsBuilder, + cancellationToken); + + logger.Verbose($"Discovered Tentacle capabilities: {string.Join(",", tentacleCapabilities.SupportedCapabilities)}"); + + // Check if we support any kubernetes script service. + // It's implied (and tested) that GetCapabilities will only return Kubernetes or non-Kubernetes script services, never a mix + if (tentacleCapabilities.HasAnyKubernetesScriptService()) + { + return DetermineKubernetesScriptServiceVersionToUse(); + } + + return DetermineShellScriptServiceVersionToUse(tentacleCapabilities); + } + + ScriptServiceVersion DetermineShellScriptServiceVersionToUse(CapabilitiesResponseV2 tentacleCapabilities) + { + if (tentacleCapabilities.HasScriptServiceV2()) + { + logger.Verbose("Using ScriptServiceV2"); + logger.Verbose(clientOptions.RpcRetrySettings.RetriesEnabled + ? $"RPC call retries are enabled. Retry timeout {rpcCallExecutor.RetryTimeout.TotalSeconds} seconds" + : "RPC call retries are disabled."); + return ScriptServiceVersion.ScriptServiceVersion2; + } + + logger.Verbose("RPC call retries are enabled but will not be used for Script Execution as a compatible ScriptService was not found. Please upgrade Tentacle to enable this feature."); + logger.Verbose("Using ScriptServiceV1"); + return ScriptServiceVersion.ScriptServiceVersion1; + } + + ScriptServiceVersion DetermineKubernetesScriptServiceVersionToUse() + { + logger.Verbose($"Using KubernetesScriptServiceV1"); + logger.Verbose(clientOptions.RpcRetrySettings.RetriesEnabled + ? $"RPC call retries are enabled. Retry timeout {rpcCallExecutor.RetryTimeout.TotalSeconds} seconds" + : "RPC call retries are disabled."); + + return ScriptServiceVersion.KubernetesScriptServiceVersion1; + } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/Scripts/UnsafeStartAttemptException.cs b/source/Octopus.Tentacle.Client/Scripts/UnsafeStartAttemptException.cs new file mode 100644 index 000000000..1ae27b201 --- /dev/null +++ b/source/Octopus.Tentacle.Client/Scripts/UnsafeStartAttemptException.cs @@ -0,0 +1,20 @@ +using System; + +namespace Octopus.Tentacle.Client.Scripts +{ + /// + /// This is thrown when we are attempting to start a script using a V1 script service, but we are trying to do so for the second time. + /// For resilient deployments, we need to ensure idempotency for our operations, including starting a script. + /// Idempotency was built into script service V2 onwards. + /// If we attempt to use a V1 script service on the resilient pipeline, then we are allowed to start the script. + /// But if anything goes wrong, and we need to reattempt, then we cannot guarantee the script has not already started. + /// In this case, the safest action is to fail (with this exception). + /// + public class UnsafeStartAttemptException : Exception + { + public UnsafeStartAttemptException(string message) : base(message) + { + + } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/ServiceHelpers/AllClients.cs b/source/Octopus.Tentacle.Client/ServiceHelpers/AllClients.cs new file mode 100644 index 000000000..57bd2f416 --- /dev/null +++ b/source/Octopus.Tentacle.Client/ServiceHelpers/AllClients.cs @@ -0,0 +1,41 @@ +using Halibut; +using Octopus.Tentacle.Contracts; +using Octopus.Tentacle.Contracts.Capabilities; +using Octopus.Tentacle.Contracts.ClientServices; +using Octopus.Tentacle.Contracts.KubernetesScriptServiceV1; +using Octopus.Tentacle.Contracts.ScriptServiceV2; + +namespace Octopus.Tentacle.Client.ServiceHelpers +{ + public class AllClients + { + public IAsyncClientScriptService ScriptServiceV1 { get; } + public IAsyncClientScriptServiceV2 ScriptServiceV2 { get; } + public IAsyncClientKubernetesScriptServiceV1 KubernetesScriptServiceV1 { get; } + public IAsyncClientFileTransferService ClientFileTransferServiceV1 { get; } + public IAsyncClientCapabilitiesServiceV2 CapabilitiesServiceV2 { get; } + + public AllClients(IHalibutRuntime halibutRuntime, ServiceEndPoint serviceEndPoint) : this(halibutRuntime, serviceEndPoint, null) + { + } + + + internal AllClients(IHalibutRuntime halibutRuntime, ServiceEndPoint serviceEndPoint, ITentacleServiceDecoratorFactory? tentacleServicesDecoratorFactory) + { + ScriptServiceV1 = halibutRuntime.CreateAsyncClient(serviceEndPoint); + ScriptServiceV2 = halibutRuntime.CreateAsyncClient(serviceEndPoint); + KubernetesScriptServiceV1 = halibutRuntime.CreateAsyncClient(serviceEndPoint); + ClientFileTransferServiceV1 = halibutRuntime.CreateAsyncClient(serviceEndPoint); + CapabilitiesServiceV2 = halibutRuntime.CreateAsyncClient(serviceEndPoint).WithBackwardsCompatability(); + + if (tentacleServicesDecoratorFactory != null) + { + ScriptServiceV1 = tentacleServicesDecoratorFactory.Decorate(ScriptServiceV1); + ScriptServiceV2 = tentacleServicesDecoratorFactory.Decorate(ScriptServiceV2); + KubernetesScriptServiceV1 = tentacleServicesDecoratorFactory.Decorate(KubernetesScriptServiceV1); + ClientFileTransferServiceV1 = tentacleServicesDecoratorFactory.Decorate(ClientFileTransferServiceV1); + CapabilitiesServiceV2 = tentacleServicesDecoratorFactory.Decorate(CapabilitiesServiceV2); + } + } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/StartScriptIsBeingReAttempted.cs b/source/Octopus.Tentacle.Client/StartScriptIsBeingReAttempted.cs new file mode 100644 index 000000000..e9ab7ac44 --- /dev/null +++ b/source/Octopus.Tentacle.Client/StartScriptIsBeingReAttempted.cs @@ -0,0 +1,10 @@ +using System; + +namespace Octopus.Tentacle.Client +{ + public enum StartScriptIsBeingReAttempted + { + FirstAttempt, + PossiblyBeingReAttempted + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Client/TentacleClient.cs b/source/Octopus.Tentacle.Client/TentacleClient.cs index b5ca6e82d..f5e9ead37 100644 --- a/source/Octopus.Tentacle.Client/TentacleClient.cs +++ b/source/Octopus.Tentacle.Client/TentacleClient.cs @@ -8,13 +8,11 @@ using Octopus.Tentacle.Client.Observability; using Octopus.Tentacle.Client.Scripts; using Octopus.Tentacle.Client.Scripts.Models; +using Octopus.Tentacle.Client.ServiceHelpers; using Octopus.Tentacle.Contracts; using Octopus.Tentacle.Contracts.Capabilities; -using Octopus.Tentacle.Contracts.ClientServices; -using Octopus.Tentacle.Contracts.KubernetesScriptServiceV1; using Octopus.Tentacle.Contracts.Logging; using Octopus.Tentacle.Contracts.Observability; -using Octopus.Tentacle.Contracts.ScriptServiceV2; using ITentacleClientObserver = Octopus.Tentacle.Contracts.Observability.ITentacleClientObserver; namespace Octopus.Tentacle.Client @@ -24,13 +22,9 @@ public class TentacleClient : ITentacleClient readonly IScriptObserverBackoffStrategy scriptObserverBackOffStrategy; readonly ITentacleClientObserver tentacleClientObserver; readonly RpcCallExecutor rpcCallExecutor; - - readonly IAsyncClientScriptService scriptServiceV1; - readonly IAsyncClientScriptServiceV2 scriptServiceV2; - readonly IAsyncClientKubernetesScriptServiceV1 kubernetesScriptServiceV1; - readonly IAsyncClientFileTransferService clientFileTransferServiceV1; - readonly IAsyncClientCapabilitiesServiceV2 capabilitiesServiceV2; + readonly TentacleClientOptions clientOptions; + readonly AllClients allClients; public static void CacheServiceWasNotFoundResponseMessages(IHalibutRuntime halibutRuntime) { @@ -69,7 +63,7 @@ internal TentacleClient( this.tentacleClientObserver = tentacleClientObserver.DecorateWithNonThrowingTentacleClientObserver(); this.clientOptions = clientOptions; - + if (halibutRuntime.OverrideErrorResponseMessageCaching == null) { // Best effort to make sure the HalibutRuntime has been configured to Cache ServiceNotFoundExceptions @@ -77,20 +71,7 @@ internal TentacleClient( throw new ArgumentException("Ensure that TentacleClient.CacheServiceWasNotFoundResponseMessages has been called for the HalibutRuntime", nameof(halibutRuntime)); } - scriptServiceV1 = halibutRuntime.CreateAsyncClient(serviceEndPoint); - scriptServiceV2 = halibutRuntime.CreateAsyncClient(serviceEndPoint); - kubernetesScriptServiceV1 = halibutRuntime.CreateAsyncClient(serviceEndPoint); - clientFileTransferServiceV1 = halibutRuntime.CreateAsyncClient(serviceEndPoint); - capabilitiesServiceV2 = halibutRuntime.CreateAsyncClient(serviceEndPoint).WithBackwardsCompatability(); - - if (tentacleServicesDecoratorFactory != null) - { - scriptServiceV1 = tentacleServicesDecoratorFactory.Decorate(scriptServiceV1); - scriptServiceV2 = tentacleServicesDecoratorFactory.Decorate(scriptServiceV2); - kubernetesScriptServiceV1 = tentacleServicesDecoratorFactory.Decorate(kubernetesScriptServiceV1); - clientFileTransferServiceV1 = tentacleServicesDecoratorFactory.Decorate(clientFileTransferServiceV1); - capabilitiesServiceV2 = tentacleServicesDecoratorFactory.Decorate(capabilitiesServiceV2); - } + allClients = new AllClients(halibutRuntime, serviceEndPoint, tentacleServicesDecoratorFactory); rpcCallExecutor = RpcCallExecutorFactory.Create(this.clientOptions.RpcRetrySettings.RetryDuration, this.tentacleClientObserver); } @@ -104,7 +85,7 @@ public async Task UploadFile(string fileName, string path, DataStr async Task UploadFileAction(CancellationToken ct) { logger.Info($"Beginning upload of {fileName} to Tentacle"); - var result = await clientFileTransferServiceV1.UploadFileAsync(path, package, new HalibutProxyRequestOptions(ct)); + var result = await allClients.ClientFileTransferServiceV1.UploadFileAsync(path, package, new HalibutProxyRequestOptions(ct)); logger.Info("Upload complete"); return result; @@ -139,7 +120,7 @@ async Task UploadFileAction(CancellationToken ct) async Task DownloadFileAction(CancellationToken ct) { logger.Info($"Beginning download of {Path.GetFileName(remotePath)} from Tentacle"); - var result = await clientFileTransferServiceV1.DownloadFileAsync(remotePath, new HalibutProxyRequestOptions(ct)); + var result = await allClients.ClientFileTransferServiceV1.DownloadFileAsync(remotePath, new HalibutProxyRequestOptions(ct)); logger.Info("Download complete"); return result; @@ -177,21 +158,18 @@ public async Task ExecuteScript(ExecuteScriptCommand exec try { - var factory = new ScriptOrchestratorFactory( - scriptServiceV1, - scriptServiceV2, - kubernetesScriptServiceV1, - capabilitiesServiceV2, - scriptObserverBackOffStrategy, - rpcCallExecutor, + var scriptExecutor = new ScriptExecutor( + allClients, + logger, + tentacleClientObserver, operationMetricsBuilder, + clientOptions, + OnCancellationAbandonCompleteScriptAfter); + + var orchestrator = new ObservingScriptOrchestrator(scriptObserverBackOffStrategy, onScriptStatusResponseReceived, onScriptCompleted, - OnCancellationAbandonCompleteScriptAfter, - clientOptions, - logger); - - var orchestrator = await factory.CreateOrchestrator(scriptExecutionCancellationToken); + scriptExecutor); var result = await orchestrator.ExecuteScript(executeScriptCommand, scriptExecutionCancellationToken); diff --git a/source/Octopus.Tentacle.Contracts/ScriptStatus.cs b/source/Octopus.Tentacle.Contracts/ScriptStatus.cs new file mode 100644 index 000000000..de6a7aa77 --- /dev/null +++ b/source/Octopus.Tentacle.Contracts/ScriptStatus.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Octopus.Tentacle.Contracts +{ + public class ScriptStatus + { + public ProcessState State { get; } + public int? ExitCode { get; } + public List Logs { get; } + + public ScriptStatus(ProcessState state, int? exitCode, List logs) + { + State = state; + ExitCode = exitCode; + Logs = logs; + } + } +} \ No newline at end of file diff --git a/source/Tentacle.sln.DotSettings b/source/Tentacle.sln.DotSettings index 0e7802a6d..2c88b318a 100644 --- a/source/Tentacle.sln.DotSettings +++ b/source/Tentacle.sln.DotSettings @@ -183,6 +183,7 @@ True True True + True True True True