aspire icon indicating copy to clipboard operation
aspire copied to clipboard

Consider providing an external endpoint resource

Open paulomorgado opened this issue 1 year ago • 20 comments

For external endpoint resources, I'm using this code:

public class ResourceWithServiceDiscovery : IResourceWithServiceDiscovery
{
    public required string Name { get; init; }

    public required ResourceMetadataCollection Annotations { get; init; }
}

And using it like this:

var remoteResource = new ResourceWithServiceDiscovery
{
    Name = "someremoteresource",
    Annotations = [
        new AllocatedEndpointAnnotation(
            "http",
            ProtocolType.Tcp,
            remoteHubConfig.GetValue<string>("Address")!,
            remoteHubConfig.GetValue<int>("Port"),
            remoteHubConfig.GetValue<string>("Scheme")!)
    ]
};

It would be nice to have something like this built in and showing up in the dashboard.

paulomorgado avatar Feb 19 '24 18:02 paulomorgado

https://github.com/dotnet/aspire/pull/2390

paulomorgado avatar Feb 29 '24 09:02 paulomorgado

@paulomorgado if you would like to send a PR for this, that would be great 😄. I think we'd want to model it similarly to how we did AddConnectionString (it's just a parameter). Do you want to mock up an API in the issue?

davidfowl avatar Mar 02 '24 16:03 davidfowl

@davidfowl, AddConnectionString is a preview 4 thing only, right?

And it creates a resource builder behind the scenes, right?

How does that behave if that connection string is added to more than one resource? How would that behave?

paulomorgado avatar Mar 04 '24 12:03 paulomorgado

AddConnectionString adds a parameter resource configured to be accessed like a connection string.

How does that behave if that connection string is added to more than one resource? How would that behave?

It's a resource by itself, it's not added to a resource.

davidfowl avatar Mar 04 '24 14:03 davidfowl

Is this preview 4 only, or can I use it on preview 3?

paulomorgado avatar Mar 04 '24 14:03 paulomorgado

Preview 4

davidfowl avatar Mar 04 '24 14:03 davidfowl

@davidfowl how would adding an external endpoint like AddConnectionString be any different from using

public static IResourceBuilder<TDestination> WithReference<TDestination>(this IResourceBuilder<TDestination> builder, string name, Uri uri)
    where TDestination : IResourceWithEnvironment

paulomorgado avatar Mar 04 '24 16:03 paulomorgado

@davidfowl, does this look like what you're looking for?

/// <summary>
/// Adds a parameter to the distributed application but wrapped in a resource with a URL for use with <see cref="ResourceBuilderExtensions.WithReference{TDestination}(IResourceBuilder{TDestination}, IResourceBuilder{IResourceWithServiceDiscovery})"/>
/// </summary>
/// <param name="builder">Distributed application builder</param>
/// <param name="name">Name of parameter resource.</param>
/// <param name="uri">The uri of the service.</param>
/// <returns>Resource builder for the parameter.</returns>
/// <exception cref="DistributedApplicationException"></exception>
public static IResourceBuilder<IResourceWithServiceDiscovery> AddEndpoint(this IDistributedApplicationBuilder builder, string name, Uri uri)
{
    if (!uri.IsAbsoluteUri)
    {
        throw new InvalidOperationException("The uri for service reference must be absolute.");
    }

    if (uri.AbsolutePath != "/")
    {
        throw new InvalidOperationException("The uri absolute path must be \"/\".");
    }

    var parameterBuilder = builder.AddParameter(
        name: name,
        callback: uri.ToString,
        secret: false,
        connectionString: false);

    parameterBuilder.WithAnnotation(
        new AllocatedEndpointAnnotation(
            name: "http",
            protocol: ProtocolType.Tcp,
            address: uri.Host,
            port: uri.Port,
            scheme: uri.Scheme));

    var surrogate = new ResourceWithServiceDiscoverySurrogate(parameterBuilder.Resource);
    return builder.CreateResourceBuilder(surrogate);
}

where

internal class ResourceWithServiceDiscoverySurrogate(IResource innerResource) : IResourceWithServiceDiscovery
{
    public string Name => innerResource.Name;

    public ResourceAnnotationCollection Annotations => innerResource.Annotations;
}

It kind of looks odd.

I'd like it to be an EndpointReference, but it doesn't look like a good idea.

paulomorgado avatar Mar 04 '24 18:03 paulomorgado

What's odd?

davidfowl avatar Mar 12 '24 00:03 davidfowl

I'm still trying to wrap my head around it.

Is this what you were suggesting?

paulomorgado avatar Mar 12 '24 09:03 paulomorgado

Without the AllocatedEndpointAnnotation part. I think we would handle this resource type specially in WithReference.

davidfowl avatar Mar 12 '24 14:03 davidfowl

I have configuration in one of my Aspire projects to choose between project, container and service hosted somewhere.

I think it would be valuable to get a EndpointReference from that. It should, at least, be a IResourceWithEndpoints.

How would I get an EndpointReference without an AllocatedEndpointAnnotation?

paulomorgado avatar Mar 12 '24 14:03 paulomorgado

I guess need the full picture. Show me how you plan to use this resource.

davidfowl avatar Mar 12 '24 14:03 davidfowl

This is kind of what I have now:

var dependencyEndpointReference = CreateDependencyEndpointReference();

dependent1.WithReference(dependencyEndpointReference);
dependent2.WithReference(dependencyEndpointReference);
dependent3.WithReference(dependencyEndpointReference);

EndpointReference CreateDependencyEndpointReference()
{
    if (builder.Configuration.GetSection("VisionBox:ConneXt:Demo:Settings:Dependency") is { } dependencyConifg && dependencyConifg.Exists())
    {
        switch (DependencyConifg.GetValue<string>("$mode"))
        {
            case "Container":
                if (DependencyConifg.GetSection("Container") is { } DependencyContainerConfig && DependencyContainerConfig.Exists())
                {
                    var DependencyContainer = builder.AddContainer(
                        "Dependency",
                        DependencyContainerConfig.GetValue<string>("Image")!,
                        DependencyContainerConfig.GetValue<string>("Tag") ?? "latest")
                        .WithEndpoint(containerPort: 8080, hostPort: 5012, name: "http", scheme: "http")
                        .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES", "true")
                        .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES", "true")
                        .WithOtlpExporter()
                        .WithEnvironment("OTEL_SERVICE_NAME", "Dependency")
                        .WithReference(DependencyIdpEndpointReference)
                        .ConfigureHttpServerAuth()
                        .ConfigureLogging()
                        ;

                    var DependencyContainerResource = new ResourceWithServiceDiscovery { Name = DependencyDependencyName, Annotations = DependencyContainer.Resource.Annotations };

                    var DependencyContainerEndpointReference = new EndpointReference(DependencyContainerResource, "http");

                    return DependencyContainerEndpointReference;
                }

                break;

            case "Remote":
                if (DependencyConifg.GetSection("Remote") is { } remoteDependencyConfig && remoteDependencyConfig.Exists())
                {
                    var remoteDependencyResource = new ResourceWithServiceDiscovery
                    {
                        Name = DependencyDependencyName,
                        Annotations = [
                            new AllocatedEndpointAnnotation(
                                "http",
                                ProtocolType.Tcp,
                                remoteDependencyConfig.GetValue<string>("Address")!,
                                remoteDependencyConfig.GetValue<int>("Port"),
                                remoteDependencyConfig.GetValue<string>("Scheme")!)
                        ]
                    };

                    var remoteDependencyEndpointReference = new EndpointReference(remoteDependencyResource, "http");

                    return remoteDependencyEndpointReference;
                }

                break;
        }
    }

    throw new InvalidOperationException("Missing Dependency configuration.");
}

paulomorgado avatar Mar 12 '24 14:03 paulomorgado

Seems like to doesn’t need to be an EndpointReference. We just need a common interface between those 2 resource types.

davidfowl avatar Mar 12 '24 14:03 davidfowl

What would that be?

Maybe we need an AllocatedEndpointReference that is the current EndpointReference and allow for other types of EndpointReference.

paulomorgado avatar Mar 12 '24 14:03 paulomorgado

Thinking about this more you might be right that supporting EndpointReference might be a nice thing to do.

var remote = builder.AddUrl("foo", "http://foo.remote.service");

builder.AddProject<Projects.Api>("api")
           .WithReference(remote); // Service discovery behavior

builder.AddProject<Projects.Frontend>("fe")
           .WithEnvironment("REMOTE_URL", remote.Endpoint); // This is an endpoint reference.

The only question in my mind is do we want this to be a parameter resource for publishing or does the same url get used verbatim ?

davidfowl avatar Mar 20 '24 13:03 davidfowl

It might make sense to be a parameter resource for publishing.

If it's not, it can be excluded from publishing.

paulomorgado avatar Mar 20 '24 13:03 paulomorgado

Hi @davidfowl, how are we going to progress with this?

paulomorgado avatar Apr 15 '24 17:04 paulomorgado

At the moment I-m using this:

internal class ExternalHostResource(string name, string host) : Resource(name), IResourceWithEndpoints
{
    public string Host { get; } = host;
}

internal static class ExternalHostResourceBuilderExtensions
{
    public static IResourceBuilder<ExternalHostResource> AddExternalHost(
        this IDistributedApplicationBuilder builder,
        string name,
        string hostname)
    {
        builder.Services.TryAddLifecycleHook<ExternalHostResourceLifecycleHook>();

        return builder.AddResource(new ExternalHostResource(name, hostname))
                .WithInitialState(new CustomResourceSnapshot
                {
                    ResourceType = "Endpoint",
                    State = "Running",
                    Properties = [
                        new (CustomResourceKnownProperties.Source, hostname),
                        new ("Running", KnownResourceStateStyles.Success)
                    ]
                });
    }

    public static IResourceBuilder<ExternalHostResource> AddExternalHost(
        this IDistributedApplicationBuilder builder,
        string name,
        Uri uri,
        bool isProxied = false,
        bool isExternal = true)
        => builder.AddExternalHost(name, uri.Host)
            .WithEndpoint(targetPort: uri.Port, scheme: uri.Scheme, name: uri.Scheme, isProxied: isProxied, isExternal: isExternal);
}

internal sealed class ExternalHostResourceLifecycleHook : IDistributedApplicationLifecycleHook
{
    private readonly ResourceNotificationService notificationService;
    private readonly ResourceLoggerService loggerService;

    public ExternalHostResourceLifecycleHook(ResourceNotificationService notificationService, ResourceLoggerService loggerService)
    {
        this.notificationService = notificationService;
        this.loggerService = loggerService;
    }

internal sealed class ExternalHostResourceLifecycleHook : IDistributedApplicationLifecycleHook
{
    private readonly ResourceNotificationService notificationService;
    private readonly ResourceLoggerService loggerService;

    public ExternalHostResourceLifecycleHook(ResourceNotificationService notificationService, ResourceLoggerService loggerService)
    {
        this.notificationService = notificationService;
        this.loggerService = loggerService;
    }

    public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
    {
        foreach (var resource in appModel.Resources)
        {
            if (resource is ExternalHostResource externalEndpointResource)
            {
                var uris = new List<UrlSnapshot>();

                foreach (var annotation in externalEndpointResource.Annotations)
                {
                    if (annotation is EndpointAnnotation endpointAnnotation)
                    {
                        endpointAnnotation.IsProxied = false;
                        endpointAnnotation.IsExternal = true;

                        if (endpointAnnotation.AllocatedEndpoint is null)
                        {
                            endpointAnnotation.AllocatedEndpoint = new(
                                endpointAnnotation,
                                externalEndpointResource.Host,
                                endpointAnnotation.TargetPort.HasValue
                                    ? endpointAnnotation.TargetPort.GetValueOrDefault()
                                    : new UriBuilder(endpointAnnotation.UriScheme, externalEndpointResource.Host).Uri.Port);
                        }

                        uris.Add(new UrlSnapshot(endpointAnnotation.Name, externalEndpointResource.GetEndpoint(endpointAnnotation.Name).Url, false));
                    }
                }

                if (uris.Count != 0)
                {
                    await notificationService.PublishUpdateAsync(resource, state => state with
                    {
                        Urls = [.. state.Urls, .. uris]
                    });
                }
            }
        }
    }
}


...


var resource = builder.AddExternalHost(name: "hub-service", new Uri("https://host:port/")
    ;

paulomorgado avatar May 15 '24 09:05 paulomorgado

Thanks @paulomorgado!

For the scenario I'm just having I changed ExternalHostResource to implement IResourceWithServiceDiscovery which allows to reference the resource using WithReference.

christiannagel avatar Jul 05 '24 19:07 christiannagel

@christiannagel,

Thanks @paulomorgado!

For the scenario I'm just having I changed ExternalHostResource to implement IResourceWithServiceDiscovery which allows to reference the resource using WithReference.

I'm starting to think that a parameter implementing IResourceWithServiceDiscovery, like ResourceWithConnectionStringSurrogate returned by ParameterResourceBuilderExtensions.AddConnectionString implements IResourceWithConnectionString.

paulomorgado avatar Jul 05 '24 20:07 paulomorgado

I've created a NuGet package to provide the functionality outlined in this issue: ExternalEndpoint.Aspire.Hosting.

The source can be found here.

wertzui avatar Jul 19 '24 10:07 wertzui

I've created a NuGet package to provide the functionality outlined in this issue: ExternalEndpoint.Aspire.Hosting.

The source can be found here.

That's pretty much what I have myself, but I'm thinking this should come from a parameter, like AddConnectionString.

paulomorgado avatar Jul 19 '24 16:07 paulomorgado

I can add another method to add it from a parameter. Then everyone is free to choose.

wertzui avatar Jul 19 '24 17:07 wertzui

1.1.0 now includes another extension Method to add an External Endpoint from a parameter.

wertzui avatar Jul 22 '24 07:07 wertzui