Skip to content

Commit d50b0b5

Browse files
committed
Add script isolation enforcement
Pass isolation options as parameters Add unit tests for script isolation Update test to pass options
1 parent 03276e2 commit d50b0b5

File tree

13 files changed

+818
-1
lines changed

13 files changed

+818
-1
lines changed

source/Calamari.Common/CalamariFlavourProgram.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using Calamari.Common.Features.FunctionScriptContributions;
1313
using Calamari.Common.Features.Packages;
1414
using Calamari.Common.Features.Processes;
15+
using Calamari.Common.Features.Processes.ScriptIsolation;
1516
using Calamari.Common.Features.Scripting;
1617
using Calamari.Common.Features.Scripting.DotnetScript;
1718
using Calamari.Common.Features.StructuredVariables;
@@ -78,6 +79,7 @@ protected virtual int Run(string[] args)
7879
}
7980
#endif
8081

82+
using var _ = Isolation.Enforce(options.ScriptIsolation);
8183
return ResolveAndExecuteCommand(container, options);
8284
}
8385
catch (Exception ex)

source/Calamari.Common/CalamariFlavourProgramAsync.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Diagnostics;
44
using System.Linq;
55
using System.Reflection;
6+
using System.Threading;
67
using System.Threading.Tasks;
78
using Autofac;
89
using Autofac.Core;
@@ -14,6 +15,7 @@
1415
using Calamari.Common.Features.FunctionScriptContributions;
1516
using Calamari.Common.Features.Packages;
1617
using Calamari.Common.Features.Processes;
18+
using Calamari.Common.Features.Processes.ScriptIsolation;
1719
using Calamari.Common.Features.Scripting;
1820
using Calamari.Common.Features.Scripting.DotnetScript;
1921
using Calamari.Common.Features.StructuredVariables;
@@ -143,6 +145,7 @@ protected async Task<int> Run(string[] args)
143145
}
144146
#endif
145147

148+
await using var _ = await Isolation.EnforceAsync(options.ScriptIsolation, CancellationToken.None);
146149
await ResolveAndExecuteCommand(container, options);
147150
return 0;
148151
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using System;
2+
using System.IO;
3+
using System.Threading.Tasks;
4+
5+
namespace Calamari.Common.Features.Processes.ScriptIsolation;
6+
7+
public static class FileLock
8+
{
9+
public static ILockHandle Acquire(LockOptions lockOptions)
10+
{
11+
var fileShareMode = GetFileShareMode(lockOptions.Type);
12+
try
13+
{
14+
var fileStream = File.Open(lockOptions.LockFile, FileMode.OpenOrCreate, FileAccess.ReadWrite, fileShareMode);
15+
return new LockHandle(fileStream);
16+
}
17+
catch (IOException e)
18+
{
19+
throw new LockRejectedException(e);
20+
}
21+
}
22+
23+
static FileShare GetFileShareMode(LockType isolationLevel)
24+
{
25+
return isolationLevel switch
26+
{
27+
LockType.Exclusive => FileShare.None,
28+
LockType.Shared => FileShare.ReadWrite,
29+
_ => throw new ArgumentOutOfRangeException(nameof(isolationLevel), isolationLevel, null)
30+
};
31+
}
32+
33+
sealed class LockHandle(FileStream fileStream) : ILockHandle
34+
{
35+
public void Dispose()
36+
{
37+
fileStream.Dispose();
38+
}
39+
40+
public async ValueTask DisposeAsync()
41+
{
42+
await fileStream.DisposeAsync();
43+
}
44+
}
45+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
using System;
2+
3+
namespace Calamari.Common.Features.Processes.ScriptIsolation;
4+
5+
public interface ILockHandle : IAsyncDisposable, IDisposable;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using Calamari.Common.Plumbing.Commands;
5+
using Calamari.Common.Plumbing.Logging;
6+
using Polly;
7+
8+
namespace Calamari.Common.Features.Processes.ScriptIsolation;
9+
10+
public static class Isolation
11+
{
12+
// Compare these values with the standard script isolation mutex strategy
13+
static readonly TimeSpan RetryInitialDelay = TimeSpan.FromMilliseconds(10);
14+
static readonly TimeSpan RetryMaxDelay = TimeSpan.FromMilliseconds(500);
15+
16+
public static ILockHandle Enforce(CommonOptions.ScriptIsolationOptions scriptIsolationOptions)
17+
{
18+
var lockOptions = LockOptions.FromScriptIsolationOptionsOrNull(scriptIsolationOptions);
19+
if (lockOptions is null)
20+
{
21+
return new NoLock();
22+
}
23+
24+
var pipeline = BuildLockAcquisitionPipeline(lockOptions);
25+
LogIsolation(lockOptions);
26+
try
27+
{
28+
return pipeline.Execute(FileLock.Acquire, lockOptions);
29+
}
30+
catch (Exception exception)
31+
{
32+
LockRejectedException.Throw(exception);
33+
throw; // Satisfy the compiler
34+
}
35+
}
36+
37+
public static async Task<ILockHandle> EnforceAsync(
38+
CommonOptions.ScriptIsolationOptions scriptIsolationOptions,
39+
CancellationToken cancellationToken
40+
)
41+
{
42+
var lockOptions = LockOptions.FromScriptIsolationOptionsOrNull(scriptIsolationOptions);
43+
if (lockOptions is null)
44+
{
45+
return new NoLock();
46+
}
47+
48+
var pipeline = BuildLockAcquisitionPipeline(lockOptions);
49+
LogIsolation(lockOptions);
50+
try
51+
{
52+
return await pipeline.ExecuteAsync(static (o, _) => ValueTask.FromResult(FileLock.Acquire(o)), lockOptions, cancellationToken);
53+
}
54+
catch (Exception exception)
55+
{
56+
LockRejectedException.Throw(exception);
57+
throw; // Satisfy the compiler
58+
}
59+
}
60+
61+
static void LogIsolation(LockOptions lockOptions)
62+
{
63+
Log.Verbose($"Acquiring script isolation mutex {lockOptions.Name} with {lockOptions.Type} lock");
64+
}
65+
66+
static ResiliencePipeline<ILockHandle> BuildLockAcquisitionPipeline(LockOptions lockOptions)
67+
{
68+
var builder = new ResiliencePipelineBuilder<ILockHandle>();
69+
// Timeout must be between 10ms and 1 day. (Polly)
70+
// If it's 10ms or less, we'll skip timeout and limit retries
71+
// If it's more than 1 day, we'll assume indefinite retries with no timeout
72+
var retryAttempts = lockOptions.Timeout <= TimeSpan.FromMilliseconds(10)
73+
? 1
74+
: int.MaxValue;
75+
if (lockOptions.Timeout < TimeSpan.FromDays(1) && lockOptions.Timeout > TimeSpan.FromMilliseconds(10))
76+
{
77+
builder.AddTimeout(lockOptions.Timeout);
78+
}
79+
80+
builder.AddRetry(
81+
new()
82+
{
83+
BackoffType = DelayBackoffType.Exponential,
84+
Delay = RetryInitialDelay,
85+
MaxDelay = RetryMaxDelay,
86+
MaxRetryAttempts = retryAttempts,
87+
ShouldHandle = new PredicateBuilder<ILockHandle>().Handle<LockRejectedException>(),
88+
UseJitter = true
89+
}
90+
);
91+
return builder.Build();
92+
}
93+
94+
class NoLock : ILockHandle
95+
{
96+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
97+
98+
public void Dispose()
99+
{
100+
}
101+
}
102+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System;
2+
using Calamari.Common.Plumbing.Commands;
3+
using Calamari.Common.Plumbing.Logging;
4+
5+
namespace Calamari.Common.Features.Processes.ScriptIsolation;
6+
7+
public sealed record LockOptions(
8+
LockType Type,
9+
string Name,
10+
string LockFile,
11+
TimeSpan Timeout
12+
)
13+
{
14+
public static LockOptions? FromScriptIsolationOptionsOrNull(CommonOptions.ScriptIsolationOptions options)
15+
{
16+
if (string.IsNullOrWhiteSpace(options.Level) || string.IsNullOrWhiteSpace(options.MutexName) || string.IsNullOrWhiteSpace(options.TentacleHome))
17+
{
18+
return null;
19+
}
20+
21+
var lockType = MapScriptIsolationLevelToLockTypeOrNull(options.Level);
22+
if (lockType == null)
23+
{
24+
Log.Verbose($"Failed to map script isolation level '{options.Level}' to a valid LockType. Expected 'FullIsolation' or 'NoIsolation' (case-insensitive).");
25+
return null;
26+
}
27+
28+
if (!TimeSpan.TryParse(options.Timeout, out var timeout))
29+
{
30+
Log.Verbose($"Failed to parse mutex timeout value '{options.Timeout}' as TimeSpan. Defaulting to TimeSpan.MaxValue.");
31+
// What should we do if the timeout is invalid? Default to max value?
32+
timeout = TimeSpan.MaxValue;
33+
}
34+
35+
var lockFilePath = GetLockFilePath(options.TentacleHome, options.MutexName);
36+
return new LockOptions(lockType.Value, options.MutexName, lockFilePath, timeout);
37+
}
38+
39+
static string GetLockFilePath(string tentacleHome, string mutexName) =>
40+
System.IO.Path.Combine(tentacleHome, $"ScriptIsolation.{mutexName}.lock"); // Should we sanitize the mutex name or just allow it to be invalid?
41+
42+
static LockType? MapScriptIsolationLevelToLockTypeOrNull(string isolationLevel) =>
43+
isolationLevel.ToLowerInvariant() switch
44+
{
45+
"fullisolation" => LockType.Exclusive,
46+
"noisolation" => LockType.Shared,
47+
_ => null
48+
};
49+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System;
2+
using System.Diagnostics.CodeAnalysis;
3+
using Polly.Timeout;
4+
5+
namespace Calamari.Common.Features.Processes.ScriptIsolation;
6+
7+
public sealed class LockRejectedException(string message, Exception? innerException)
8+
: Exception(message, innerException)
9+
{
10+
public LockRejectedException(Exception innerException) : this("Lock acquisition failed", innerException)
11+
{
12+
}
13+
14+
[DoesNotReturn]
15+
public static void Throw(Exception innerException)
16+
{
17+
if (innerException is LockRejectedException lockRejectedException)
18+
{
19+
throw lockRejectedException;
20+
}
21+
22+
if (innerException is TimeoutRejectedException timeoutRejectedException)
23+
{
24+
var message = $"Lock acquisition failed after {timeoutRejectedException.Timeout}";
25+
throw new LockRejectedException(message, timeoutRejectedException);
26+
}
27+
28+
throw new LockRejectedException("Lock acquisition failed", innerException);
29+
}
30+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Calamari.Common.Features.Processes.ScriptIsolation;
2+
3+
public enum LockType
4+
{
5+
Shared,
6+
Exclusive
7+
}

source/Calamari.Common/Plumbing/Commands/CommonOptions.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ internal CommonOptions(string command)
1616
public string Command { get; }
1717
public List<string> RemainingArguments { get; private set; } = new List<string>();
1818
public Variables InputVariables { get; } = new Variables();
19+
public ScriptIsolationOptions ScriptIsolation { get; } = new ScriptIsolationOptions();
1920

2021
public static CommonOptions Parse(string[] args)
2122
{
@@ -32,7 +33,10 @@ public static CommonOptions Parse(string[] args)
3233
.Add("variables=", "Path to a encrypted JSON file containing variables.", v => options.InputVariables.VariableFiles.Add(v))
3334
.Add("variablesPassword=", "Password used to decrypt variables.", v => options.InputVariables.VariablesPassword = v)
3435
.Add("outputVariables=", "Path to a encrypted JSON file containing output variables from previous executions.", v => options.InputVariables.OutputVariablesFile = v)
35-
.Add("outputVariablesPassword=", "Password used to decrypt output variables", v => options.InputVariables.OutputVariablesPassword = v);
36+
.Add("outputVariablesPassword=", "Password used to decrypt output variables", v => options.InputVariables.OutputVariablesPassword = v)
37+
.Add("scriptIsolationLevel=", "The level of isolation to run scripts at. Valid values are: NoIsolation or FullIsolation.", v => options.ScriptIsolation.Level = v)
38+
.Add("scriptIsolationMutexName=", "The name of the mutex to use when running scripts with Calamari isolation.", v => options.ScriptIsolation.MutexName = v)
39+
.Add("scriptIsolationTimeout=", "The timeout to use when running scripts with Calamari isolation. In .NET TimeSpan format.", v => options.ScriptIsolation.Timeout = v);
3640

3741
//these are legacy options to support the V2 pipeline
3842
set.Add("sensitiveVariables=", "(DEPRECATED) Path to a encrypted JSON file containing sensitive variables. This file format is deprecated.", v => options.InputVariables.DeprecatedFormatVariableFiles.Add(v))
@@ -54,5 +58,13 @@ public class Variables
5458
public List<string> DeprecatedFormatVariableFiles { get; internal set; } = new List<string>();
5559
public string? DeprecatedVariablesPassword { get; internal set; }
5660
}
61+
62+
public class ScriptIsolationOptions
63+
{
64+
public string? Level { get; internal set; }
65+
public string? MutexName { get; internal set; }
66+
public string? Timeout { get; internal set; }
67+
public string? TentacleHome { get; internal set; } = Environment.GetEnvironmentVariable("TentacleHome");
68+
}
5769
}
5870
}

0 commit comments

Comments
 (0)