diff --git a/integration_tests/provider_construct/testcomponent-dotnet/Component.cs b/integration_tests/provider_construct/testcomponent-dotnet/Component.cs index bfbb8961..4f150f88 100644 --- a/integration_tests/provider_construct/testcomponent-dotnet/Component.cs +++ b/integration_tests/provider_construct/testcomponent-dotnet/Component.cs @@ -50,10 +50,13 @@ class Component : ComponentResource public Output ComplexResult { get; set; } public Component(string name, ComponentArgs args, ComponentResourceOptions? opts = null) - : base("test:index:Test", name, args, opts) + : base("test:index:Component", name, args, opts) { PasswordResult = args.PasswordLength.Apply(GenerateRandomString); - ComplexResult = args.Complex.Apply(complex => Output.Create(AsTask(new ComplexType(complex.Name, complex.IntValue)))); + if (args.Complex != null) + { + ComplexResult = args.Complex.Apply(complex => Output.Create(AsTask(new ComplexType(complex.Name, complex.IntValue)))); + } } private static Output GenerateRandomString(int length) diff --git a/integration_tests/provider_construct/testcomponent-dotnet/Program.cs b/integration_tests/provider_construct/testcomponent-dotnet/Program.cs index 21887858..66fbbebf 100644 --- a/integration_tests/provider_construct/testcomponent-dotnet/Program.cs +++ b/integration_tests/provider_construct/testcomponent-dotnet/Program.cs @@ -4,6 +4,9 @@ class Program { - public static Task Main(string []args) => - Pulumi.Experimental.Provider.Provider.Serve(args, "0.0.1", host => new TestProviderImpl(), CancellationToken.None); + public static Task Main(string []args) + { + var provider = new Pulumi.Experimental.Provider.ComponentProviderImplementation(null, "test"); + return Pulumi.Experimental.Provider.Provider.Serve(args, "0.0.1", host => provider, CancellationToken.None); + } } diff --git a/integration_tests/provider_construct/testcomponent-dotnet/TestProviderImpl.cs b/integration_tests/provider_construct/testcomponent-dotnet/TestProviderImpl.cs index 9da2e590..ab5a68ab 100644 --- a/integration_tests/provider_construct/testcomponent-dotnet/TestProviderImpl.cs +++ b/integration_tests/provider_construct/testcomponent-dotnet/TestProviderImpl.cs @@ -12,7 +12,7 @@ public override Task Construct(ConstructRequest request, Canc { return request.Type switch { - "test:index:Test" => Construct(request, + "test:index:Component" => Construct(request, (name, args, options) => Task.FromResult(new Component(name, args, options))), _ => throw new NotImplementedException() }; diff --git a/integration_tests/provider_construct/testcomponent-dotnet/example/Pulumi.yaml b/integration_tests/provider_construct/testcomponent-dotnet/example/Pulumi.yaml new file mode 100644 index 00000000..b52e8d26 --- /dev/null +++ b/integration_tests/provider_construct/testcomponent-dotnet/example/Pulumi.yaml @@ -0,0 +1,11 @@ +name: test-yaml +runtime: yaml +plugins: + providers: + - name: test + path: ../ +resources: + r1: + type: test:index:Component + properties: + passwordLength: 10 diff --git a/sdk/Pulumi/Provider/Analyzer.cs b/sdk/Pulumi/Provider/Analyzer.cs new file mode 100644 index 00000000..fd21384e --- /dev/null +++ b/sdk/Pulumi/Provider/Analyzer.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Linq; + +namespace Pulumi.Experimental.Provider +{ + public class Analyzer + { + private readonly Metadata metadata; + private readonly Dictionary typeDefinitions = new(); + + public Analyzer(Metadata metadata) + { + this.metadata = metadata; + } + + public (Dictionary, Dictionary) Analyze(Assembly assembly) + { + var components = new Dictionary(); + + // Find all component resources in the assembly + var types = assembly.GetTypes(); + foreach (var type in types) + { + if (typeof(ComponentResource).IsAssignableFrom(type) && !type.IsAbstract) + { + components[type.Name] = AnalyzeComponent(type); + } + } + + return (components, typeDefinitions); + } + + private ComponentDefinition AnalyzeComponent(Type componentType) + { + // Get constructor args type + var argsType = GetArgsType(componentType); + var (inputs, inputsMapping) = AnalyzeType(argsType); + var (outputs, outputsMapping) = AnalyzeOutputs(componentType); + + return new ComponentDefinition + { + Description = componentType.GetCustomAttribute()?.Description, + Inputs = inputs, + InputsMapping = inputsMapping, + Outputs = outputs, + OutputsMapping = outputsMapping + }; + } + + private static Type GetArgsType(Type componentType) + { + var constructor = componentType.GetConstructors().First(); + var argsParameter = constructor.GetParameters() + .FirstOrDefault(p => p.Name == "args") + ?? throw new ArgumentException($"Component {componentType.Name} must have an 'args' parameter in constructor"); + + return argsParameter.ParameterType; + } + + private (Dictionary, Dictionary) AnalyzeType(Type type) + { + var properties = new Dictionary(); + var mapping = new Dictionary(); + + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var schemaName = GetSchemaPropertyName(prop); + mapping[schemaName] = prop.Name; + + var propertyDef = AnalyzeProperty(prop); + if (propertyDef != null) + { + properties[schemaName] = propertyDef; + } + } + + return (properties, mapping); + } + + private (Dictionary, Dictionary) AnalyzeOutputs(Type type) + { + var properties = new Dictionary(); + var mapping = new Dictionary(); + + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + // Only look at properties marked with [Output] + var outputAttr = prop.GetCustomAttribute(); + if (outputAttr == null) continue; + + var schemaName = outputAttr.Name ?? GetSchemaPropertyName(prop); + mapping[schemaName] = prop.Name; + + var propertyDef = AnalyzeProperty(prop); + if (propertyDef != null) + { + properties[schemaName] = propertyDef; + } + } + + return (properties, mapping); + } + + private PropertyDefinition? AnalyzeProperty(PropertyInfo prop) + { + var propType = prop.PropertyType; + var isOptional = IsOptionalProperty(prop); + + // Handle Input and Output + if (IsInputOrOutput(propType, out var unwrappedType)) + { + propType = unwrappedType; + } + + // Get property description + var description = prop.GetCustomAttribute()?.Description; + + if (IsBuiltinType(propType, out var builtinType)) + { + return new PropertyDefinition + { + Description = description, + Type = builtinType, + Optional = isOptional + }; + } + else if (propType.IsClass && propType != typeof(string)) + { + // Complex type + var typeName = propType.Name; + var typeRef = $"#/types/{metadata.Name}:index:{typeName}"; + + // Add to type definitions if not already present + if (!typeDefinitions.ContainsKey(typeName)) + { + var (properties, mapping) = AnalyzeType(propType); + typeDefinitions[typeName] = new TypeDefinition + { + Name = typeName, + Description = propType.GetCustomAttribute()?.Description, + Properties = properties, + PropertiesMapping = mapping + }; + } + + return new PropertyDefinition + { + Description = description, + Ref = typeRef, + Optional = isOptional + }; + } + + return null; + } + + private static bool IsInputOrOutput(Type type, out Type unwrappedType) + { + unwrappedType = type; + + if (type.IsGenericType) + { + var genericDef = type.GetGenericTypeDefinition(); + if (genericDef == typeof(Input<>) || genericDef == typeof(Output<>)) + { + unwrappedType = type.GetGenericArguments()[0]; + return true; + } + } + + return false; + } + + private static bool IsBuiltinType(Type type, out string builtinType) + { + if (type == typeof(string)) + { + builtinType = BuiltinTypeSpec.String; + return true; + } + if (type == typeof(int) || type == typeof(long)) + { + builtinType = BuiltinTypeSpec.Integer; + return true; + } + if (type == typeof(double) || type == typeof(float) || type == typeof(decimal)) + { + builtinType = BuiltinTypeSpec.Number; + return true; + } + if (type == typeof(bool)) + { + builtinType = BuiltinTypeSpec.Boolean; + return true; + } + + builtinType = BuiltinTypeSpec.Object; + return false; + } + + private static bool IsOptionalProperty(PropertyInfo prop) + { + // Check if type is nullable + if (Nullable.GetUnderlyingType(prop.PropertyType) != null) + return true; + + // Check if property has [Input] attribute with required=false + var inputAttr = prop.GetCustomAttribute(); + if (inputAttr != null) + return !inputAttr.IsRequired; + + // Default to optional + return true; + } + + private static string GetSchemaPropertyName(PropertyInfo prop) + { + // Check for explicit name in Input attribute + var inputAttr = prop.GetCustomAttribute(); + if (inputAttr != null && !string.IsNullOrEmpty(inputAttr.Name)) + return inputAttr.Name; + + // Convert to camelCase + var name = prop.Name; + return char.ToLowerInvariant(name[0]) + name.Substring(1); + } + } +} \ No newline at end of file diff --git a/sdk/Pulumi/Provider/ComponentProviderImplementation.cs b/sdk/Pulumi/Provider/ComponentProviderImplementation.cs new file mode 100644 index 00000000..464480fe --- /dev/null +++ b/sdk/Pulumi/Provider/ComponentProviderImplementation.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Immutable; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; +using System.Text.Json.Serialization; +using Pulumi.Utilities; +namespace Pulumi.Experimental.Provider +{ + public class ComponentProviderImplementation : Provider + { + private readonly Assembly componentAssembly; + private readonly string packageName; +#pragma warning disable CS0618 // Type or member is obsolete + private readonly PropertyValueSerializer serializer; +#pragma warning restore CS0618 // Type or member is obsolete + + public ComponentProviderImplementation(Assembly? componentAssembly, string? packageName) + { + this.componentAssembly = componentAssembly ?? Assembly.GetCallingAssembly(); + this.packageName = packageName ?? this.componentAssembly.GetName().Name!.ToLower(); +#pragma warning disable CS0618 // Type or member is obsolete + this.serializer = new PropertyValueSerializer(); +#pragma warning restore CS0618 // Type or member is obsolete + } + + public override Task GetSchema(GetSchemaRequest request, CancellationToken ct) + { + var analyzer = new Analyzer(new Metadata + { + Name = packageName, + Version = "1.0.0" // This should probably come from the provider configuration + }); + + var (components, typeDefinitions) = analyzer.Analyze(componentAssembly); + var schema = PackageSpec.GenerateSchema( + metadata: new Metadata + { + Name = packageName, + Version = "1.0.0", + // You might want to add DisplayName from provider config + }, + components: components, + typeDefinitions: typeDefinitions + ); + + // Serialize to JSON + var jsonSchema = JsonSerializer.Serialize(schema, new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + + // throw new Exception(jsonSchema); + + return Task.FromResult(new GetSchemaResponse + { + Schema = jsonSchema + }); + } + + public override async Task Construct(ConstructRequest request, CancellationToken ct) + { + try { + // Parse type token + var parts = request.Type.Split(':'); + if (parts.Length != 3 || parts[0] != packageName) + throw new ArgumentException($"Invalid resource type: {request.Type}"); + + var componentName = parts[2]; + var componentType = componentAssembly.GetType(componentName) + ?? throw new ArgumentException($"Component type not found: {componentName}"); + + // Create args instance by deserializing inputs + var argsType = GetArgsType(componentType); + var args = serializer.Deserialize(new PropertyValue(request.Inputs), argsType); + + // Create component instance + var component = (ComponentResource)Activator.CreateInstance(componentType, request.Name, args, request.Options)!; + + var urn = await OutputUtilities.GetValueAsync(component.Urn); + if (string.IsNullOrEmpty(urn)) + { + throw new InvalidOperationException($"URN of resource {request.Name} is not known."); + } + + var stateValue = await serializer.StateFromComponentResource(component); + + return new ConstructResponse(new Experimental.Provider.Urn(urn), stateValue, ImmutableDictionary>.Empty); + } + catch (Exception e) + { + throw new Exception($"Error constructing resource {request.Name}: {e.Message} {e.StackTrace}"); + } + } + + private static Type GetArgsType(Type componentType) + { + var constructor = componentType.GetConstructors().First(); + var argsParameter = constructor.GetParameters() + .FirstOrDefault(p => p.Name == "args") + ?? throw new ArgumentException($"Component {componentType.Name} must have an 'args' parameter in constructor"); + + return argsParameter.ParameterType; + } + } +} diff --git a/sdk/Pulumi/Provider/Metadata.cs b/sdk/Pulumi/Provider/Metadata.cs new file mode 100644 index 00000000..da808da7 --- /dev/null +++ b/sdk/Pulumi/Provider/Metadata.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +namespace Pulumi.Experimental.Provider +{ + public class Metadata + { + public string Name { get; set; } = ""; + public string Version { get; set; } = ""; + public string? DisplayName { get; set; } + } + + public class PropertyDefinition + { + public string? Description { get; set; } + public string? Type { get; set; } + public string? Ref { get; set; } + public bool Optional { get; set; } + } + + public class TypeDefinition + { + public string Name { get; set; } = ""; + public string? Description { get; set; } + public Dictionary Properties { get; set; } = new(); + public Dictionary PropertiesMapping { get; set; } = new(); + } + + public class ComponentDefinition + { + public string? Description { get; set; } + public Dictionary Inputs { get; set; } = new(); + public Dictionary InputsMapping { get; set; } = new(); + public Dictionary Outputs { get; set; } = new(); + public Dictionary OutputsMapping { get; set; } = new(); + } +} \ No newline at end of file diff --git a/sdk/Pulumi/Provider/PropertyValueSerializer.cs b/sdk/Pulumi/Provider/PropertyValueSerializer.cs index 42668b11..f185ec3b 100644 --- a/sdk/Pulumi/Provider/PropertyValueSerializer.cs +++ b/sdk/Pulumi/Provider/PropertyValueSerializer.cs @@ -382,8 +382,7 @@ private string DeserializationError( public Task Deserialize(PropertyValue value) { - var rootPath = new[] { "$" }; - var deserialized = DeserializeValue(value, typeof(T), rootPath); + var deserialized = Deserialize(value, typeof(T)); if (deserialized is T deserializedValue) { return Task.FromResult(deserializedValue); @@ -392,6 +391,12 @@ public Task Deserialize(PropertyValue value) throw new InvalidOperationException($"Could not deserialize value of type {typeof(T).Name}"); } + public object? Deserialize(PropertyValue value, Type targetType) + { + var rootPath = new[] { "$" }; + return DeserializeValue(value, targetType, rootPath); + } + private object? DeserializeValue(PropertyValue value, Type targetType, string[] path) { void ThrowTypeMismatchError(PropertyValueType expectedType) diff --git a/sdk/Pulumi/Provider/Schema.cs b/sdk/Pulumi/Provider/Schema.cs new file mode 100644 index 00000000..1dee5a5b --- /dev/null +++ b/sdk/Pulumi/Provider/Schema.cs @@ -0,0 +1,184 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using System.Linq; + +namespace Pulumi.Experimental.Provider +{ + public static class BuiltinTypeSpec + { + public const string String = "string"; + public const string Integer = "integer"; + public const string Number = "number"; + public const string Boolean = "boolean"; + public const string Object = "object"; + } + + public class ItemTypeSpec + { + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + } + + public class PropertySpec + { + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("willReplaceOnChanges")] + public bool? WillReplaceOnChanges { get; set; } + + [JsonPropertyName("items")] + public ItemTypeSpec? Items { get; set; } + + [JsonPropertyName("$ref")] + public string? Ref { get; set; } + + public static PropertySpec FromDefinition(PropertyDefinition property) + { + return new PropertySpec + { + Description = property.Description, + Type = property.Type, + WillReplaceOnChanges = false, + Items = null, + Ref = property.Ref + }; + } + } + + public class ComplexTypeSpec + { + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = new(); + + [JsonPropertyName("required")] + public List Required { get; set; } = new(); + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("enum")] + public List? Enum { get; set; } + + public static ComplexTypeSpec FromDefinition(TypeDefinition typeDef) + { + return new ComplexTypeSpec + { + Type = "object", + Properties = typeDef.Properties.ToDictionary( + kvp => kvp.Key, + kvp => PropertySpec.FromDefinition(kvp.Value)), + Required = new List(), + Description = typeDef.Description + }; + } + } + + public class ResourceSpec + { + [JsonPropertyName("isComponent")] + public bool IsComponent { get; set; } + + [JsonPropertyName("inputProperties")] + public Dictionary InputProperties { get; set; } = new(); + + [JsonPropertyName("requiredInputs")] + public List RequiredInputs { get; set; } = new(); + + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = new(); + + [JsonPropertyName("required")] + public List Required { get; set; } = new(); + + [JsonPropertyName("description")] + public string? Description { get; set; } + + public static ResourceSpec FromDefinition(ComponentDefinition component) + { + return new ResourceSpec + { + IsComponent = true, + Type = "object", + InputProperties = component.Inputs.ToDictionary( + kvp => kvp.Key, + kvp => PropertySpec.FromDefinition(kvp.Value)), + RequiredInputs = component.Inputs + .Where(kvp => !kvp.Value.Optional) + .Select(kvp => kvp.Key) + .ToList(), + Properties = component.Outputs.ToDictionary( + kvp => kvp.Key, + kvp => PropertySpec.FromDefinition(kvp.Value)), + Required = component.Outputs + .Where(kvp => !kvp.Value.Optional) + .Select(kvp => kvp.Key) + .ToList() + }; + } + } + + public class PackageSpec + { + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + [JsonPropertyName("version")] + public string Version { get; set; } = ""; + + [JsonPropertyName("resources")] + public Dictionary Resources { get; set; } = new(); + + [JsonPropertyName("types")] + public Dictionary Types { get; set; } = new(); + + [JsonPropertyName("language")] + public Dictionary> Language { get; set; } = new(); + + public static PackageSpec GenerateSchema( + Metadata metadata, + Dictionary components, + Dictionary typeDefinitions) + { + var pkg = new PackageSpec + { + Name = metadata.Name, + Version = metadata.Version, + DisplayName = metadata.DisplayName ?? metadata.Name, + Language = new Dictionary> + { + ["nodejs"] = new() { ["respectSchemaVersion"] = true }, + ["python"] = new() { ["respectSchemaVersion"] = true }, + ["csharp"] = new() { ["respectSchemaVersion"] = true }, + ["java"] = new() { ["respectSchemaVersion"] = true }, + ["go"] = new() { ["respectSchemaVersion"] = true } + } + }; + + foreach (var (componentName, component) in components) + { + var name = $"{metadata.Name}:index:{componentName}"; + pkg.Resources[name] = ResourceSpec.FromDefinition(component); + } + + foreach (var (typeName, type) in typeDefinitions) + { + pkg.Types[$"{metadata.Name}:index:{typeName}"] = ComplexTypeSpec.FromDefinition(type); + } + + return pkg; + } + } +}