Skip to content

Commit

Permalink
Component provider host for auto-inferred components (#507)
Browse files Browse the repository at this point in the history
Similar to other language SDKs, this PR implements a high-level
component provider host that discovers schemas and constructors of all
components in a given assembly or list of types at runtime.

Instantiating a component provider is now as simple as authoring a
component and then calling

```csharp
class Program
{
    public static Task Main(string []args) => ComponentProviderHost.Serve(args);
}
```

(see the provider_component_host example)

Resolve #469

---------

Co-authored-by: Thomas Gummerer <[email protected]>
  • Loading branch information
mikhailshilkov and tgummerer authored Feb 25, 2025
1 parent e6178b3 commit e3323bd
Show file tree
Hide file tree
Showing 13 changed files with 469 additions and 5 deletions.
6 changes: 6 additions & 0 deletions .changes/unreleased/Improvements-507.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
component: sdk/provider
kind: Improvements
body: Implement component provider host for auto-inferred components
time: 2025-02-24T16:07:06.547901+01:00
custom:
PR: "507"
9 changes: 9 additions & 0 deletions integration_tests/integration_dotnet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,15 @@ func TestProviderCallInvalidArgument(t *testing.T) {
})
}

//nolint:paralleltest // ProgramTest calls testing.T.Parallel
func TestProviderComponentHost(t *testing.T) {
const testDir = "provider_component_host"
testDotnetProgram(t, &integration.ProgramTestOptions{
Dir: filepath.Join(testDir, "example"),
Quick: true,
})
}

//nolint:paralleltest // ProgramTest calls testing.T.Parallel
func TestProviderConstruct(t *testing.T) {
const testDir = "provider_construct"
Expand Down
80 changes: 80 additions & 0 deletions integration_tests/provider_component_host/Component.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;
using System.Text;
using System.Threading.Tasks;
using Pulumi;

public sealed class ComponentArgs : ResourceArgs
{
[Input("passwordLength", required: true)]
public Input<int> PasswordLength { get; set; } = null!;

[Input("complex")]
public Input<ComplexTypeArgs> Complex { get; set; } = null!;
}

public sealed class ComplexTypeArgs : ResourceArgs
{
[Input("name", required: true)]
public string Name { get; set; } = null!;

[Input("intValue", required: true)]
public int IntValue { get; set; }
}

[OutputType]
public sealed class ComplexType
{
[Output("name")]
public string Name { get; set; }

[Output("intValue")]
public int IntValue { get; set; }

[OutputConstructor]
public ComplexType(string name, int intValue)
{
Name = name;
IntValue = intValue;
}
}

class Component : ComponentResource
{
private static readonly char[] Chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".ToCharArray();

[Output("passwordResult")]
public Output<string> PasswordResult { get; set; }

[Output("complexResult")]
public Output<ComplexType> ComplexResult { get; set; }

public Component(string name, ComponentArgs args, ComponentResourceOptions? opts = null)
: base("test:index:Test", name, args, opts)
{
PasswordResult = args.PasswordLength.Apply(GenerateRandomString);
if (args.Complex != null)
{
ComplexResult = args.Complex.Apply(complex => Output.Create(AsTask(new ComplexType(complex.Name, complex.IntValue))));
}
}

private static Output<string> GenerateRandomString(int length)
{
var result = new StringBuilder(length);
var random = new Random();

for (var i = 0; i < length; i++)
{
result.Append(Chars[random.Next(Chars.Length)]);
}

return Output.CreateSecret(result.ToString());
}

private async Task<T> AsTask<T>(T value)
{
await Task.Delay(10);
return value;
}
}
8 changes: 8 additions & 0 deletions integration_tests/provider_component_host/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Threading;
using System.Threading.Tasks;
using Pulumi.Experimental.Provider;

class Program
{
public static Task Main(string []args) => ComponentProviderHost.Serve(args);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
runtime: dotnet
14 changes: 14 additions & 0 deletions integration_tests/provider_component_host/TestProvider.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework Condition=" '$(TARGET_FRAMEWORK)' != '' ">$(TARGET_FRAMEWORK)</TargetFramework>
<TargetFramework Condition=" '$(TARGET_FRAMEWORK)' == '' ">net8.0</TargetFramework>
<Nullable>enable</Nullable>
<AssemblyName>dotnet-components</AssemblyName>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\sdk\Pulumi\Pulumi.csproj"/>
</ItemGroup>
</Project>
13 changes: 13 additions & 0 deletions integration_tests/provider_component_host/example/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: dotnet-component-yaml
runtime: yaml
plugins:
providers:
- name: dotnet-components
path: ..
resources:
hello:
type: dotnet-components:index:Component
properties:
passwordLength: 12
outputs:
value: ${hello.passwordResult}
109 changes: 109 additions & 0 deletions sdk/Pulumi.Tests/Provider/ComponentProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using Pulumi.Experimental.Provider;

namespace Pulumi.Tests.Provider
{
public class ComponentProviderTests
{
private ComponentProvider _provider;

public ComponentProviderTests()
{
var assembly = typeof(TestComponent).Assembly;
_provider = new ComponentProvider(assembly, "test-package", new[] { typeof(TestComponent) });
}

[Fact]
public async Task GetSchema_ShouldReturnValidSchema()
{
var request = new GetSchemaRequest(1, null, null);
var response = await _provider.GetSchema(request, CancellationToken.None);

Assert.NotNull(response);
Assert.NotNull(response.Schema);
Assert.Contains("TestComponent", response.Schema);
Assert.Contains("testProperty", response.Schema);
}

[Fact]
public async Task Construct_ValidComponent_ShouldThrowExpectedDeploymentException()
{
var name = "test-component";
var inputs = new Dictionary<string, PropertyValue>
{
["testProperty"] = new PropertyValue("test-value")
}.ToImmutableDictionary();
var options = new ComponentResourceOptions();
var request = new ConstructRequest(
"test-package:index:TestComponent",
name,
inputs,
options
);

// We haven't initiated the deployment, so component construction will fail. Expect that specific exception,
// since it will indicate that the rest of the provider is working. The checks are somewhat brittle and may fail
// if we change the exception message or stack, but hopefully that will not happen too often.
var exception = await Assert.ThrowsAsync<TargetInvocationException>(
async () => await _provider.Construct(request, CancellationToken.None)
);

Assert.IsType<InvalidOperationException>(exception.InnerException);
Assert.Contains("Deployment", exception.InnerException!.Message);
}

[Fact]
public async Task Construct_InvalidPackageName_ShouldThrowException()
{
var request = new ConstructRequest(
"wrong:index:TestComponent",
"test",
ImmutableDictionary<string, PropertyValue>.Empty,
new ComponentResourceOptions()
);

var exception = await Assert.ThrowsAsync<ArgumentException>(
async () => await _provider.Construct(request, CancellationToken.None)
);

Assert.Contains("Invalid resource type", exception.Message);
}

[Fact]
public async Task Construct_NonExistentComponent_ShouldThrowException()
{
var request = new ConstructRequest(
"test-package:index:NonExistentComponent",
"test",
ImmutableDictionary<string, PropertyValue>.Empty,
new ComponentResourceOptions()
);

var exception = await Assert.ThrowsAsync<ArgumentException>(
async () => await _provider.Construct(request, CancellationToken.None)
);

Assert.Contains("Component type not found", exception.Message);
}
}

class TestComponentArgs : ResourceArgs
{
[Input("testProperty", required: true)]
public Input<string> TestProperty { get; set; } = null!;
}

class TestComponent : ComponentResource
{
public TestComponent(string name, TestComponentArgs args, ComponentResourceOptions? options = null)
: base("test-package:index:TestComponent", name, args, options)
{
}
}
}
28 changes: 25 additions & 3 deletions sdk/Pulumi/Provider/ComponentAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ private ComponentAnalyzer(Metadata metadata)
/// <returns>A PackageSpec containing the complete schema for all components and their types</returns>
public static PackageSpec GenerateSchema(Metadata metadata, Assembly assembly)
{
var types = assembly.GetTypes()
.Where(t => typeof(ComponentResource).IsAssignableFrom(t) && !t.IsAbstract);
return GenerateSchema(metadata, types.ToArray());
var types = FindComponentTypes(assembly).ToArray();
return GenerateSchema(metadata, types);
}

/// <summary>
Expand Down Expand Up @@ -74,6 +73,29 @@ public static PackageSpec GenerateSchema(Metadata metadata, params Type[] compon
return analyzer.GenerateSchema(metadata, components, analyzer.typeDefinitions);
}

/// <summary>
/// Finds a component type by name in the given assembly or type array.
/// </summary>
public static Type? FindComponentType(string name, Assembly assembly, Type[]? componentTypes = null)
{
// First try to find the type in explicitly provided component types
var componentType = componentTypes?.FirstOrDefault(t => t.Name == name);

// Fall back to assembly lookup if not found or if no types were provided
if (componentType == null)
{
componentType = FindComponentTypes(assembly).FirstOrDefault(t => t.Name == name);
}

return componentType;
}

private static IEnumerable<Type> FindComponentTypes(Assembly assembly)
{
return assembly.GetTypes()
.Where(t => typeof(ComponentResource).IsAssignableFrom(t) && !t.IsAbstract);
}

private PackageSpec GenerateSchema(
Metadata metadata,
Dictionary<string, ResourceSpec> components,
Expand Down
Loading

0 comments on commit e3323bd

Please sign in to comment.