Skip to content

Instantly share code, notes, and snippets.

@jsquire
Last active October 19, 2020 21:30
Show Gist options
  • Save jsquire/13742f53bf642ec67baf9491fc844cff to your computer and use it in GitHub Desktop.
Save jsquire/13742f53bf642ec67baf9491fc844cff to your computer and use it in GitHub Desktop.
Follow-Up: Client creation for application deployments across environments

Follow-Up: Client creation for application deployments across environments

This serves to illustrate some of the ideas surfaced during discussion of the options outlined in the Client creation for application deployments across environments and assumed familiarity with content of that document.

To ensure focus continues to be aligned, the goals and baseline illustrations have been brought forward for convenience.

Goal

Allow developers working with the Azure Messaging libraries to instantiate client types using a code path that adapts to changes in configuration without requiring code to be recompiled. Client creation between different environments should have minimal friction and require only simple, straightforward, and safe code that is appropriate for applications.

To enable scenarios where an operations team has ownership of deployment and configuration for host environments, ensure configuration may be updated independent of an application by individuals unfamiliar with the Azure Messaging services. Support audit trails and other change management processes.

Non-Goals

  • Encourage use of connection strings as a general-purpose configuration system; developers should be guided towards the use of more robust and general purpose configuration systems as best practice.

  • Overcome an Azure portal experience that is not ideal for developers; the areas where the portal experience causes friction for developers, should be fixed within the portal. For example, the core concepts for the resource such as the fully-qualified namespace and entity name should be displayed prominently and be easily discoverable for developers.

  • Support the messaging clients consuming structured configuration in its raw form; the responsibility for understanding and parsing configuration for the application into the set of information needed by the clients should remain an application responsibility.

  • Substitute for Azure.Identity as the responsible party for authentication; authentication is an orthogonal domain which has inherent security considerations. In the interest of security, developers should be aware of authentication and explicitly express their intent. The messaging clients should not make implicit decisions on behalf of the user with respect to authentication.

Selected Approach: Parser with EventHubsSharedAccessKeyCredential (stand-alone)

This serves to illustrate a refinement of Option 3 from our recent discussion, with the addition of a parser to help consume the connection string. Unlike the option on which it is based, the EventHubsSharedAccessKeyCredential is specific to the client library.

Note: This version of the EventHubsSharedAccessKeyCredential follows the recommended SDK guidelines that it not derive from TokenCredential to avoid potentially passing it to other SDK clients. Because of this, it requires a dedicated constructor overload.

public EventHubProducerClient CreateClient()
{
    var connectionString = Environment.GetEnvironmentVariable("EventHubsConnectionString");
    var parseResult = EventHubsConnectionStringParser.Parse(connectionString);
    
    if (!string.IsNullOrEmpty(parseResult.SharedAccessSignature))
    {
        var credential = new EventHubsSharedAccessKeyCredential(parseResult.SharedAccessSignature);
        
        return new EventHubProducerClient(
            parseResult.FullyQualifiedNamespace, 
            parseResult.EventHubName, 
            credential);
    }
    
    if (!string.IsNullOrEmpty(parseResult.SharedAccessKey))
    {
        var credential = new EventHubsSharedAccessKeyCredential(parseResult.SharedAccessKeyName, parseResult.SharedAccessKey);
        
        return new EventHubProducerClient(
            parseResult.FullyQualifiedNamespace, 
            parseResult.EventHubName, 
            credential);
    }
    
    return new EventHubProducerClient(
        parseResult.FullyQualifiedNamespace, 
        parseResult.EventHubName,
        new DefaultAzureCredential());
}

// REFERENCE:
//   The following are the intended high-level structures for connection string parsing.
//
public class EventHubsConnectionStringProperties
{
    public string FullyQualifiedNamespace { get; }
    public Uri Endpoint { get; set; }
    public string? EventHubName { get; set; }
    public string? SharedAccessSignature { get; set; }
    public string? SharedAccessKeyName { get; set; }
    public string? SharedAccessKey { get; set; }
    
    public static EventHubsConnectionStringProperties Parse(string connectionString);
}

This scenario could also be streamlined by using the existing connection string-based constructor overload and bypassing the EventHubsSharedKeyCredential.

public EventHubProducerClient CreateClient()
{
    var connectionString = Environment.GetEnvironmentVariable("EventHubsConnectionString");
    var parseResult = EventHubsConnectionStringParser.Parse(connectionString);
    
    if (!string.IsNullOrEmpty(parseResult.SharedAccessSignature) || !string.IsNullOrEmpty(parseResult.SharedAccessKey))
    {
        return new EventHubProducerClient(connectionString);
    }
    
    return new EventHubProducerClient(
        parseResult.FullyQualifiedNamespace, 
        parseResult.EventHubName,
        new DefaultAzureCredential());
}

This approach is appealing when parsing only the basic elements allowed by a connection string, but becomes more cumbersome in the case where additional client configuration is needed. In this scenario, we believe that structured configuration via JSON or other string-based format would be preferable.

public EventHubProducerClient CreateClient()
{
    var connectionString = Environment.GetEnvironmentVariable("EventHubsConnectionString");
    var eventHubName = Environment.GetEnvironmentVariable("EventHubName");
    
    // Additional optional configuration is demonstrated to illustrate the 
    // difference in approach needed when moving beyond only configuring authentication.
    
    var loadBalanceEnvironment = Environment.GetEnvironmentVariable("EventHubsLoadBalancerStrategy");
    
    if (!Enum.TryParse<LoadBalancingStrategy>(loadBalanceEnvironment, true, out var loadBalanceStrategy))
    {
        throw new ArgumentException("Invalid load balancing config", "Environment.EventHubsLoadBalancerStrategy");
    }
    
    if (!int.TryParse(Environment.GetEnvironmentVariable("EventHubsPrefetch"), out var prefetchCount))
    {
        throw new ArgumentException("Invalid prefetch config", "Environment.EventHubsPrefetch");
    }
    
    var clientOptions = new EventHubProcessorClientOptions
    {
        PrefetchCount = prefetchCount,
        LoadBalancingStrategy = loadBalanceStrategy
    }
    
    // Parse the connection string and create the resulting client.
    
    var parseResult = EventHubsConnectionStringParser.Parse(connectionString);
    
    if (!string.IsNullOrEmpty(parseResult.SharedAccessSignature))
    {
        var credential = new EventHubsSharedAccessKeyCredential(parseResult.SharedAccessSignature);
        
        return new EventHubProducerClient(
            parseResult.FullyQualifiedNamespace, 
            eventHubName, 
            credential,
            clientOptions);
    }
    
    if (!string.IsNullOrEmpty(parseResult.SharedAccessKey))
    {
        var credential = new EventHubsSharedAccessKeyCredential(parseResult.SharedAccessKeyName, parseResult.SharedAccessKey);
         
        return new EventHubProducerClient(
            parseResult.FullyQualifiedNamespace, 
            eventHubName, 
            credential,
            clientOptions);
    }
    
    return new EventHubProducerClient(
        parseResult.FullyQualifiedNamespace, 
        eventHubName,
        new DefaultAzureCredential(),
        clientOptions);
}

Creating a connection string from disparate parts is supported via the same properties that are used when parsing.

public EventHubProducerClient CreateClient()
{
    var properties = new EventHubsConnectionStringProperties
    {
        Endpoint = new Uri(Environment.GetEnvironmentVariable("EventHubsEndpoint")),
        EventHubName = Environment.GetEnvironmentVariable("EventHubName"),
        SharedAccessSignature = Environment.GetEnvironmentVariable("EventHubsSAS")
    };

    return new EventHubProducerClient(properties.ToConnectionString());
}

Baseline: Creating a client using the current experience

Using the local identity

This approach eschews the use of connection strings for local development in favor of the local identity. For developers working locally, this would often be the identity associated with Visual Studio, Visual Studio Code, or the Azure CLI.

For hosted environments, such as the traditional Development, UAT, and Production segmentation, the local credential is most likely to be the Managed Service Identity of the host or a specific dedicated Azure Active Directory principal.

public EventHubProducerClient CreateClient()
{
    // The Azure.Identity library holds responsibility for determining the appropriate identity
    // for the local environment.  Validation of parameters is performed by the client.
    
    var fqns = Environment.GetEnvironmentVariable("EventHubsNamespace");
    var eventHub = Environment.GetEnvironmentVariable("EventHub");
    var credential = new DefaultAzureCredential();
    
    return new EventHubProducerClient(fqns, eventHub, credential);
}

Using environment variables

Another common approach is to break individual configuration items out into dedicated environment variables. This avoids the need for any complex parsing, is container-friendly, and has minimal friction across heterogeneous platforms. Using this approach each item constrained to a small and simple scope to make it easier to spot errors during review.

public EventHubProducerClient CreateClient()
{
    // Prefer the connection string; validation is performed by the client.
    
    var connectionString = Environment.GetEnvironmentVariable("EventHubsConnectionString");
    
    if (!string.IsNullOrEmpty(connectionString))
    {
        return new EventHubProducerClient(connectionString);
    }
    
    // Use the expanded form with a token credential; validation is performed by the client.
    
    var fqns = Environment.GetEnvironmentVariable("EventHubsNamespace");
    var eventHub = Environment.GetEnvironmentVariable("EventHub");
    var credential = new DefaultAzureCredential();
    
    return new EventHubProducerClient(fqns, eventHub, credential);
}

Using structured configuration

One common approach is to use structured data, such as JSON, XML, or YAML in order to enable key/value pair items. This allows configuration to be grouped into a single, cohesive unit while keeping each item constrained to a small and simple scope to make it easier to spot errors during review.

public EventHubProducerClient CreateClient(string jsonConfiguration)
{
    var config = JsonSerializer.Deserialize<MyAppConfiguration>(jsonConfiguration);
    
    // Prefer the connection string; validation is performed by the client.
    
    if (!string.IsNullOrEmpty(config.ConnectionString))
    {
        return new EventHubProducerClient(config.ConnectionString);
    }
    
    // Use the expanded form with a token credential; validation is performed by the client.
    
    var credential = new DefaultAzureCredential();
    return new EventHubProducerClient(config.EventHubsNamepace, config.EventHub, credential);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment