First-class Stack component for .NET (#3618)

First-class Stack component for .NET
This commit is contained in:
Mikhail Shilkov 2019-12-23 08:31:12 +01:00 committed by GitHub
parent 6525d17b7b
commit 66de4a48b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 315 additions and 34 deletions

View file

@ -18,6 +18,8 @@ CHANGELOG
- Support for using `Config`, `getProject()`, `getStack()`, and `isDryRun()` from Policy Packs.
[#3612](https://github.com/pulumi/pulumi/pull/3612)
- Top-level Stack component in the .NET SDK.
[#3618](https://github.com/pulumi/pulumi/pull/3618)
- Add the .NET Core 3.0 runtime to the `pulumi/pulumi` container. [#3616](https://github.com/pulumi/pulumi/pull/3616)

View file

@ -0,0 +1,113 @@
// Copyright 2016-2019, Pulumi Corporation
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Moq;
using Pulumi.Serialization;
using Xunit;
using Xunit.Sdk;
namespace Pulumi.Tests
{
public class StackTests
{
private class ValidStack : Stack
{
[Output("foo")]
public Output<string> ExplicitName { get; }
[Output]
public Output<string> ImplicitName { get; }
public ValidStack()
{
this.ExplicitName = Output.Create("bar");
this.ImplicitName = Output.Create("buzz");
}
}
[Fact]
public async Task ValidStackInstantiationSucceeds()
{
var (stack, outputs) = await Run<ValidStack>();
Assert.Equal(2, outputs.Count);
Assert.Same(stack.ExplicitName, outputs["foo"]);
Assert.Same(stack.ImplicitName, outputs["ImplicitName"]);
}
private class NullOutputStack : Stack
{
[Output("foo")]
public Output<string>? Foo { get; }
}
[Fact]
public async Task StackWithNullOutputsThrows()
{
try
{
var (stack, outputs) = await Run<NullOutputStack>();
}
catch (RunException ex)
{
Assert.Contains("foo", ex.Message);
return;
}
throw new XunitException("Should not come here");
}
private class InvalidOutputTypeStack : Stack
{
[Output("foo")]
public string Foo { get; }
public InvalidOutputTypeStack()
{
this.Foo = "bar";
}
}
[Fact]
public async Task StackWithInvalidOutputTypeThrows()
{
try
{
var (stack, outputs) = await Run<InvalidOutputTypeStack>();
}
catch (RunException ex)
{
Assert.Contains("foo", ex.Message);
return;
}
throw new XunitException("Should not come here");
}
private async Task<(T, IDictionary<string, object?>)> Run<T>() where T : Stack, new()
{
// Arrange
Output<IDictionary<string, object?>>? outputs = null;
var mock = new Mock<IDeploymentInternal>(MockBehavior.Strict);
mock.Setup(d => d.ProjectName).Returns("TestProject");
mock.Setup(d => d.StackName).Returns("TestStack");
mock.SetupSet(content => content.Stack = It.IsAny<Stack>());
mock.Setup(d => d.ReadOrRegisterResource(It.IsAny<Stack>(), It.IsAny<ResourceArgs>(), It.IsAny<ResourceOptions>()));
mock.Setup(d => d.RegisterResourceOutputs(It.IsAny<Stack>(), It.IsAny<Output<IDictionary<string, object?>>>()))
.Callback((Resource _, Output<IDictionary<string, object?>> o) => outputs = o);
Deployment.Instance = mock.Object;
// Act
var stack = new T();
stack.RegisterPropertyOutputs();
// Assert
Assert.NotNull(outputs);
var values = await outputs!.DataTask;
return (stack, values.Value);
}
}
}

View file

@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Pulumi.Tests")]
[assembly: InternalsVisibleTo("Pulumi.Tests")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq tests

View file

@ -15,6 +15,23 @@ namespace Pulumi
public Runner(IDeploymentInternal deployment)
=> _deployment = deployment;
public Task<int> RunAsync<TStack>() where TStack : Stack, new()
{
try
{
var stack = new TStack();
// Stack doesn't call RegisterOutputs, so we register them on its behalf.
stack.RegisterPropertyOutputs();
RegisterTask("User program code.", stack.Outputs.DataTask);
}
catch (Exception ex)
{
return HandleExceptionAsync(ex);
}
return WhileRunningAsync();
}
public Task<int> RunAsync(Func<Task<IDictionary<string, object?>>> func)
{
var stack = new Stack(func);

View file

@ -9,6 +9,7 @@ namespace Pulumi
public partial class Deployment
{
private Task<string>? _rootResource;
private object _rootResourceLock = new object();
/// <summary>
/// Returns a root resource URN that will automatically become the default parent of all
@ -23,21 +24,18 @@ namespace Pulumi
if (type == Stack._rootPulumiStackTypeName)
return null;
if (_rootResource == null)
throw new InvalidOperationException($"Calling {nameof(GetRootResourceAsync)} before the root resource was registered!");
lock (_rootResourceLock)
{
if (_rootResource == null)
{
var stack = InternalInstance.Stack ?? throw new InvalidOperationException($"Calling {nameof(GetRootResourceAsync)} before the stack was registered!");
_rootResource = SetRootResourceWorkerAsync(stack);
}
}
return await _rootResource.ConfigureAwait(false);
}
Task IDeploymentInternal.SetRootResourceAsync(Stack stack)
{
if (_rootResource != null)
throw new InvalidOperationException("Tried to set the root resource more than once!");
_rootResource = SetRootResourceWorkerAsync(stack);
return _rootResource;
}
private async Task<string> SetRootResourceWorkerAsync(Stack stack)
{
var resUrn = await stack.Urn.GetValueAsync().ConfigureAwait(false);

View file

@ -28,7 +28,7 @@ namespace Pulumi
=> RunAsync(() => Task.FromResult(func()));
/// <summary>
/// <see cref="RunAsync(Func{Task{IDictionary{string, object}}})"/> is the
/// <see cref="RunAsync(Func{Task{IDictionary{string, object}}})"/> is an
/// entry-point to a Pulumi application. .NET applications should perform all startup logic
/// they need in their <c>Main</c> method and then end with:
/// <para>
@ -36,7 +36,7 @@ namespace Pulumi
/// static Task&lt;int&gt; Main(string[] args)
/// {
/// // program initialization code ...
///
///
/// return Deployment.Run(async () =>
/// {
/// // Code that creates resources.
@ -56,6 +56,38 @@ namespace Pulumi
/// in this dictionary will become the outputs for the Pulumi Stack that is created.
/// </summary>
public static Task<int> RunAsync(Func<Task<IDictionary<string, object?>>> func)
=> CreateRunner().RunAsync(func);
/// <summary>
/// <see cref="RunAsync{TStack}()"/> is an entry-point to a Pulumi
/// application. .NET applications should perform all startup logic they
/// need in their <c>Main</c> method and then end with:
/// <para>
/// <c>
/// static Task&lt;int&gt; Main(string[] args) {// program
/// initialization code ...
///
/// return Deployment.Run&lt;MyStack&gt;();}
/// </c>
/// </para>
/// <para>
/// Deployment will instantiate a new stack instance based on the type
/// passed as TStack type parameter. Importantly, cloud resources cannot
/// be created outside of the <see cref="Stack"/> component.
/// </para>
/// <para>
/// Because cloud Resource construction is inherently asynchronous, the
/// result of this function is a <see cref="Task{T}"/> which should then
/// be returned or awaited. This will ensure that any problems that are
/// encountered during the running of the program are properly reported.
/// Failure to do this may lead to the program ending early before all
/// resources are properly registered.
/// </para>
/// </summary>
public static Task<int> RunAsync<TStack>() where TStack : Stack, new()
=> CreateRunner().RunAsync<TStack>();
private static IRunner CreateRunner()
{
// Serilog.Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger();
@ -68,7 +100,7 @@ namespace Pulumi
Serilog.Log.Debug("Creating new Deployment.");
var deployment = new Deployment();
Instance = deployment;
return deployment._runner.RunAsync(func);
return deployment._runner;
}
}
}

View file

@ -16,8 +16,6 @@ namespace Pulumi
ILogger Logger { get; }
IRunner Runner { get; }
Task SetRootResourceAsync(Stack stack);
void ReadOrRegisterResource(Resource resource, ResourceArgs args, ResourceOptions opts);
void RegisterResourceOutputs(Resource resource, Output<IDictionary<string, object?>> outputs);
}

View file

@ -10,5 +10,6 @@ namespace Pulumi
{
void RegisterTask(string description, Task task);
Task<int> RunAsync(Func<Task<IDictionary<string, object?>>> func);
Task<int> RunAsync<TStack>() where TStack : Stack, new();
}
}

View file

@ -165,11 +165,13 @@ Pulumi.Serialization.InputAttribute
Pulumi.Serialization.InputAttribute.InputAttribute(string name, bool required = false, bool json = false) -> void
Pulumi.Serialization.OutputAttribute
Pulumi.Serialization.OutputAttribute.Name.get -> string
Pulumi.Serialization.OutputAttribute.OutputAttribute(string name) -> void
Pulumi.Serialization.OutputAttribute.OutputAttribute(string name = null) -> void
Pulumi.Serialization.OutputConstructorAttribute
Pulumi.Serialization.OutputConstructorAttribute.OutputConstructorAttribute() -> void
Pulumi.Serialization.OutputTypeAttribute
Pulumi.Serialization.OutputTypeAttribute.OutputTypeAttribute() -> void
Pulumi.Stack
Pulumi.Stack.Stack() -> void
Pulumi.StackReference
Pulumi.StackReference.GetOutput(Pulumi.Input<string> name) -> Pulumi.Output<object>
Pulumi.StackReference.GetValueAsync(Pulumi.Input<string> name) -> System.Threading.Tasks.Task<object>
@ -208,6 +210,7 @@ static Pulumi.Deployment.Instance.get -> Pulumi.IDeployment
static Pulumi.Deployment.RunAsync(System.Action action) -> System.Threading.Tasks.Task<int>
static Pulumi.Deployment.RunAsync(System.Func<System.Collections.Generic.IDictionary<string, object>> func) -> System.Threading.Tasks.Task<int>
static Pulumi.Deployment.RunAsync(System.Func<System.Threading.Tasks.Task<System.Collections.Generic.IDictionary<string, object>>> func) -> System.Threading.Tasks.Task<int>
static Pulumi.Deployment.RunAsync<TStack>() -> System.Threading.Tasks.Task<int>
static Pulumi.Input<T>.implicit operator Pulumi.Input<T>(Pulumi.Output<T> value) -> Pulumi.Input<T>
static Pulumi.Input<T>.implicit operator Pulumi.Input<T>(T value) -> Pulumi.Input<T>
static Pulumi.Input<T>.implicit operator Pulumi.Output<T>(Pulumi.Input<T> input) -> Pulumi.Output<T>

View file

@ -11,9 +11,9 @@ namespace Pulumi.Serialization
[AttributeUsage(AttributeTargets.Property)]
public sealed class OutputAttribute : Attribute
{
public string Name { get; }
public string? Name { get; }
public OutputAttribute(string name)
public OutputAttribute(string? name = null)
{
Name = name;
}

View file

@ -92,7 +92,9 @@ namespace Pulumi.Serialization
var completionSource = (IOutputCompletionSource)ocsContructor.Invoke(new[] { resource });
setMethod.Invoke(resource, new[] { completionSource.Output });
result.Add(attr.Name, completionSource);
var outputName = attr.Name ?? prop.Name;
result.Add(outputName, completionSource);
}
Log.Debug("Fields to assign: " + JsonSerializer.Serialize(result.Keys), resource);

View file

@ -3,18 +3,18 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Pulumi.Serialization;
namespace Pulumi
{
/// <summary>
/// Stack is the root resource for a Pulumi stack. Before invoking the <c>init</c> callback, it
/// registers itself as the root resource with the Pulumi engine.
///
/// An instance of this will be automatically created when any <see
/// cref="Deployment.RunAsync(Action)"/> overload is called.
/// Stack is the root resource for a Pulumi stack. Derive from this class to create your
/// stack definitions.
/// </summary>
internal sealed class Stack : ComponentResource
public class Stack : ComponentResource
{
/// <summary>
/// Constant to represent the 'root stack' resource for a Pulumi application. The purpose
@ -31,7 +31,7 @@ namespace Pulumi
/// may look a bit confusing and may incorrectly look like something that could be removed
/// without changing semantics.
/// </summary>
public static readonly Resource? Root = null;
internal static readonly Resource? Root = null;
/// <summary>
/// <see cref="_rootPulumiStackTypeName"/> is the type name that should be used to construct
@ -44,14 +44,25 @@ namespace Pulumi
/// <summary>
/// The outputs of this stack, if the <c>init</c> callback exited normally.
/// </summary>
public readonly Output<IDictionary<string, object?>> Outputs =
internal Output<IDictionary<string, object?>> Outputs =
Output.Create<IDictionary<string, object?>>(ImmutableDictionary<string, object?>.Empty);
internal Stack(Func<Task<IDictionary<string, object?>>> init)
/// <summary>
/// Create a Stack with stack resources defined in derived class constructor.
/// </summary>
public Stack()
: base(_rootPulumiStackTypeName, $"{Deployment.Instance.ProjectName}-{Deployment.Instance.StackName}")
{
Deployment.InternalInstance.Stack = this;
}
/// <summary>
/// Create a Stack with stack resources created by the <c>init</c> callback.
/// An instance of this will be automatically created when any <see
/// cref="Deployment.RunAsync(Action)"/> overload is called.
/// </summary>
internal Stack(Func<Task<IDictionary<string, object?>>> init) : this()
{
try
{
this.Outputs = Output.Create(RunInitAsync(init));
@ -62,12 +73,46 @@ namespace Pulumi
}
}
/// <summary>
/// Inspect all public properties of the stack to find outputs. Validate the values and register them as stack outputs.
/// </summary>
internal void RegisterPropertyOutputs()
{
var outputs = (from property in this.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
let attr = property.GetCustomAttribute<OutputAttribute>()
where attr != null
let name = attr.Name ?? property.Name
select new KeyValuePair<string, object?>(name, property.GetValue(this))).ToList();
// Check that none of the values are null: catch unassigned outputs
var nulls = (from kv in outputs
where kv.Value == null
select kv.Key).ToList();
if (nulls.Any())
{
var message = $"Output(s) '{string.Join(", ", nulls)}' have no value assigned. [Output] attributed properties must be assigned inside Stack constructor.";
throw new RunException(message);
}
// Check that all the values are Output<T>
var wrongTypes = (from kv in outputs
let type = kv.Value.GetType()
let isOutput = type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Output<>)
where !isOutput
select kv.Key).ToList();
if (wrongTypes.Any())
{
var message = $"Output(s) '{string.Join(", ", wrongTypes)}' have incorrect type. [Output] attributed properties must be instances of Output<T>.";
throw new RunException(message);
}
IDictionary<string, object?> dict = new Dictionary<string, object?>(outputs);
this.Outputs = Output.Create(dict);
this.RegisterOutputs(this.Outputs);
}
private async Task<IDictionary<string, object?>> RunInitAsync(Func<Task<IDictionary<string, object?>>> init)
{
// Ensure we are known as the root resource. This is needed before we execute any user
// code as many codepaths will request the root resource.
await Deployment.InternalInstance.SetRootResourceAsync(this).ConfigureAwait(false);
var dictionary = await init().ConfigureAwait(false);
return dictionary == null
? ImmutableDictionary<string, object?>.Empty

View file

@ -505,6 +505,29 @@ func TestStackDependencyGraph(t *testing.T) {
})
}
// TestStackComponentDotNet tests the programming model of defining a stack as an explicit top-level component.
func TestStackComponentDotNet(t *testing.T) {
integration.ProgramTest(t, &integration.ProgramTestOptions{
Dir: filepath.Join("stack_component", "dotnet"),
Dependencies: []string{"Pulumi"},
Quick: true,
ExtraRuntimeValidation: func(t *testing.T, stackInfo integration.RuntimeValidationStackInfo) {
// Ensure the checkpoint contains a single resource, the Stack, with two outputs.
fmt.Printf("Deployment: %v", stackInfo.Deployment)
assert.NotNil(t, stackInfo.Deployment)
if assert.Equal(t, 1, len(stackInfo.Deployment.Resources)) {
stackRes := stackInfo.Deployment.Resources[0]
assert.NotNil(t, stackRes)
assert.Equal(t, resource.RootStackType, stackRes.URN.Type())
assert.Equal(t, 0, len(stackRes.Inputs))
assert.Equal(t, 2, len(stackRes.Outputs))
assert.Equal(t, "ABC", stackRes.Outputs["abc"])
assert.Equal(t, float64(42), stackRes.Outputs["Foo"])
}
},
})
}
// TestConfigSave ensures that config commands in the Pulumi CLI work as expected.
func TestConfigSave(t *testing.T) {
e := ptesting.NewEnvironment(t)

View file

@ -0,0 +1,5 @@
/.pulumi/
[Bb]in/
[Oo]bj/

View file

@ -0,0 +1,30 @@
// Copyright 2016-2019, Pulumi Corporation. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Pulumi;
using Pulumi.Serialization;
class MyStack : Stack
{
[Output("abc")]
public Output<string> Abc { get; private set; }
[Output]
public Output<int> Foo { get; private set; }
// This should NOT be exported as stack output due to the missing attribute
public Output<string> Bar { get; private set; }
public MyStack()
{
this.Abc = Output.Create("ABC");
this.Foo = Output.Create(42);
this.Bar = Output.Create("this should not come to output");
}
}
class Program
{
static Task<int> Main(string[] args) => Deployment.RunAsync<MyStack>();
}

View file

@ -0,0 +1,3 @@
name: stack_compoennt
description: A program that is defined as a Stack component.
runtime: dotnet

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
</PropertyGroup>
</Project>