Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow specifying an Event Hub to use as the default EntityPath for namespace connection string #7105

Open
wants to merge 22 commits into
base: main
Choose a base branch
from

Conversation

oising
Copy link
Contributor

@oising oising commented Jan 14, 2025

Description

  • Add new extension method WithDefaultEntity(string)

Only one EventHub can be specified as the default entity path. The entity path can still be overridden by settings in integrations. This method can be called multiple times with the same name.

  • Add test to validate EntityPath is in connection string, and is well-formed
  • Add test to validate setting multiple default entities is invalid
  • Updated playground to remove settings usage (but left setting usage in other tests)
  • Updated documentation in README.md for integrations to mention new functionality

An exception is thrown early if WithDefaultEntity sees that a hub has already been flagged.

Manual testing (playground project):

  • Tested with emulator
  • Tested with local dev, deploy remote resources
  • Tested via azd up full deployment to azure

Notes

I cleaned up the validation logic and tried to simplify it in the base client shared component EventHubsComponent.cs in the method EnsureConnectionStringOrNamespaceProvided. I also fixed an edge case bug that already existed for parsing. I added #define clauses to the playground for disabling the emulator to make it easier to test. There are also some #define conditionals in the projects to allow using AzureCli credential source, but functionally the playground project as it stands is unchanged.

As the FQNS & token credential method doesn't work with docker and the emulator, I couldn't add local unit testing. That said, all scenarios were fully tested, and the code that extracts the event hub name hint for the FQNS method is shared among all five client types, so I'm confident it's solid.

For those not super familiar with the quirks of event hub connection strings, this PR ensures that the SAS-key style connection string has the EntityPath keyword set if WithDefaultEntity(string hubName) is called:

i.e. Endpoint=sb://localhost:57195;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;EntityPath=hub

For the FQNS (Uri) & Token Credential connection, this PR stashes a hint in the FQNS (fully qualified namespace) Uri via a QueryString parameter:

https://foobar.servicebus.azure.net:443/?EntityPath=hub

The client integration packages will extract this hint from the Url and assign it to the client's settings.EventHubName if it is found to be unset, thus using the prior tested code path of the settings callback.

Fixes #7093

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
      • If yes, did you have an API Review for it?
        • Yes
        • No
      • Did you add <remarks /> and <code /> elements on your triple slash comments?
        • Yes
        • No
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
      • If yes, have you done a threat model and had a security review?
        • Yes
        • No
    • No
  • Does the change require an update in our Aspire docs?

@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label Jan 14, 2025
@oising
Copy link
Contributor Author

oising commented Jan 14, 2025

If we can agree on tihs, I'll send a PR for the same thing in Azure Service Bus for queue/topic in EntityPath, or I can put it in this one

@davidfowl
Copy link
Member

Does this also work when deployed?

@oising
Copy link
Contributor Author

oising commented Jan 15, 2025

Does this also work when deployed?

It's funny you should ask -- I had just deployed the playground project to test, and I found a bug. I'll report back when fixed.

@oising
Copy link
Contributor Author

oising commented Jan 15, 2025

Well, sh*t.

It turns out that there's no way to officially pass the event hub name when using the FQNS + TokenCredential for a real Event Hub. It's fine for the emulator, because the non-tokencredential connection string allows you to pass an EntityPath. I had mistakely believed you could include the hub name as part of the Uri in the non-emulator case.

One way to do it would be to add Aspire-specific handling of a FQNS that includes the hub name as a query string, e.g. https://foobar.servicebus.windows.net?EntityPath=hubName. But this would be setting a new precedent for Aspire I think, i.e. special casing parsing of connection strings in the Aspire clients. As for my thoughts on the matter, at first it felt off (and it does a little still) but it does not break compatibility in the connection strings for non-Aspire wrapped clients.

The other way might be to emit an environment variable, but then this needs even more thought for scoping/namespacing it.
Thoughts?

@davidfowl @mitchdenny @eerhardt

@davidfowl
Copy link
Member

We can use our own connection string format that the client understands. This is what openai does

@sebastienros
Copy link
Member

We can use our own connection string format that the client understands. This is what openai does

What about setting the ENV that will be bound to the EntityPath property in the settings on the client instead? I am doing this in a PR for openai to split the connection string from the models/deployments.

Another comment on this PR, late one sorry, I personally prefer calling a helper like WithEntityPath(string?) instead of setting a flag on any hub. I assume this way you could even set it if you don't create the hubs at the host level too?

@oising
Copy link
Contributor Author

oising commented Jan 16, 2025

We can use our own connection string format that the client understands. This is what openai does

What about setting the ENV that will be bound to the EntityPath property in the settings on the client instead? I am doing this in a PR for openai to split the connection string from the models/deployments.

Another comment on this PR, late one sorry, I personally prefer calling a helper like WithEntityPath(string?) instead of setting a flag on any hub. I assume this way you could even set it if you don't create the hubs at the host level too?

Hi @sebastienros - I had thought of that in an earlier point in time, but given that there are five different clients for eventhub, we'd have to inject five env vars to bind to all client settings. Also, they would take precedence over client appsettings which might be at best surprising and at worst possibly breaking, right? If we use a custom connection string then it's just a hint and the regular configuration should take precedence, if provided.

As for the WithEntityPath idea, I guess so? What does everyone else think?

@davidfowl
Copy link
Member

I prefer a single connection string… This brings back our connection info discussion 🙂

@oising
Copy link
Contributor Author

oising commented Jan 16, 2025

@davidfowl:

I prefer a single connection string… This brings back our connection info discussion 🙂

Alrighty -- so I'll add a query path hint in the FQNS. And are we good with having WithEntityPath(string) ? It would just set IsDefaultEntity on the model, and I'd drop the visibility to internal on the field. I take it we don't want to start down the "two ways to do everything" path.

@davidfowl
Copy link
Member

before making the change, can we compare both API samples?

@oising
Copy link
Contributor Author

oising commented Jan 16, 2025

@davidfowl

before making the change, can we compare both API samples?

A

var eventHub = builder.AddAzureEventHubs("eventhubns")
    .RunAsEmulator()
    .WithHub("hub1")
    .WithHub("hub2", h => h.IsDefaultEntity = true);

vs

B

var eventHub = builder.AddAzureEventHubs("eventhubns")
    .RunAsEmulator()
    .WithHub("hub1")
    .WithHub("hub2")
    .WithDefaultEntity("hub2"); // WithEntityPath ?

At first you might think why not WithDefaultHub? And the reasoning is if we bring the same API to service bus, you'd need WithDefaultQueue and WithDefaultTopic, which would be mutually exclusive. Using Entity is clearer, IMO. I prefer WithDefaultEntity over WithEntityPath because the former is about the resource and the latter is about connection strings, but the implication should be clear.

@sebastienros
Copy link
Member

I am fine with both approaches, my preference being an extension method (whatever name makes more sense), or an argument on the AddAzureEventHubs(). The value could then be stored in the event hubs resource. We may don't even have to deal with invalid state, in case you want to set the value with a hub name that doesn't need to be defined on the resource (like you can do today).

Does my suggestion to generate a separate ENV to flow to the settings make sense? It would be prefixed with the resource name to bind to the correct settings.

@oising
Copy link
Contributor Author

oising commented Jan 16, 2025

@sebastienros

Does my suggestion to generate a separate ENV to flow to the settings make sense? It would be prefixed with the resource name to bind to the correct settings.

Yes, I thought I had mentioned this above. It does make sense from a mechanical perspective, but like I had said, there are five different clients that ship in the integration and you'd have to inject an ENV var for all of them since you cannot know in the hosting side which clients will be used. Personally, I prefer to inline a hint in the connectionstring. It keeps the scope narrower. Although injecting environment variables means we could probably get away without modifying the clients, it might cause unexpected behaviour when someone tries to specify/override the event hub in appsetting.json -- the env var will always win for configuration.

@sebastienros
Copy link
Member

when someone tries to specify/override the event hub in appsetting.json

Isn't it of higher priority than ENVs? And then the configure callback has the most priority.

there are five different clients that ship in the integration

I checked the code as I am not familiar with it, I see the issue is each component has its own settings class, but the EventHubName is set on the base one.

Would something like this work:

    protected override void BindSettingsToConfiguration(AzureMessagingEventHubsBufferedProducerSettings settings,
        IConfiguration configuration)
    {
        configuration.Bind((AzureMessagingEventHubsSettings)settings);
        configuration.Bind(settings);
    }

If not set in component specific section, it would take the one from the apphost config. configureSettings would still work. Bonus, now you can even define the defaults for any component.

@oising
Copy link
Contributor Author

oising commented Jan 16, 2025

when someone tries to specify/override the event hub in appsetting.json

Isn't it of higher priority than ENVs? And then the configure callback has the most priority.

Nope. AppSettings are loaded first, then environment. Last one wins. Priority is effectively:

image

https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration

@oising oising marked this pull request as draft January 16, 2025 22:14
@davidfowl
Copy link
Member

What happens when you use this feature with azure functions, none of this applies right?

cc @captainsafia

@oising
Copy link
Contributor Author

oising commented Jan 17, 2025

@davidfowl

What happens when you use this feature with azure functions, none of this applies right?

cc @captainsafia

It only improves the scenario for Functions that use the SASKey style connection string that officially supports EntityPath. Existing Function bindings that are explicit about the Event Hub name will override this, so I don't forsee any breaking behaviour there. For those bound via an FQNS + managed identity, I don't touch it since we don't have control over the client to parse out the hint.

image

@oising
Copy link
Contributor Author

oising commented Jan 17, 2025

Ooops, I accidentally checked in overrides in the playground to use AzureCliCredential for my local testing. I'll remove them in the next commit.

@oising oising marked this pull request as ready for review January 18, 2025 00:04
update note for README about WithDefaultEntity
@oising
Copy link
Contributor Author

oising commented Jan 18, 2025

Ready for review @davidfowl

I've updated the PR description with more details to help with the review.

@oising oising requested a review from davidfowl January 18, 2025 15:09
/// <param name="builder">The Azure Event Hubs resource builder.</param>
/// <param name="name">The name of the Event Hub.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<AzureEventHubsResource> WithDefaultEntity(this IResourceBuilder<AzureEventHubsResource> builder, [ResourceName] string name)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we'd want to use this without having WithHub? Example: connecting to an existing azure resource without the need to call WithHub for each of the existing ones.

After all WithHub("foo") doesn't ensure it actually exists on the resource either. Almost like we are asking users twice in that case.

And related to this, as a user it might be nice to be able to call it twice, the second being one winning. That would mean store the hub name in the resource itself, instead of having a bool on each resource to verify the integrity (might be simpler too).

Is "Default" in DefaultEntity something concrete? Why not called WithEntityPath()? (ignore me if I already asked ;) )

Copy link
Contributor Author

@oising oising Jan 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we'd want to use this without having WithHub? Example: connecting to an existing azure resource without the need to call WithHub for each of the existing ones.

When you mentioned this first, I had to think about it and I don't believe there's a scenario where we'd want to set a default entity path without having any associated hubs in the resource. If we want to use a pre-existing resource that already has hubs, we would use AddConnectionString. Anything that uses AddAzureEventHubs needs to provide at least one hub, or health checks will fail. I understand it is possible to want to create a namespace without hubs and have hubs created dynamically (i.e. some dapr patterns encourage dthis but you have to grant it management permissions.)

After all WithHub("foo") doesn't ensure it actually exists on the resource either. Almost like we are asking users twice in that case.

The resource itself doesn't neccessarily exist either -- all this does is mutate the model. At runtime, it's either projected into the emulator config, or emitted in the manifest. I don't think this is our concern?

And related to this, as a user it might be nice to be able to call it twice, the second being one winning. That would mean store the hub name in the resource itself, instead of having a bool on each resource to verify the integrity (might be simpler too).

I'm not sure how this scenario ("nice to be able to call it twice") would arise beyond a mistake?

Is "Default" in DefaultEntity something concrete? Why not called WithEntityPath()? (ignore me if I already asked ;) )

I say "default" because you can override it in the client settings.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebastienros -- you're right -- it does make sense to allow someone to call it multiple times with different hubs, with last one winning. I've had a head cold all week and for some reason I didn't quite get it, lol. I'll fix that now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm renaming WithDefaultEntity to WithEntityPath since this now opens up:

var ns = builder.AddAzureEventHubs("eventhubns")
    .RunAsEmulator()
    .WithHub("hub")
    .WithHub("hub2");
    
builder.AddProject<Projects.EventHubsConsumerA>("consumera")
    .WithReference(ns.WithEntityPath("hub"));
    
builder.AddProject<Projects.EventHubsConsumerB>("consumerb")
    .WithReference(ns.WithEntityPath("hub2"));
 
 // ...

@davidfowl @eerhardt ^ ?

Copy link
Contributor Author

@oising oising Jan 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although ... this approach could be troublesome due to it mutating the parent. Example:

ar ns = builder.AddAzureEventHubs("eventhubns")
    .RunAsEmulator()
    .WithHub("hub")
    .WithHub("hub2");

var hub = ns.WithEntityPath("hub");
var hub2 = ns.WithEntityPath("hub2");

builder.AddProject<Projects.EventHubsConsumerA>("consumera")
    .WithReference(hub);
    
builder.AddProject<Projects.EventHubsConsumerB>("consumerb")
    .WithReference(hub2);

So here, both get pointed at hub2. Oops. What are your thoughts? I like that With vs Add has well defined semantics. Is it clear enough though to dissuade people doing things like this? Maybe this isn't the only API that could fall foul to this, so it's an accepted tradeoff?

Copy link
Contributor Author

@oising oising Jan 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more I think about it, I think it's okay. There have to be many places where people might assign results from With* extensions and expect different semantics. This is why the distinction exists, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is getting at the comment @davidfowl made here: #7105 (comment). The problem we are running into is that EventHub "Hub" instances aren't a Resource in Aspire. Which means they aren't referencable/addressable. So that's the struggle with the above code. If instead it looked more like a database in typical DBMS systems (like SqlServer or Postgres) the above code would be:

IResourceBuilder<AzureEventHubsResource> ns = builder.AddAzureEventHubs("eventhubns")
    .RunAsEmulator();

IResourceBuilder<AzureEventHubsHubResource> hub = ns.AddHub("hub");
IResourceBuilder<AzureEventHubsHubResource> hub2 = ns.AddHub("hub2");

builder.AddProject<Projects.EventHubsConsumerA>("consumera")
    .WithReference(hub);
    
builder.AddProject<Projects.EventHubsConsumerB>("consumerb")
    .WithReference(hub2);

ConsumerA would get a connection string for eventhubns?EntityPath=hub and ConsumerB would get a connection string for eventhubns?EntityPath=hub2.

This feels more natural and consistent with the rest of Aspire.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely, and I completely agree. I had another PR open where I was creating child resources that could be deep-linked in this style, but it stalled because of refactoring work by the team in main. The thing is that we have obsolete APIs for AddEventHub that mutate the parent, so a stop-gap solution would be nice until the obsoleted API can be removed and then an AddHub added that does what you show. I guess the question is, do we go with this intermediary solution or do we throw it away and target 10.0 with AddHub. That's for you guys to decide.

@davidfowl
Copy link
Member

The thing that feels strange about this to be is that fact that we would mutate the original resource to have a default entity instead of making a child resource (like we had before), which could be a way to "deep link" (to use @mitchdenny's words) into an event hub instance. That way you aren't limited to one and there's no strange default behavior to implement.

@oising
Copy link
Contributor Author

oising commented Jan 23, 2025

@davidfowl

The thing that feels strange about this to be is that fact that we would mutate the original resource to have a default entity instead of making a child resource (like we had before), which could be a way to "deep link" (to use @mitchdenny's words) into an event hub instance. That way you aren't limited to one and there's no strange default behavior to implement.

I already started that PR before you guys got entangled in internal debate and switched to this model, so then I spent my time on this instead. This is frustrating as a contributor who isn't paid to do this.

This is just a hint to the model which gets projected to emulator config, or into bicep via the manifest. I don't see a problem with the namespace (the primary resource) having a "default entity path" hint, since it can be overridden later. In your own words, "With" mutates the resource model, and "Add" creates new child resources. Just go with this now, you're already deprecated v1 of the AddEventHub calls, remove them in v10, then add them back in v11 with child resources for event hubs. Deep linking can still happen without interfering with having a default entity path on the namespace resource.

update:

"...making a child resource (like we had before)" -- you guys never had child resources for azure event hub (nor service bus.) You had AddEventHub calls that mutated the namespace. This (welcome) distinction between With and Add is good, but it's new.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
community-contribution Indicates that the PR has been added by a community member
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow specifying a default EntityPath for Azure Event Hubs / Azure Service Bus connection string
6 participants