diff --git a/integration_tests/transformations_remote/.gitignore b/integration_tests/transformations_remote/.gitignore new file mode 100644 index 00000000..e9519ab9 --- /dev/null +++ b/integration_tests/transformations_remote/.gitignore @@ -0,0 +1,3 @@ +/.pulumi/ +[Bb]in/ +[Oo]bj/ diff --git a/integration_tests/transformations_remote/Program.cs b/integration_tests/transformations_remote/Program.cs new file mode 100644 index 00000000..371d0f7c --- /dev/null +++ b/integration_tests/transformations_remote/Program.cs @@ -0,0 +1,129 @@ +// Copyright 2016-2024, Pulumi Corporation. All rights reserved. + +using System; +using System.Threading.Tasks; +using Pulumi; +using Pulumi.Random; + +class MyComponent : ComponentResource +{ + public RandomString Child { get; } + + public MyComponent(string name, ComponentResourceOptions? options = null) + : base("my:component:MyComponent", name, options) + { + this.Child = new RandomString($"{name}-child", + new RandomStringArgs { Length = 5 }, + new CustomResourceOptions {Parent = this, AdditionalSecretOutputs = {"special"} }); + } +} + +// Scenario #5 - cross-resource transformations that inject the output of one resource to the input +// of the other one. +class MyOtherComponent : ComponentResource +{ + public RandomString Child1 { get; } + public RandomString Child2 { get; } + + public MyOtherComponent(string name, ComponentResourceOptions? options = null) + : base("my:component:MyComponent", name, options) + { + this.Child1 = new RandomString($"{name}-child1", + new RandomStringArgs { Length = 5 }, + new CustomResourceOptions { Parent = this }); + + this.Child2 = new RandomString($"{name}-child2", + new RandomStringArgs { Length = 6 }, + new CustomResourceOptions { Parent = this }); + } +} + +class TransformationsStack : Stack +{ + public TransformationsStack() : base(new StackOptions { ResourceTransforms = {Scenario3} }) + { + // Scenario #1 - apply a transformation to a CustomResource + var res1 = new RandomString("res1", new RandomStringArgs { Length = 5 }, new CustomResourceOptions + { + ResourceTransforms = + { + args => + { + var options = CustomResourceOptions.Merge( + (CustomResourceOptions)args.Options, + new CustomResourceOptions {AdditionalSecretOutputs = {"length"}}); + return new ResourceTransformResult(args.Args, options); + } + } + }); + + // Scenario #2 - apply a transformation to a Component to transform its children + var res2 = new MyComponent("res2", new ComponentResourceOptions + { + ResourceTransforms = + { + args => + { + if (args.Resource.GetResourceType() == RandomStringType && args.Args is RandomStringArgs oldArgs) + { + var resultArgs = new RandomStringArgs {Length = oldArgs.Length, MinUpper = 2}; + var resultOpts = CustomResourceOptions.Merge((CustomResourceOptions)args.Options, + new CustomResourceOptions {AdditionalSecretOutputs = {"length"}}); + return new ResourceTransformResult(resultArgs, resultOpts); + } + + return null; + } + } + }); + + // Scenario #3 - apply a transformation to the Stack to transform all resources in the stack. + var res3 = new RandomString("res3", new RandomStringArgs { Length = 5 }); + + // Scenario #4 - transformations are applied in order of decreasing specificity + // 1. (not in this example) Child transformation + // 2. First parent transformation + // 3. Second parent transformation + // 4. Stack transformation + var res4 = new MyComponent("res4", new ComponentResourceOptions + { + ResourceTransforms = { args => scenario4(args, "value1"), args => scenario4(args, "value2") } + }); + + ResourceTransformResult? scenario4(ResourceTransformArgs args, string v) + { + if (args.Resource.GetResourceType() == RandomStringType && args.Args is RandomStringArgs oldArgs) + { + var resultArgs = new RandomStringArgs + {Length = oldArgs.Length, OverrideSpecial = Output.Format($"{oldArgs.OverrideSpecial}{v}")}; + return new ResourceTransformResult(resultArgs, args.Options); + } + + return null; + } + } + + // Scenario #3 - apply a transformation to the Stack to transform all (future) resources in the stack + private static ResourceTransformResult? Scenario3(ResourceTransformArgs args) + { + if (args.Resource.GetResourceType() == RandomStringType && args.Args is RandomStringArgs oldArgs) + { + var resultArgs = new RandomStringArgs + { + Length = oldArgs.Length, + MinUpper = oldArgs.MinUpper, + OverrideSpecial = Output.Format($"{oldArgs.OverrideSpecial}stackvalue") + }; + return new ResourceTransformResult(resultArgs, args.Options); + } + + return null; + } + + private const string RandomStringType = "random:index/randomString:RandomString"; +} + +class Program +{ + static Task Main(string[] args) => Deployment.RunAsync(); +} diff --git a/integration_tests/transformations_remote/Pulumi.yaml b/integration_tests/transformations_remote/Pulumi.yaml new file mode 100644 index 00000000..49b78794 --- /dev/null +++ b/integration_tests/transformations_remote/Pulumi.yaml @@ -0,0 +1,3 @@ +name: transformations_dotnet +description: A simple .NET program that uses transformations. +runtime: dotnet diff --git a/integration_tests/transformations_remote/Transformations.csproj b/integration_tests/transformations_remote/Transformations.csproj new file mode 100644 index 00000000..9b4c4ef0 --- /dev/null +++ b/integration_tests/transformations_remote/Transformations.csproj @@ -0,0 +1,13 @@ + + + + Exe + net6.0 + enable + + + + + + + diff --git a/integration_tests/transformations_simple_test.go b/integration_tests/transformations_simple_test.go index c70e1d2e..24b8012c 100644 --- a/integration_tests/transformations_simple_test.go +++ b/integration_tests/transformations_simple_test.go @@ -85,3 +85,84 @@ func dotNetValidator() func(t *testing.T, stack integration.RuntimeValidationSta assert.True(t, foundRes5Child) } } + +func TestDotNetTransforms(t *testing.T) { + testDotnetProgram(t, &integration.ProgramTestOptions{ + Dir: "transformations_remote", + Quick: true, + ExtraRuntimeValidation: Validator, + LocalProviders: []integration.LocalDependency{ + { + Package: "testprovider", + Path: "testprovider", + }, + }, + }) +} + +func Validator(t *testing.T, stack integration.RuntimeValidationStackInfo) { + randomResName := "testprovider:index:Random" + foundRes1 := false + foundRes2Child := false + foundRes3 := false + foundRes4Child := false + foundRes5 := false + for _, res := range stack.Deployment.Resources { + // "res1" has a transformation which adds additionalSecretOutputs + if res.URN.Name() == "res1" { + foundRes1 = true + assert.Equal(t, res.Type, tokens.Type(randomResName)) + assert.Contains(t, res.AdditionalSecretOutputs, resource.PropertyKey("result")) + } + // "res2" has a transformation which adds additionalSecretOutputs to it's + // "child" + if res.URN.Name() == "res2-child" { + foundRes2Child = true + assert.Equal(t, res.Type, tokens.Type(randomResName)) + assert.Equal(t, res.Parent.Type(), tokens.Type("my:component:MyComponent")) + assert.Contains(t, res.AdditionalSecretOutputs, resource.PropertyKey("result")) + assert.Contains(t, res.AdditionalSecretOutputs, resource.PropertyKey("length")) + } + // "res3" is impacted by a global stack transformation which sets + // optionalDefault to "stackDefault" + if res.URN.Name() == "res3" { + foundRes3 = true + assert.Equal(t, res.Type, tokens.Type(randomResName)) + optionalPrefix := res.Inputs["prefix"] + assert.NotNil(t, optionalPrefix) + assert.Equal(t, "stackDefault", optionalPrefix.(string)) + length := res.Inputs["length"] + assert.NotNil(t, length) + // length should be secret + secret, ok := length.(map[string]interface{}) + assert.True(t, ok, "length should be a secret") + assert.Equal(t, resource.SecretSig, secret[resource.SigKey]) + assert.Contains(t, res.AdditionalSecretOutputs, resource.PropertyKey("result")) + } + // "res4" is impacted by two component parent transformations which set + // optionalDefault to "default1" and then "default2" and also a global stack + // transformation which sets optionalDefault to "stackDefault". The end + // result should be "stackDefault". + if res.URN.Name() == "res4-child" { + foundRes4Child = true + assert.Equal(t, res.Type, tokens.Type(randomResName)) + assert.Equal(t, res.Parent.Type(), tokens.Type("my:component:MyComponent")) + optionalPrefix := res.Inputs["prefix"] + assert.NotNil(t, optionalPrefix) + assert.Equal(t, "stackDefault", optionalPrefix.(string)) + } + // "res5" should have mutated the length + if res.URN.Name() == "res5" { + foundRes5 = true + assert.Equal(t, res.Type, tokens.Type(randomResName)) + length := res.Inputs["length"] + assert.NotNil(t, length) + assert.Equal(t, 20.0, length.(float64)) + } + } + assert.True(t, foundRes1) + assert.True(t, foundRes2Child) + assert.True(t, foundRes3) + assert.True(t, foundRes4Child) + assert.True(t, foundRes5) +} diff --git a/sdk/Pulumi/Deployment/Callbacks.cs b/sdk/Pulumi/Deployment/Callbacks.cs new file mode 100644 index 00000000..5d831d3d --- /dev/null +++ b/sdk/Pulumi/Deployment/Callbacks.cs @@ -0,0 +1,153 @@ +using Grpc.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace Pulumi +{ + internal delegate Task Callback(Google.Protobuf.ByteString message, CancellationToken cancellationToken = default); + + /// + /// This class implements the callbacks server used by the engine to invoke remote functions in the dotnet process. + /// + internal sealed class Callbacks : Pulumirpc.Callbacks.CallbacksBase + { + private readonly ConcurrentDictionary _callbacks = new ConcurrentDictionary(); + private readonly Task _target; + + public Callbacks(Task target) + { + _target = target; + } + + public async Task AllocateCallback(Callback callback) + { + // Find a unique token for this callback, this will generally succed on first attempt. + var token = Guid.NewGuid().ToString(); + while (!_callbacks.TryAdd(token, callback)) + { + token = Guid.NewGuid().ToString(); + } + + var result = new Pulumirpc.Callback(); + result.Token = token; + result.Target = await _target; + return result; + } + + public override async Task Invoke(Pulumirpc.CallbackInvokeRequest request, ServerCallContext context) + { + if (!_callbacks.TryGetValue(request.Token, out var callback)) + { + throw new Exception(string.Format("Callback not found: {}", request.Token)); + } + + var response = new Pulumirpc.CallbackInvokeResponse(); + response.Response = await callback(request.Request, context.CancellationToken); + return response; + } + } + + internal sealed class CallbacksHost : IAsyncDisposable + { + private readonly TaskCompletionSource _targetTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly IHost _host; + private readonly CancellationTokenRegistration _portRegistration; + + public CallbacksHost() + { + this._host = Host.CreateDefaultBuilder() + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder + .ConfigureKestrel(kestrelOptions => + { + kestrelOptions.Listen(IPAddress.Loopback, 0, listenOptions => + { + listenOptions.Protocols = HttpProtocols.Http2; + }); + }) + .ConfigureAppConfiguration((context, config) => + { + // clear so we don't read appsettings.json + // note that we also won't read environment variables for config + config.Sources.Clear(); + }) + .ConfigureLogging(loggingBuilder => + { + // disable default logging + loggingBuilder.ClearProviders(); + }) + .ConfigureServices(services => + { + // Injected into Callbacks constructor + services.AddSingleton(_targetTcs); + + services.AddGrpc(grpcOptions => + { + // MaxRpcMesageSize raises the gRPC Max Message size from `4194304` (4mb) to `419430400` (400mb) + var maxRpcMesageSize = 1024 * 1024 * 400; + + grpcOptions.MaxReceiveMessageSize = maxRpcMesageSize; + grpcOptions.MaxSendMessageSize = maxRpcMesageSize; + }); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapGrpcService(); + }); + }); + }) + .Build(); + + // before starting the host, set up this callback to tell us what port was selected + this._portRegistration = this._host.Services.GetRequiredService().ApplicationStarted.Register(() => + { + try + { + var serverFeatures = this._host.Services.GetRequiredService().Features; + var addresses = serverFeatures.Get()!.Addresses.ToList(); + Debug.Assert(addresses.Count == 1, "Server should only be listening on one address"); + this._targetTcs.TrySetResult(addresses[0]); + } + catch (Exception ex) + { + this._targetTcs.TrySetException(ex); + } + }); + } + + public Callbacks Callbacks + { + get + { + return this._host.Services.GetRequiredService(); + } + } + + public Task StartAsync(CancellationToken cancellationToken) + => this._host.StartAsync(cancellationToken); + + public async ValueTask DisposeAsync() + { + this._portRegistration.Unregister(); + await this._host.StopAsync().ConfigureAwait(false); + this._host.Dispose(); + } + } +} diff --git a/sdk/Pulumi/Deployment/Deployment.cs b/sdk/Pulumi/Deployment/Deployment.cs index ef1a1f6a..0ae7b288 100644 --- a/sdk/Pulumi/Deployment/Deployment.cs +++ b/sdk/Pulumi/Deployment/Deployment.cs @@ -175,6 +175,30 @@ internal Deployment(IEngine engine, IMonitor monitor, TestOptions? options) IEngineLogger IDeploymentInternal.Logger => _logger; IRunner IDeploymentInternal.Runner => _runner; + CallbacksHost _callbacks; + internal async Task GetCallbacksAsync(CancellationToken cancellationToken) + { + if (_callbacks != null) + { + return _callbacks; + } + + // Atomically allocate a callbacks instance to use + var callbacks = new CallbacksHost(); + var current = Interlocked.CompareExchange(ref _callbacks, callbacks, null); + if (current == null) + { + // We swapped in the new host so start it up + await callbacks.StartAsync(cancellationToken); + return callbacks; + } + + // Someone beat us to it, just retun the existing one and dispose of the new one we made. + await callbacks.DisposeAsync(); + return current; + } + + Stack IDeploymentInternal.Stack { get => Stack; @@ -231,6 +255,14 @@ internal Task MonitorSupportsAliasSpecs() return MonitorSupportsFeature("aliasSpecs"); } + /// + /// Returns whether the resource monitor we are connected to supports the "transformations" feature across the RPC interface. + /// + internal Task MonitorSupportsTransforms() + { + return MonitorSupportsFeature("transformations"); + } + // Because the secrets feature predates the Pulumi .NET SDK, we assume // that the monitor supports secrets. } diff --git a/sdk/Pulumi/Deployment/Deployment_Prepare.cs b/sdk/Pulumi/Deployment/Deployment_Prepare.cs index 7651f926..3272165a 100644 --- a/sdk/Pulumi/Deployment/Deployment_Prepare.cs +++ b/sdk/Pulumi/Deployment/Deployment_Prepare.cs @@ -6,6 +6,10 @@ using System.Collections.Immutable; using System.Threading.Tasks; using Google.Protobuf.WellKnownTypes; +using Google.Protobuf.Reflection; +using System.Threading; +using System.ComponentModel; +using Google.Protobuf; namespace Pulumi { @@ -119,6 +123,23 @@ await _logger.WarnAsync( var resourceMonitorSupportsAliasSpecs = await MonitorSupportsAliasSpecs().ConfigureAwait(false); var aliases = await PrepareAliases(res, options, resourceMonitorSupportsAliasSpecs).ConfigureAwait(false); + var transformations = new List(); + var resourceMonitorSupportsTransforms = await MonitorSupportsAliasSpecs().ConfigureAwait(false); + if (options.ResourceTransforms.Any()) + { + if (!resourceMonitorSupportsTransforms) + { + throw new Exception("Engine does not support resource transformations"); + } + + var callbacks = await this.GetCallbacksAsync(CancellationToken.None); + + foreach (var t in options.ResourceTransforms) + { + transformations.Add(await AllocateTransform(callbacks.Callbacks, t)); + } + } + return new PrepareResult( serializedProps, parentUrn ?? "", @@ -127,7 +148,8 @@ await _logger.WarnAsync( allDirectDependencyUrns, propertyToDirectDependencyUrns, aliases, - resourceMonitorSupportsAliasSpecs); + resourceMonitorSupportsAliasSpecs, + transformations); void LogExcessive(string message) { @@ -136,6 +158,39 @@ void LogExcessive(string message) } } + private static async Task AllocateTransform(Callbacks callbacks, ResourceTransform transform) + { + var wrapper = new Callback(async (message, token) => + { + var request = Pulumirpc.TransformationRequest.Parser.ParseFrom(message); + + var props = ImmutableDictionary.CreateBuilder(); + foreach(var kv in request.Properties.Fields) + { + props.Add(kv.Key, Serialization.Deserializer.Deserialize(kv.Value)); + } + + var opts = new CustomResourceOptions(); + + var args = new ResourceTransformArgs( + request.Name, + request.Type, + request.Custom, + props.ToImmutable(), + opts); + + var result = await transform(args, token); + + var response = new Pulumirpc.TransformationResponse(); + if (result != null) + { + //response.Properties = + } + return response.ToByteString(); + }); + return await callbacks.AllocateCallback(wrapper); + } + static async Task Resolve(Input? input, T whenUnknown) { return input == null @@ -434,6 +489,7 @@ private readonly struct PrepareResult public readonly HashSet AllDirectDependencyUrns; public readonly Dictionary> PropertyToDirectDependencyUrns; public readonly List Aliases; + public readonly List Transformations; /// /// Returns whether the resource monitor we are connected to supports the "aliasSpec" feature across the RPC interface. /// When that is not the case, use only use the URNs of the aliases to populate the AliasURNs field of RegisterResourceRequest, @@ -450,7 +506,8 @@ public PrepareResult( Dictionary> propertyToDirectDependencyUrns, List aliases, - bool supportsAliasSpec) + bool supportsAliasSpec, + List transformations) { SerializedProps = serializedProps; ParentUrn = parentUrn; @@ -460,6 +517,7 @@ public PrepareResult( PropertyToDirectDependencyUrns = propertyToDirectDependencyUrns; SupportsAliasSpec = supportsAliasSpec; Aliases = aliases; + Transformations = transformations; } } } diff --git a/sdk/Pulumi/Deployment/Deployment_RegisterResource.cs b/sdk/Pulumi/Deployment/Deployment_RegisterResource.cs index 74cb72c3..cda1c2c8 100644 --- a/sdk/Pulumi/Deployment/Deployment_RegisterResource.cs +++ b/sdk/Pulumi/Deployment/Deployment_RegisterResource.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Options; using Pulumirpc; namespace Pulumi @@ -69,6 +70,7 @@ private static void PopulateRequest(RegisterResourceRequest request, PrepareResu request.AliasURNs.AddRange(aliasUrns); } + request.Transformations.AddRange(prepareResult.Transformations); request.Dependencies.AddRange(prepareResult.AllDirectDependencyUrns); foreach (var (key, resourceUrns) in prepareResult.PropertyToDirectDependencyUrns) diff --git a/sdk/Pulumi/Resources/Resource.cs b/sdk/Pulumi/Resources/Resource.cs index 3cc5c386..5211ea16 100644 --- a/sdk/Pulumi/Resources/Resource.cs +++ b/sdk/Pulumi/Resources/Resource.cs @@ -162,7 +162,9 @@ private protected Resource( _name = name; var transformations = ImmutableArray.CreateBuilder(); +#pragma warning disable CS0618 // Type or member is obsolete transformations.AddRange(options.ResourceTransformations); +#pragma warning restore CS0618 // Type or member is obsolete if (parent != null) { transformations.AddRange(parent._transformations); diff --git a/sdk/Pulumi/Resources/ResourceOptions.cs b/sdk/Pulumi/Resources/ResourceOptions.cs index 916a7cd8..1fe47e31 100644 --- a/sdk/Pulumi/Resources/ResourceOptions.cs +++ b/sdk/Pulumi/Resources/ResourceOptions.cs @@ -1,5 +1,6 @@ -// Copyright 2016-2021, Pulumi Corporation +// Copyright 2016-2024, Pulumi Corporation +using System; using System.Collections.Generic; namespace Pulumi @@ -80,12 +81,25 @@ public List IgnoreChanges /// transformations are applied in order, and are applied prior to transformation applied to /// parents walking from the resource up to the stack. /// + [Obsolete("Use ResourceTransforms instead.")] public List ResourceTransformations { get => _resourceTransformations ??= new List(); set => _resourceTransformations = value; } + private List? _resourceTransforms; + + /// + /// Optional list of transforms to apply to this resource during construction.The transforms are applied in + /// order, and are applied prior to transform applied to parents walking from the resource up to the stack. + /// + public List XResourceTransforms + { + get => _resourceTransforms ??= new List(); + set => _resourceTransforms = value; + } + /// /// An optional list of aliases to treat this resource as matching. /// diff --git a/sdk/Pulumi/Resources/ResourceTransform.cs b/sdk/Pulumi/Resources/ResourceTransform.cs new file mode 100644 index 00000000..3f18041d --- /dev/null +++ b/sdk/Pulumi/Resources/ResourceTransform.cs @@ -0,0 +1,64 @@ +// Copyright 2016-2019, Pulumi Corporation + +using System.Collections.Immutable; + +namespace Pulumi +{ + /// + /// ResourceTransform is the callback signature for . A + /// transform is passed the same set of inputs provided to the constructor, and can + /// optionally return back alternate values for the properties and/or options prior to the resource + /// actually being created. The effect will be as though those properties and/or options were passed + /// in place of the original call to the constructor. If the transform returns , this indicates that the resource will not be transformed. + /// + /// The new values to use for the args and options of the in place of + /// the originally provided values. + public delegate ResourceTransformResult? ResourceTransform(ResourceTransformArgs args); + + public readonly struct ResourceTransformArgs + { + /// + /// The name of the resource being transformed. + /// + public string Name { get; } + /// + /// The type of the resource being transformed. + /// + public string Type { get; } + /// + /// If this is a custom resource. + /// + public bool Custom { get; } + /// + /// The original properties passed to the Resource constructor. + /// + public ImmutableDictionary Args { get; } + /// + /// The original resource options passed to the Resource constructor. + /// + public ResourceOptions Options { get; } + + public ResourceTransformArgs( + string name, string type, bool custom, ImmutableDictionary args, ResourceOptions options) + { + Name = name; + Type = type; + Custom = custom; + Args = args; + Options = options; + } + } + + public readonly struct ResourceTransformResult + { + public ImmutableDictionary Args { get; } + public ResourceOptions Options { get; } + + public ResourceTransformResult(ImmutableDictionary args, ResourceOptions options) + { + Args = args; + Options = options; + } + } +} diff --git a/sdk/Pulumi/Resources/StackOptions.cs b/sdk/Pulumi/Resources/StackOptions.cs index 11b2b684..7d87aa42 100644 --- a/sdk/Pulumi/Resources/StackOptions.cs +++ b/sdk/Pulumi/Resources/StackOptions.cs @@ -1,5 +1,6 @@ -// Copyright 2016-2020, Pulumi Corporation +// Copyright 2016-2024, Pulumi Corporation +using System; using System.Collections.Generic; namespace Pulumi @@ -16,10 +17,23 @@ public class StackOptions /// The transformations are applied in order, and are applied after all the transformations of custom /// and component resources in the stack. /// + [Obsolete("Use ResourceTransforms instead.")] public List ResourceTransformations { get => _resourceTransformations ??= new List(); set => _resourceTransformations = value; } + + private List? _resourceTransforms; + + /// + /// Optional list of transforms to apply to this stack's resources during construction. The transforms are + /// applied in order, and are applied after all the transforms of custom and component resources in the stack. + /// + public List XResourceTransforms + { + get => _resourceTransforms ??= new List(); + set => _resourceTransforms = value; + } } } diff --git a/sdk/Pulumi/Stack.cs b/sdk/Pulumi/Stack.cs index b3b06d74..7f554e0d 100644 --- a/sdk/Pulumi/Stack.cs +++ b/sdk/Pulumi/Stack.cs @@ -124,7 +124,10 @@ internal void RegisterPropertyOutputs() return new ComponentResourceOptions { - ResourceTransformations = options.ResourceTransformations +#pragma warning disable CS0618 // Type or member is obsolete + ResourceTransformations = options.ResourceTransformations, +#pragma warning restore CS0618 // Type or member is obsolete + ResourceTransforms = options.ResourceTransforms, }; } }