From a95a4d11954fb28b80f585cd399e570a1a6f80ea Mon Sep 17 00:00:00 2001 From: Mikhail Shilkov Date: Wed, 11 Mar 2020 23:10:01 +0100 Subject: [PATCH] Unit testing in .NET (#3696) Mock-based testing in .NET --- .gitignore | 1 + CHANGELOG.md | 3 + sdk/dotnet/Pulumi.Tests/PulumiTest.cs | 1 + sdk/dotnet/Pulumi.Tests/StackTests.cs | 10 +- .../Pulumi/Deployment/Deployment.Logger.cs | 4 +- sdk/dotnet/Pulumi/Deployment/Deployment.cs | 37 ++++--- .../Deployment/Deployment_ReadResource.cs | 2 +- .../Deployment/Deployment_RegisterResource.cs | 2 +- .../Deployment_RegisterResourceOutputs.cs | 2 +- .../Pulumi/Deployment/Deployment_Run.cs | 61 +++++++++-- sdk/dotnet/Pulumi/Deployment/GrpcEngine.cs | 27 +++++ sdk/dotnet/Pulumi/Deployment/GrpcMonitor.cs | 30 +++++ .../Pulumi/Deployment/IDeploymentInternal.cs | 1 - sdk/dotnet/Pulumi/Deployment/IEngine.cs | 16 +++ sdk/dotnet/Pulumi/Deployment/IMonitor.cs | 18 +++ sdk/dotnet/Pulumi/PublicAPI.Unshipped.txt | 12 ++ .../Serialization/OutputCompletionSource.cs | 2 +- sdk/dotnet/Pulumi/Testing/IMocks.cs | 36 ++++++ sdk/dotnet/Pulumi/Testing/MockEngine.cs | 56 ++++++++++ sdk/dotnet/Pulumi/Testing/MockMonitor.cs | 103 ++++++++++++++++++ sdk/dotnet/Pulumi/Testing/TestOptions.cs | 23 ++++ 21 files changed, 414 insertions(+), 33 deletions(-) create mode 100644 sdk/dotnet/Pulumi/Deployment/GrpcEngine.cs create mode 100644 sdk/dotnet/Pulumi/Deployment/GrpcMonitor.cs create mode 100644 sdk/dotnet/Pulumi/Deployment/IEngine.cs create mode 100644 sdk/dotnet/Pulumi/Deployment/IMonitor.cs create mode 100644 sdk/dotnet/Pulumi/Testing/IMocks.cs create mode 100644 sdk/dotnet/Pulumi/Testing/MockEngine.cs create mode 100644 sdk/dotnet/Pulumi/Testing/MockMonitor.cs create mode 100644 sdk/dotnet/Pulumi/Testing/TestOptions.cs diff --git a/.gitignore b/.gitignore index 27d9145d9..201dbf8e8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ **/.vscode/ **/.vs/ **/.ionide/ +**/.idea/ coverage.cov *.coverprofile diff --git a/CHANGELOG.md b/CHANGELOG.md index 345e7db57..319fba041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,15 @@ CHANGELOG - Ensure Python overlays work as part of our SDK generation [#4043](https://github.com/pulumi/pulumi/pull/4043) + - Fix terminal gets into a state where UP/DOWN don't work with prompts. [#4042](https://github.com/pulumi/pulumi/pull/4042) - Ensure old provider is not used when configuration has changed [#4051](https://github.com/pulumi/pulumi/pull/4051) +- Support for unit testing and mocking in the .NET SDK. + [#3696](https://github.com/pulumi/pulumi/pull/3696) ## 1.12.0 (2020-03-04) - Avoid Configuring providers which are not used during preview. diff --git a/sdk/dotnet/Pulumi.Tests/PulumiTest.cs b/sdk/dotnet/Pulumi.Tests/PulumiTest.cs index a57cd5206..d09da5f61 100644 --- a/sdk/dotnet/Pulumi.Tests/PulumiTest.cs +++ b/sdk/dotnet/Pulumi.Tests/PulumiTest.cs @@ -22,6 +22,7 @@ namespace Pulumi.Tests Deployment.Instance = mock.Object; await func().ConfigureAwait(false); + Deployment.Instance = null!; } protected static Task RunInPreview(Action action) diff --git a/sdk/dotnet/Pulumi.Tests/StackTests.cs b/sdk/dotnet/Pulumi.Tests/StackTests.cs index a7082a3f9..634d382ac 100644 --- a/sdk/dotnet/Pulumi.Tests/StackTests.cs +++ b/sdk/dotnet/Pulumi.Tests/StackTests.cs @@ -15,10 +15,10 @@ namespace Pulumi.Tests private class ValidStack : Stack { [Output("foo")] - public Output ExplicitName { get; } + public Output ExplicitName { get; set; } [Output] - public Output ImplicitName { get; } + public Output ImplicitName { get; set; } public ValidStack() { @@ -51,7 +51,7 @@ namespace Pulumi.Tests } catch (RunException ex) { - Assert.Contains("foo", ex.Message); + Assert.Contains("Output(s) 'foo' have no value assigned", ex.ToString()); return; } @@ -61,7 +61,7 @@ namespace Pulumi.Tests private class InvalidOutputTypeStack : Stack { [Output("foo")] - public string Foo { get; } + public string Foo { get; set; } public InvalidOutputTypeStack() { @@ -78,7 +78,7 @@ namespace Pulumi.Tests } catch (RunException ex) { - Assert.Contains("foo", ex.Message); + Assert.Contains("Output(s) 'foo' have incorrect type", ex.ToString()); return; } diff --git a/sdk/dotnet/Pulumi/Deployment/Deployment.Logger.cs b/sdk/dotnet/Pulumi/Deployment/Deployment.Logger.cs index 2c8671727..1acc61f9a 100644 --- a/sdk/dotnet/Pulumi/Deployment/Deployment.Logger.cs +++ b/sdk/dotnet/Pulumi/Deployment/Deployment.Logger.cs @@ -13,14 +13,14 @@ namespace Pulumi { private readonly object _logGate = new object(); private readonly IDeploymentInternal _deployment; - private readonly Engine.EngineClient _engine; + private readonly IEngine _engine; // We serialize all logging tasks so that the engine doesn't hear about them out of order. // This is necessary for streaming logs to be maintained in the right order. private Task _lastLogTask = Task.CompletedTask; private int _errorCount; - public Logger(IDeploymentInternal deployment, Engine.EngineClient engine) + public Logger(IDeploymentInternal deployment, IEngine engine) { _deployment = deployment; _engine = engine; diff --git a/sdk/dotnet/Pulumi/Deployment/Deployment.cs b/sdk/dotnet/Pulumi/Deployment/Deployment.cs index 75a3e6d41..8580e68f2 100644 --- a/sdk/dotnet/Pulumi/Deployment/Deployment.cs +++ b/sdk/dotnet/Pulumi/Deployment/Deployment.cs @@ -1,10 +1,9 @@ // Copyright 2016-2019, Pulumi Corporation using System; +using System.Collections.Generic; using System.Threading.Tasks; -using Grpc.Core; -using Microsoft.Win32.SafeHandles; -using Pulumirpc; +using Pulumi.Testing; namespace Pulumi { @@ -34,6 +33,7 @@ namespace Pulumi public sealed partial class Deployment : IDeploymentInternal { private static IDeployment? _instance; + private static readonly object _instanceLock = new object(); /// /// The current running deployment instance. This is only available from inside the function @@ -42,13 +42,12 @@ namespace Pulumi public static IDeployment Instance { get => _instance ?? throw new InvalidOperationException("Trying to acquire Deployment.Instance before 'Run' was called."); - internal set => _instance = (value ?? throw new ArgumentNullException(nameof(value))); + internal set => _instance = value; } internal static IDeploymentInternal InternalInstance => (IDeploymentInternal)Instance; - private readonly Options _options; private readonly string _projectName; private readonly string _stackName; private readonly bool _isDryRun; @@ -56,8 +55,8 @@ namespace Pulumi private readonly ILogger _logger; private readonly IRunner _runner; - internal Engine.EngineClient Engine { get; } - internal ResourceMonitor.ResourceMonitorClient Monitor { get; } + internal IEngine Engine { get; } + internal IMonitor Monitor { get; } internal Stack? _stack; internal Stack Stack @@ -93,27 +92,37 @@ namespace Pulumi _stackName = stack; _projectName = project; - _options = new Options( - queryMode: queryModeValue, parallel: parallelValue, pwd: pwd, - monitor: monitor, engine: engine, tracing: tracing); - Serilog.Log.Debug("Creating Deployment Engine."); - this.Engine = new Engine.EngineClient(new Channel(engine, ChannelCredentials.Insecure)); + this.Engine = new GrpcEngine(engine); Serilog.Log.Debug("Created Deployment Engine."); Serilog.Log.Debug("Creating Deployment Monitor."); - this.Monitor = new ResourceMonitor.ResourceMonitorClient(new Channel(monitor, ChannelCredentials.Insecure)); + this.Monitor = new GrpcMonitor(monitor); Serilog.Log.Debug("Created Deployment Monitor."); _runner = new Runner(this); _logger = new Logger(this, this.Engine); } + /// + /// This constructor is called from with + /// a mocked monitor and dummy values for project and stack. + /// + private Deployment(IEngine engine, IMonitor monitor, TestOptions? options) + { + _isDryRun = options?.IsPreview ?? true; + _stackName = options?.StackName ?? "stack"; + _projectName = options?.ProjectName ?? "project"; + this.Engine = engine; + this.Monitor = monitor; + _runner = new Runner(this); + _logger = new Logger(this, this.Engine); + } + string IDeployment.ProjectName => _projectName; string IDeployment.StackName => _stackName; bool IDeployment.IsDryRun => _isDryRun; - Options IDeploymentInternal.Options => _options; ILogger IDeploymentInternal.Logger => _logger; IRunner IDeploymentInternal.Runner => _runner; diff --git a/sdk/dotnet/Pulumi/Deployment/Deployment_ReadResource.cs b/sdk/dotnet/Pulumi/Deployment/Deployment_ReadResource.cs index 999e80282..716064810 100644 --- a/sdk/dotnet/Pulumi/Deployment/Deployment_ReadResource.cs +++ b/sdk/dotnet/Pulumi/Deployment/Deployment_ReadResource.cs @@ -41,7 +41,7 @@ namespace Pulumi request.Dependencies.AddRange(prepareResult.AllDirectDependencyURNs); // Now run the operation, serializing the invocation if necessary. - var response = await this.Monitor.ReadResourceAsync(request); + var response = await this.Monitor.ReadResourceAsync(resource, request); return (response.Urn, id, response.Properties); } diff --git a/sdk/dotnet/Pulumi/Deployment/Deployment_RegisterResource.cs b/sdk/dotnet/Pulumi/Deployment/Deployment_RegisterResource.cs index 843dba313..a404b670b 100644 --- a/sdk/dotnet/Pulumi/Deployment/Deployment_RegisterResource.cs +++ b/sdk/dotnet/Pulumi/Deployment/Deployment_RegisterResource.cs @@ -28,7 +28,7 @@ namespace Pulumi PopulateRequest(request, prepareResult); Log.Debug($"Registering resource monitor start: t={type}, name={name}, custom={custom}"); - var result = await this.Monitor.RegisterResourceAsync(request); + var result = await this.Monitor.RegisterResourceAsync(resource, request); Log.Debug($"Registering resource monitor end: t={type}, name={name}, custom={custom}"); return (result.Urn, result.Id, result.Object); } diff --git a/sdk/dotnet/Pulumi/Deployment/Deployment_RegisterResourceOutputs.cs b/sdk/dotnet/Pulumi/Deployment/Deployment_RegisterResourceOutputs.cs index 7fe69a9ad..49a40d603 100644 --- a/sdk/dotnet/Pulumi/Deployment/Deployment_RegisterResourceOutputs.cs +++ b/sdk/dotnet/Pulumi/Deployment/Deployment_RegisterResourceOutputs.cs @@ -33,7 +33,7 @@ namespace Pulumi Log.Debug($"RegisterResourceOutputs RPC prepared: urn={urn}" + (_excessiveDebugOutput ? $", outputs ={JsonFormatter.Default.Format(serialized)}" : "")); - await Monitor.RegisterResourceOutputsAsync(new RegisterResourceOutputsRequest() + await Monitor.RegisterResourceOutputsAsync(new RegisterResourceOutputsRequest { Urn = urn, Outputs = serialized, diff --git a/sdk/dotnet/Pulumi/Deployment/Deployment_Run.cs b/sdk/dotnet/Pulumi/Deployment/Deployment_Run.cs index c4e7600d8..915cc9488 100644 --- a/sdk/dotnet/Pulumi/Deployment/Deployment_Run.cs +++ b/sdk/dotnet/Pulumi/Deployment/Deployment_Run.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using System.Threading.Tasks; +using Pulumi.Testing; namespace Pulumi { @@ -87,20 +89,65 @@ namespace Pulumi public static Task RunAsync() where TStack : Stack, new() => CreateRunner().RunAsync(); + /// + /// Entry point to test a Pulumi application. Deployment will + /// instantiate a new stack instance based on the type passed as TStack + /// type parameter. This method creates no real resources. + /// Note: Currently, unit tests that call + /// must run serially; parallel execution is not supported. + /// + /// Hooks to mock the engine calls. + /// Optional settings for the test run. + /// The type of the stack to test. + /// Test result containing created resources and errors, if any. + public static async Task> TestAsync(IMocks mocks, TestOptions? options = null) where TStack : Stack, new() + { + var engine = new MockEngine(); + var monitor = new MockMonitor(mocks); + Deployment deployment; + lock (_instanceLock) + { + if (_instance != null) + throw new NotSupportedException($"Mulitple executions of {nameof(TestAsync)} must run serially. Please configure your unit test suite to run tests one-by-one."); + + deployment = new Deployment(engine, monitor, options); + Instance = deployment; + } + + try + { + await deployment._runner.RunAsync(); + return engine.Errors.Count switch + { + 1 => throw new RunException(engine.Errors.Single()), + int v when v > 1 => throw new AggregateException(engine.Errors.Select(e => new RunException(e))), + _ => monitor.Resources.ToImmutableArray() + }; + } + finally + { + lock (_instanceLock) + { + _instance = null; + } + } + } + private static IRunner CreateRunner() { // Serilog.Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger(); Serilog.Log.Debug("Deployment.Run called."); - if (_instance != null) + lock (_instanceLock) { - throw new NotSupportedException("Deployment.Run can only be called a single time."); - } + if (_instance != null) + throw new NotSupportedException("Deployment.Run can only be called a single time."); - Serilog.Log.Debug("Creating new Deployment."); - var deployment = new Deployment(); - Instance = deployment; - return deployment._runner; + Serilog.Log.Debug("Creating new Deployment."); + var deployment = new Deployment(); + Instance = deployment; + return deployment._runner; + } } } } diff --git a/sdk/dotnet/Pulumi/Deployment/GrpcEngine.cs b/sdk/dotnet/Pulumi/Deployment/GrpcEngine.cs new file mode 100644 index 000000000..476b94154 --- /dev/null +++ b/sdk/dotnet/Pulumi/Deployment/GrpcEngine.cs @@ -0,0 +1,27 @@ +// Copyright 2016-2020, Pulumi Corporation + +using System.Threading.Tasks; +using Grpc.Core; +using Pulumirpc; + +namespace Pulumi +{ + internal class GrpcEngine : IEngine + { + private readonly Engine.EngineClient _engine; + + public GrpcEngine(string engine) + { + this._engine = new Engine.EngineClient(new Channel(engine, ChannelCredentials.Insecure)); + } + + public async Task LogAsync(LogRequest request) + => await this._engine.LogAsync(request); + + public async Task SetRootResourceAsync(SetRootResourceRequest request) + => await this._engine.SetRootResourceAsync(request); + + public async Task GetRootResourceAsync(GetRootResourceRequest request) + => await this._engine.GetRootResourceAsync(request); + } +} diff --git a/sdk/dotnet/Pulumi/Deployment/GrpcMonitor.cs b/sdk/dotnet/Pulumi/Deployment/GrpcMonitor.cs new file mode 100644 index 000000000..d641f8a99 --- /dev/null +++ b/sdk/dotnet/Pulumi/Deployment/GrpcMonitor.cs @@ -0,0 +1,30 @@ +// Copyright 2016-2020, Pulumi Corporation + +using System.Threading.Tasks; +using Grpc.Core; +using Pulumirpc; + +namespace Pulumi +{ + internal class GrpcMonitor : IMonitor + { + private readonly ResourceMonitor.ResourceMonitorClient _client; + + public GrpcMonitor(string monitor) + { + this._client = new ResourceMonitor.ResourceMonitorClient(new Channel(monitor, ChannelCredentials.Insecure)); + } + + public async Task InvokeAsync(InvokeRequest request) + => await this._client.InvokeAsync(request); + + public async Task ReadResourceAsync(Resource resource, ReadResourceRequest request) + => await this._client.ReadResourceAsync(request); + + public async Task RegisterResourceAsync(Resource resource, RegisterResourceRequest request) + => await this._client.RegisterResourceAsync(request); + + public async Task RegisterResourceOutputsAsync(RegisterResourceOutputsRequest request) + => await this._client.RegisterResourceOutputsAsync(request); + } +} diff --git a/sdk/dotnet/Pulumi/Deployment/IDeploymentInternal.cs b/sdk/dotnet/Pulumi/Deployment/IDeploymentInternal.cs index 8c790677f..762cdfc6f 100644 --- a/sdk/dotnet/Pulumi/Deployment/IDeploymentInternal.cs +++ b/sdk/dotnet/Pulumi/Deployment/IDeploymentInternal.cs @@ -8,7 +8,6 @@ namespace Pulumi { internal interface IDeploymentInternal : IDeployment { - Options Options { get; } string? GetConfig(string fullKey); Stack Stack { get; set; } diff --git a/sdk/dotnet/Pulumi/Deployment/IEngine.cs b/sdk/dotnet/Pulumi/Deployment/IEngine.cs new file mode 100644 index 000000000..6d470684f --- /dev/null +++ b/sdk/dotnet/Pulumi/Deployment/IEngine.cs @@ -0,0 +1,16 @@ +// Copyright 2016-2020, Pulumi Corporation + +using System.Threading.Tasks; +using Pulumirpc; + +namespace Pulumi +{ + internal interface IEngine + { + Task LogAsync(LogRequest request); + + Task SetRootResourceAsync(SetRootResourceRequest request); + + Task GetRootResourceAsync(GetRootResourceRequest request); + } +} diff --git a/sdk/dotnet/Pulumi/Deployment/IMonitor.cs b/sdk/dotnet/Pulumi/Deployment/IMonitor.cs new file mode 100644 index 000000000..16f072bdf --- /dev/null +++ b/sdk/dotnet/Pulumi/Deployment/IMonitor.cs @@ -0,0 +1,18 @@ +// Copyright 2016-2020, Pulumi Corporation + +using System.Threading.Tasks; +using Pulumirpc; + +namespace Pulumi +{ + internal interface IMonitor + { + Task InvokeAsync(InvokeRequest request); + + Task ReadResourceAsync(Resource resource, ReadResourceRequest request); + + Task RegisterResourceAsync(Resource resource, RegisterResourceRequest request); + + Task RegisterResourceOutputsAsync(RegisterResourceOutputsRequest request); + } +} diff --git a/sdk/dotnet/Pulumi/PublicAPI.Unshipped.txt b/sdk/dotnet/Pulumi/PublicAPI.Unshipped.txt index 4780ee4be..c24ec0037 100644 --- a/sdk/dotnet/Pulumi/PublicAPI.Unshipped.txt +++ b/sdk/dotnet/Pulumi/PublicAPI.Unshipped.txt @@ -198,6 +198,17 @@ Pulumi.StackReferenceArgs.Name.set -> void Pulumi.StackReferenceArgs.StackReferenceArgs() -> void Pulumi.StringAsset Pulumi.StringAsset.StringAsset(string text) -> void +Pulumi.Testing.IMocks +Pulumi.Testing.IMocks.CallAsync(string token, System.Collections.Immutable.ImmutableDictionary args, string provider) -> System.Threading.Tasks.Task +Pulumi.Testing.IMocks.NewResourceAsync(string type, string name, System.Collections.Immutable.ImmutableDictionary inputs, string provider, string id) -> System.Threading.Tasks.Task<(string id, object state)> +Pulumi.Testing.TestOptions +Pulumi.Testing.TestOptions.TestOptions() -> void +Pulumi.Testing.TestOptions.ProjectName.get -> string +Pulumi.Testing.TestOptions.ProjectName.set -> void +Pulumi.Testing.TestOptions.StackName.get -> string +Pulumi.Testing.TestOptions.StackName.set -> void +Pulumi.Testing.TestOptions.IsPreview.get -> bool? +Pulumi.Testing.TestOptions.IsPreview.set -> void Pulumi.Union Pulumi.Union.AsT0.get -> T0 Pulumi.Union.AsT1.get -> T1 @@ -223,6 +234,7 @@ static Pulumi.Deployment.RunAsync(System.Action action) -> System.Threading.Task static Pulumi.Deployment.RunAsync(System.Func> func) -> System.Threading.Tasks.Task static Pulumi.Deployment.RunAsync(System.Func>> func) -> System.Threading.Tasks.Task static Pulumi.Deployment.RunAsync() -> System.Threading.Tasks.Task +static Pulumi.Deployment.TestAsync(Pulumi.Testing.IMocks mocks, Pulumi.Testing.TestOptions options = null) -> System.Threading.Tasks.Task> static Pulumi.Input.implicit operator Pulumi.Input(Pulumi.Output value) -> Pulumi.Input static Pulumi.Input.implicit operator Pulumi.Input(T value) -> Pulumi.Input static Pulumi.Input.implicit operator Pulumi.Output(Pulumi.Input input) -> Pulumi.Output diff --git a/sdk/dotnet/Pulumi/Serialization/OutputCompletionSource.cs b/sdk/dotnet/Pulumi/Serialization/OutputCompletionSource.cs index 064acf261..731e080eb 100644 --- a/sdk/dotnet/Pulumi/Serialization/OutputCompletionSource.cs +++ b/sdk/dotnet/Pulumi/Serialization/OutputCompletionSource.cs @@ -75,7 +75,7 @@ namespace Pulumi.Serialization { var propType = prop.PropertyType; var propFullName = $"[Output] {resource.GetType().FullName}.{prop.Name}"; - if (!propType.IsConstructedGenericType && + if (!propType.IsConstructedGenericType || propType.GetGenericTypeDefinition() != typeof(Output<>)) { throw new InvalidOperationException($"{propFullName} was not an Output"); diff --git a/sdk/dotnet/Pulumi/Testing/IMocks.cs b/sdk/dotnet/Pulumi/Testing/IMocks.cs new file mode 100644 index 000000000..095eb95be --- /dev/null +++ b/sdk/dotnet/Pulumi/Testing/IMocks.cs @@ -0,0 +1,36 @@ +// Copyright 2016-2020, Pulumi Corporation + +using System.Collections.Immutable; +using System.Threading.Tasks; + +namespace Pulumi.Testing +{ + /// + /// Hooks to mock the engine that provide test doubles for offline unit testing of stacks. + /// + public interface IMocks + { + /// + /// Invoked when a new resource is created by the program. + /// + /// Resource type name. + /// Resource name. + /// Dictionary of resource input properties. + /// Provider. + /// Resource identifier. + /// A tuple of a resource identifier and resource state. State can be either a POCO + /// or a dictionary bag. + Task<(string id, object state)> NewResourceAsync(string type, string name, + ImmutableDictionary inputs, string? provider, string? id); + + /// + /// Invoked when the program needs to call a provider to load data (e.g., to retrieve an existing + /// resource). + /// + /// Function token. + /// Dictionary of input arguments. + /// Provider. + /// Invocation result, can be either a POCO or a dictionary bag. + Task CallAsync(string token, ImmutableDictionary args, string? provider); + } +} diff --git a/sdk/dotnet/Pulumi/Testing/MockEngine.cs b/sdk/dotnet/Pulumi/Testing/MockEngine.cs new file mode 100644 index 000000000..d3fe773e3 --- /dev/null +++ b/sdk/dotnet/Pulumi/Testing/MockEngine.cs @@ -0,0 +1,56 @@ +// Copyright 2016-2020, Pulumi Corporation + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Pulumi.Serialization; +using Pulumirpc; + +namespace Pulumi.Testing +{ + internal class MockEngine : IEngine + { + private string? _rootResourceUrn; + private readonly object _rootResourceUrnLock = new object(); + + public readonly List Errors = new List(); + + public Task LogAsync(LogRequest request) + { + if (request.Severity == LogSeverity.Error) + { + lock (this.Errors) + { + this.Errors.Add(request.Message); + } + } + + return Task.CompletedTask; + } + + public Task SetRootResourceAsync(SetRootResourceRequest request) + { + lock (_rootResourceUrnLock) + { + if (_rootResourceUrn != null && _rootResourceUrn != request.Urn) + throw new InvalidOperationException( + $"An invalid attempt to set the root resource to {request.Urn} while it's already set to {_rootResourceUrn}"); + + _rootResourceUrn = request.Urn; + } + + return Task.FromResult(new SetRootResourceResponse()); + } + + public Task GetRootResourceAsync(GetRootResourceRequest request) + { + lock (_rootResourceUrnLock) + { + if (_rootResourceUrn == null) + throw new InvalidOperationException("Root resource is not set"); + + return Task.FromResult(new GetRootResourceResponse {Urn = _rootResourceUrn}); + } + } + } +} diff --git a/sdk/dotnet/Pulumi/Testing/MockMonitor.cs b/sdk/dotnet/Pulumi/Testing/MockMonitor.cs new file mode 100644 index 000000000..6dc582fb4 --- /dev/null +++ b/sdk/dotnet/Pulumi/Testing/MockMonitor.cs @@ -0,0 +1,103 @@ +// Copyright 2016-2020, Pulumi Corporation + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Google.Protobuf.WellKnownTypes; +using Pulumi.Serialization; +using Pulumirpc; + +namespace Pulumi.Testing +{ + internal class MockMonitor : IMonitor + { + private readonly IMocks _mocks; + private readonly Serializer _serializer = new Serializer(); + + public readonly List Resources = new List(); + + public MockMonitor(IMocks mocks) + { + _mocks = mocks; + } + + public async Task InvokeAsync(InvokeRequest request) + { + var result = await _mocks.CallAsync(request.Tok, ToDictionary(request.Args), request.Provider) + .ConfigureAwait(false); + return new InvokeResponse {Return = await SerializeAsync(result).ConfigureAwait(false)}; + } + + public async Task ReadResourceAsync(Resource resource, ReadResourceRequest request) + { + var (id, state) = await _mocks.NewResourceAsync(request.Type, request.Name, + ToDictionary(request.Properties), request.Provider, request.Id).ConfigureAwait(false); + + lock (this.Resources) + { + this.Resources.Add(resource); + } + + return new ReadResourceResponse + { + Urn = NewUrn(request.Parent, request.Type, request.Name), + Properties = await SerializeAsync(state).ConfigureAwait(false) + }; + } + + public async Task RegisterResourceAsync(Resource resource, RegisterResourceRequest request) + { + var (id, state) = await _mocks.NewResourceAsync(request.Type, request.Name, ToDictionary(request.Object), + request.Provider, request.ImportId).ConfigureAwait(false); + + lock (this.Resources) + { + this.Resources.Add(resource); + } + + return new RegisterResourceResponse + { + Id = id ?? request.ImportId, + Urn = NewUrn(request.Parent, request.Type, request.Name), + Object = await SerializeAsync(state).ConfigureAwait(false) + }; + } + + public Task RegisterResourceOutputsAsync(RegisterResourceOutputsRequest request) => Task.CompletedTask; + + private static string NewUrn(string parent, string type, string name) + { + if (!string.IsNullOrEmpty(parent)) + { + var qualifiedType = parent.Split("::")[2]; + var parentType = qualifiedType.Split("$").First(); + type = parentType + "$" + type; + } + return "urn:pulumi:" + string.Join("::", new[] { Deployment.Instance.StackName, Deployment.Instance.ProjectName, type, name }); + } + + private static ImmutableDictionary ToDictionary(Struct s) + { + var builder = ImmutableDictionary.CreateBuilder(); + foreach (var (key, value) in s.Fields) + { + var data = Deserializer.Deserialize(value); + if (data.IsKnown && data.Value != null) + { + builder.Add(key, data.Value); + } + } + return builder.ToImmutable(); + } + + private async Task SerializeAsync(object o) + { + var dict = (o as IDictionary)?.ToImmutableDictionary() + ?? await _serializer.SerializeAsync("", o).ConfigureAwait(false) as ImmutableDictionary + ?? throw new InvalidOperationException($"{o.GetType().FullName} is not a supported argument type"); + return Serializer.CreateStruct(dict); + } + } +} diff --git a/sdk/dotnet/Pulumi/Testing/TestOptions.cs b/sdk/dotnet/Pulumi/Testing/TestOptions.cs new file mode 100644 index 000000000..a1ecaf04c --- /dev/null +++ b/sdk/dotnet/Pulumi/Testing/TestOptions.cs @@ -0,0 +1,23 @@ +namespace Pulumi.Testing +{ + /// + /// Optional settings for . + /// + public class TestOptions + { + /// + /// Project name. Defaults to "project" if not specified. + /// + public string? ProjectName { get; set; } + + /// + /// Stack name. Defaults to "stack" if not specified. + /// + public string? StackName { get; set; } + + /// + /// Whether the test runs in Preview mode. Defaults to true if not specified. + /// + public bool? IsPreview { get; set; } + } +}