Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CommunityToolkit.Aspire.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@
<Project Path="src/CommunityToolkit.Aspire.Hosting.DbGate/CommunityToolkit.Aspire.Hosting.DbGate.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.Deno/CommunityToolkit.Aspire.Hosting.Deno.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.Flagd/CommunityToolkit.Aspire.Hosting.Flagd.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.Gcp/CommunityToolkit.Aspire.Hosting.Gcp.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.Golang/CommunityToolkit.Aspire.Hosting.Golang.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.Java/CommunityToolkit.Aspire.Hosting.Java.csproj" />
Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@
<PackageVersion Include="ModelContextProtocol" Version="0.3.0-preview.1" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="0.3.0-preview.1" />
<PackageVersion Include="KurrentDB.Client" Version="1.2.0" />
<PackageVersion Include="Google.Cloud.Storage.V1" Version="4.13.0" />
<PackageVersion Include="Google.Cloud.PubSub.V1" Version="3.27.0" />
</ItemGroup>
<ItemGroup Label="Testing">
<!-- Testing packages -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting" />
<PackageReference Include="Google.Cloud.Storage.V1" />
<PackageReference Include="Google.Cloud.PubSub.V1" />
</ItemGroup>

</Project>
60 changes: 60 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.Gcp/DevCertsExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;

#pragma warning disable ASPIRECERTIFICATES001

namespace CommunityToolkit.Aspire.Hosting.Gcp;

internal static class DevCertsExtensions
{
internal static IResourceBuilder<T> WithDevCertificates<T>(this IResourceBuilder<T> builder, string devCertPath, string pemFileName, string keyFileName) where T : ContainerResource
{
return builder.WithContainerFiles(devCertPath, (context, ct) =>
{
var certificate = context.ServiceProvider.GetRequiredService<IDeveloperCertificateService>().Certificates[0];
var certPem = PemEncoding.Write("CERTIFICATE", certificate.RawData);
char[]? keyPem = null;

using (var rsa = certificate.GetRSAPrivateKey())
{
if (rsa != null)
{
var keyBytes = rsa.ExportPkcs8PrivateKey();
keyPem = PemEncoding.Write("PRIVATE KEY", keyBytes);
goto end;
}
}

using (var ecdsa = certificate.GetECDsaPrivateKey())
{
if (ecdsa != null)
{
var keyBytes = ecdsa.ExportPkcs8PrivateKey();
keyPem = PemEncoding.Write("PRIVATE KEY", keyBytes);
}
}
end:
if (keyPem is null)
{
throw new InvalidOperationException("Dev Certificate does not contain a private key.");
}

ContainerFileSystemItem[] files = [
new ContainerFile
{
Contents = new string(certPem),
Name = pemFileName
},
new ContainerFile
{
Contents = new string(keyPem),
Name = keyFileName
}
];
return Task.FromResult<IEnumerable<ContainerFileSystemItem>>(files);
});
}
}
12 changes: 12 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.Gcp/Gcs/GcsBucketResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Aspire.Hosting.ApplicationModel;

namespace CommunityToolkit.Aspire.Hosting.Gcp.Gcs;

public class GcsBucketResource(string resourceName, GcsResource gcs, ReferenceExpression? bucketName = null): Resource(resourceName), IResourceWithParent<GcsResource>, IResourceWithWaitSupport
{
public ReferenceExpression BucketNameParameter { get; } = bucketName ?? ReferenceExpression.Create($"{resourceName}");

public GcsResource Parent { get; } = gcs;

// TODO: add connection string properties with different formats
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace CommunityToolkit.Aspire.Hosting.Gcp.Gcs;

internal static class GcsEmulatorContainerImageTags
{
public const string Image = "fsouza/fake-gcs-server";
public const string Tag = "1.52.2";
}
39 changes: 39 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.Gcp/Gcs/GcsResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Aspire.Hosting.ApplicationModel;
using Google.Cloud.Storage.V1;
using System.Diagnostics.CodeAnalysis;

namespace CommunityToolkit.Aspire.Hosting.Gcp.Gcs;

public class GcsResource(string name, ParameterResource projectId)
: ContainerResource(name), IResourceWithConnectionString
{
internal const string InitializationPath = "/data";
private const string EndpointName = "https";
internal const int TargetPort = 4443;

internal List<GcsBucketResource> Buckets { get; } = [];
public ParameterResource ProjectId { get; } = projectId;

private StorageClient? _client;

internal async ValueTask<StorageClient> GetClientAsync(CancellationToken ct = default)
{
if (_client is not null)
{
return _client;
}

var builder = new StorageClientBuilder
{
BaseUri = $"{Endpoint.Url}/storage/v1/"
};
_client = await builder.BuildAsync(ct);
return _client;
}

[field: AllowNull, MaybeNull]
public EndpointReference Endpoint => field ??= new EndpointReference(this, EndpointName);

public ReferenceExpression ConnectionStringExpression =>
ReferenceExpression.Create($"{Endpoint.Property(EndpointProperty.Scheme)}://{Endpoint.Property(EndpointProperty.HostAndPort)}");
}
149 changes: 149 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.Gcp/Gcs/GcsResourceExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Google;
using Google.Cloud.Storage.V1;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using System.Net;

namespace CommunityToolkit.Aspire.Hosting.Gcp.Gcs;

public static class GcsResourceExtensions
{
public static IResourceBuilder<GcsResource> AddGcs(this IDistributedApplicationBuilder builder, [ResourceName] string name, IResourceBuilder<ParameterResource> projectId, string certPath, string? initDataDirectory = null)
{
var gcs = new GcsResource(name, projectId.Resource);

var gcsBuilder = builder.AddResource(gcs)
.WithImage(GcsEmulatorContainerImageTags.Image, GcsEmulatorContainerImageTags.Tag)
.WithHttpsEndpoint(name: "https", targetPort: GcsResource.TargetPort, port: GcsResource.TargetPort)
.WithArgs("-external-url", $"https://localhost:{GcsResource.TargetPort}", "-public-host", "localhost", "-cert-location", "/app/cert/localhost.pem", "-private-key-location", "/app/cert/localhost.key")
.WithDevCertificates("/app/cert/", "localhost.pem", "localhost.key")
.WithHttpHealthCheck("/storage/v1/b", endpointName: "https")
.WithEnvironment("GCS_PROJECT_ID", projectId)
.WithIconName("Cloud");

if (initDataDirectory is not null)
{
gcsBuilder.WithBindMount(initDataDirectory, GcsResource.InitializationPath, isReadOnly: false);
}

return gcsBuilder;
}

public static IResourceBuilder<GcsBucketResource> AddBucket(this IResourceBuilder<GcsResource> gcsBuilder, string bucketResourceName, ReferenceExpression? bucketName = null)
{
var gcs = gcsBuilder.Resource;
var bucketResource = new GcsBucketResource(bucketResourceName, gcs, bucketName);
var bucketBuilder = gcsBuilder.ApplicationBuilder.AddResource(bucketResource);
gcs.Buckets.Add(bucketResource);
bucketBuilder.WithInitialState(new CustomResourceSnapshot
{
ResourceType = "Bucket",
CreationTimeStamp = DateTime.UtcNow,
State = KnownResourceStates.NotStarted,
Properties = []
}).OnInitializeResource(static async (bucketResource, initEvent, ct) =>
{
var log = initEvent.Logger;
var eventing = initEvent.Eventing;
var notification = initEvent.Notifications;
var services = initEvent.Services;
var bucketName = await bucketResource.BucketNameParameter.GetValueAsync(ct);
if (bucketName is null)
{
log.LogError("Couldn't allocate bucket since no name provided! {bucketResourceName}", bucketResource.Name);
await notification.PublishUpdateAsync(bucketResource,
snapshot => snapshot with { State = KnownResourceStates.FailedToStart });

return;
}

var gcs = bucketResource.Parent;
await notification.PublishUpdateAsync(bucketResource, snapshot =>
snapshot with { State = KnownResourceStates.Waiting });
await notification.WaitForResourceHealthyAsync(gcs.Name, ct);
await eventing.PublishAsync(new BeforeResourceStartedEvent(bucketResource, services), ct);

StorageClient client;
try
{
client = await gcs.GetClientAsync(ct);
}
catch (Exception ex)
{
log.LogError(ex, "Failed to get storage client for gcs: {gcsResourceName}", bucketResource.Parent.Name);
await notification.PublishUpdateAsync(bucketResource, snapshot =>
snapshot with { State = KnownResourceStates.FailedToStart });
throw;
}

try
{
log.LogInformation("Creating bucket: {bucketName} for resource: {bucketResourceName}", bucketName,
bucketResource.Name);
await client.CreateBucketAsync(await gcs.ProjectId.GetValueAsync(ct), bucketName,
cancellationToken: ct);
await notification.PublishUpdateAsync(bucketResource, snapshot =>
snapshot with { State = KnownResourceStates.Running });
}
catch (GoogleApiException e) when (e.HttpStatusCode == HttpStatusCode.Conflict)
{
log.LogInformation("Bucket: {bucketName} for resource: {bucketResourceName} already preinitialized", bucketName, bucketResource.Name);
await notification.PublishUpdateAsync(bucketResource, snapshot =>
snapshot with { State = KnownResourceStates.Running });
}
catch (Exception ex)
{
log.LogError(ex, "Failed to create bucket: {bucketName}", bucketResource.Name);
await notification.PublishUpdateAsync(bucketResource, snapshot =>
snapshot with { State = KnownResourceStates.FailedToStart });
throw;
}
}).WithIconName("CloudArchive");

var checkKey = $"{bucketResource.Name}_check";
bucketBuilder.ApplicationBuilder.Services.AddHealthChecks().AddAsyncCheck(
checkKey,
async ct =>
{
var client = await gcs.GetClientAsync(ct);
var bucketName = await bucketResource.BucketNameParameter.GetValueAsync(ct);
try
{
await client.GetBucketAsync(bucketName, cancellationToken: ct);
return HealthCheckResult.Healthy();
}
catch (GoogleApiException e) when (e.HttpStatusCode == HttpStatusCode.NotFound)
{
return HealthCheckResult.Unhealthy("bucket is not created yet");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(ex.Message, ex);
}
});

bucketBuilder.WithHealthCheck(checkKey);

return bucketBuilder;
}

public static EndpointReference GetEndpoint(this IResourceBuilder<GcsResource> gcsBuilder)
{
return gcsBuilder.Resource.Endpoint;
}

public static IResourceBuilder<T> WithEnvironment<T>(this IResourceBuilder<T> builder, string environmentName,
IResourceBuilder<GcsResource> gcs) where T : IResourceWithEnvironment
{
return builder.WithEnvironment(environmentName, gcs.GetEndpoint());
}

public static IResourceBuilder<T> WithEnvironment<T>(this IResourceBuilder<T> builder, string environmentName,
IResourceBuilder<GcsBucketResource> bucketResource) where T : IResourceWithEnvironment
{
return builder.WithEnvironment(environmentName, bucketResource.Resource.BucketNameParameter);
}
}
27 changes: 27 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.Gcp/Gcs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# CommunityToolkit.Aspire.Hosting.Gcp.Gcs
Provides extension methods and resource definitions for a .NET Aspire AppHost to configure Gcs and buckets.

## Usage Example
In the AppHost.cs file of your AppHost project, register the Gcs Server and then add bucket definitions for each bucket you want.

```c#
var projectId = builder.AddParameter("project-id");
// Optional initDataDirectory
var gcs = builder.AddGcs("gcs", projectId, certPath: "path/to/ssl/cert", initDataDirectory: "path/to/preinitialize/content/directory");

//optional custom templating of bucket name
var bucket = gcs.AddBucket("bucket-name", ReferenceExpression.Create($"{projectId}-bucket-name"));

//usage - supports WaitFor and environment passing for buckets
var service = builder.AddContainer(...)
.WithEnvironemnt("GCP_PROJECT_ID", gcs.Resource.ProjectId)
.WithEnvironment("GCS_BUCKET_NAME", bucket)
.WaitFor(bucket); // will wait for bucket creation

if (builder.ExecutionContext.IsRunMode)
{
service
.WithEnvironment("GCS_ENDPOINT", gcs) // will automatically pass the correct endpoint
.WithEnvironment("GCP_APPLICATION_CREDENTIALS", "path/to/ssl/cert")
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace CommunityToolkit.Aspire.Hosting.Gcp.PubSub;

internal static class PubSubEmulatorContainerImage
{
public const string Image = "gcr.io/google.com/cloudsdktool/google-cloud-cli";
public const string Tag = "446.0.1-emulators";
}
42 changes: 42 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.Gcp/PubSub/PubSubResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace CommunityToolkit.Aspire.Hosting.Gcp.PubSub;

public class PubSubResource: ContainerResource, IResourceWithConnectionString
{
internal const int Port = 8085;
internal const string EndpointName = "http";

internal List<TopicResource> Topics { get; } = [];

private EndpointReference? _endpoint;

public ParameterResource ProjectId { get; }

public PubSubResource(string name, ParameterResource projectId) : base(name)
{
ProjectId = projectId;
}

public EndpointReference Endpoint => _endpoint ??= new EndpointReference(this, EndpointName);


private PublisherServiceApiClient? _client;

internal async ValueTask<PublisherServiceApiClient> GetClientAsync(CancellationToken ct = default)
{
if (_client is not null)
{
return _client;
}

var builder = new PublisherServiceApiClientBuilder
{
Endpoint = Endpoint.Url,
ChannelCredentials = ChannelCredentials.Insecure
};
_client = await builder.BuildAsync(ct);
return _client;
}

public ReferenceExpression ConnectionStringExpression =>
ReferenceExpression.Create($"{Endpoint.Property(EndpointProperty.Scheme)}://{Endpoint.Property(EndpointProperty.HostAndPort)}");
}
Loading