diff --git a/src/Aspirate.Cli/Aspirate.Cli.csproj b/src/Aspirate.Cli/Aspirate.Cli.csproj index 26f5979..b4eeb40 100644 --- a/src/Aspirate.Cli/Aspirate.Cli.csproj +++ b/src/Aspirate.Cli/Aspirate.Cli.csproj @@ -42,13 +42,7 @@ - - Always - - - Always - - + Always diff --git a/src/Aspirate.Cli/Properties/launchSettings.json b/src/Aspirate.Cli/Properties/launchSettings.json index 0cfff82..d5142af 100644 --- a/src/Aspirate.Cli/Properties/launchSettings.json +++ b/src/Aspirate.Cli/Properties/launchSettings.json @@ -15,7 +15,7 @@ "generate": { "commandName": "Project", "commandLineArgs": "generate", - "workingDirectory": "$(ProjectDir)/../../../aspire/samples/eShopLite/AppHost", + "workingDirectory": "/Users/prom3theu5/git/test-volumes/AspireSample/AspireSample.AppHost", "hotReloadEnabled": false }, "generate-non-interactive": { @@ -39,7 +39,7 @@ "apply": { "commandName": "Project", "commandLineArgs": "apply", - "workingDirectory": "$(ProjectDir)/../../../aspire/samples/eShopLite/AppHost", + "workingDirectory": "/Users/prom3theu5/git/test-volumes/AspireSample/AspireSample.AppHost", "hotReloadEnabled": false }, "apply-non-interactive": { @@ -51,7 +51,7 @@ "destroy": { "commandName": "Project", "commandLineArgs": "destroy", - "workingDirectory": "$(ProjectDir)/../../../aspire/samples/eShopLite/AppHost", + "workingDirectory": "/Users/prom3theu5/git/test-volumes/AspireSample/AspireSample.AppHost", "hotReloadEnabled": false }, "destroy-non-interactive": { diff --git a/src/Aspirate.Cli/Templates/mongo-server.hbs b/src/Aspirate.Cli/Templates/mongo-server.hbs deleted file mode 100644 index a365f7b..0000000 --- a/src/Aspirate.Cli/Templates/mongo-server.hbs +++ /dev/null @@ -1,42 +0,0 @@ ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: mongo-statefulset - labels: - app: mongo -spec: - serviceName: "mongo" - replicas: 1 - selector: - matchLabels: - app: mongo - template: - metadata: - labels: - app: mongo - spec: - {{#if withPrivateRegistry}} - imagePullSecrets: - - name: image-pull-secret - {{/if}} - containers: - - name: mongo - image: mongo:latest - ports: - - containerPort: 27017 - name: mongodb ---- -apiVersion: v1 -kind: Service -metadata: - name: mongo-service - labels: - app: mongo -spec: - type: ClusterIP - ports: - - port: 27017 - name: mongo - selector: - app: mongo \ No newline at end of file diff --git a/src/Aspirate.Cli/Templates/mysql.hbs b/src/Aspirate.Cli/Templates/mysql.hbs deleted file mode 100644 index fc3fd5f..0000000 --- a/src/Aspirate.Cli/Templates/mysql.hbs +++ /dev/null @@ -1,53 +0,0 @@ ---- -apiVersion: v1 -kind: Secret -metadata: - name: mysql-secrets -type: Opaque -data: - MYSQL_ROOT_PASSWORD: {{RootPassword}} ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: mysql-statefulset - labels: - app: mysql -spec: - serviceName: "mysql" - replicas: 1 - selector: - matchLabels: - app: mysql - template: - metadata: - labels: - app: mysql - spec: - {{#if withPrivateRegistry}} - imagePullSecrets: - - name: image-pull-secret - {{/if}} - containers: - - name: mysql - image: mysql:latest - envFrom: - - secretRef: - name: mysql-secrets - ports: - - containerPort: 3306 - name: mysqldb ---- -apiVersion: v1 -kind: Service -metadata: - name: mysql-service - labels: - app: mysql -spec: - type: ClusterIP - ports: - - port: 3306 - name: mysql - selector: - app: mysql \ No newline at end of file diff --git a/src/Aspirate.Cli/Templates/postgres-server.hbs b/src/Aspirate.Cli/Templates/postgres-server.hbs deleted file mode 100644 index 9d3f27e..0000000 --- a/src/Aspirate.Cli/Templates/postgres-server.hbs +++ /dev/null @@ -1,56 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: postgres-configuration - labels: - app: postgres -data: - POSTGRES_DB: "postgres" - POSTGRES_USER: "postgres" - POSTGRES_PASSWORD: "postgres" ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: postgres-statefulset - labels: - app: postgres -spec: - serviceName: "postgres" - replicas: 1 - selector: - matchLabels: - app: postgres - template: - metadata: - labels: - app: postgres - spec: - {{#if withPrivateRegistry}} - imagePullSecrets: - - name: image-pull-secret - {{/if}} - containers: - - name: postgres - image: postgres:12 - envFrom: - - configMapRef: - name: postgres-configuration - ports: - - containerPort: 5432 - name: postgresdb ---- -apiVersion: v1 -kind: Service -metadata: - name: postgres-service - labels: - app: postgres -spec: - type: ClusterIP - ports: - - port: 5432 - name: postgres - selector: - app: postgres \ No newline at end of file diff --git a/src/Aspirate.Cli/Templates/rabbitmq.hbs b/src/Aspirate.Cli/Templates/rabbitmq.hbs deleted file mode 100644 index d72ccc5..0000000 --- a/src/Aspirate.Cli/Templates/rabbitmq.hbs +++ /dev/null @@ -1,55 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: rabbitmq-configuration - labels: - app: rabbitmq -data: - RABBITMQ_DEFAULT_USER: "guest" - RABBITMQ_DEFAULT_PASS: "guest" ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: rabbitmq-statefulset - labels: - app: rabbitmq -spec: - serviceName: "rabbitmq" - replicas: 1 - selector: - matchLabels: - app: rabbitmq - template: - metadata: - labels: - app: rabbitmq - spec: - {{#if withPrivateRegistry}} - imagePullSecrets: - - name: image-pull-secret - {{/if}} - containers: - - name: rabbitmq - image: rabbitmq:3.8-management - envFrom: - - configMapRef: - name: rabbitmq-configuration - ports: - - containerPort: 5672 - name: rabbitmq ---- -apiVersion: v1 -kind: Service -metadata: - name: rabbitmq-service - labels: - app: rabbitmq -spec: - type: ClusterIP - ports: - - port: 5672 - name: rabbitmq - selector: - app: rabbitmq diff --git a/src/Aspirate.Cli/Templates/redis.hbs b/src/Aspirate.Cli/Templates/redis.hbs deleted file mode 100644 index 949ac74..0000000 --- a/src/Aspirate.Cli/Templates/redis.hbs +++ /dev/null @@ -1,46 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: redis -spec: - replicas: 1 - selector: - matchLabels: - app: redis - template: - metadata: - labels: - app: redis - spec: -{{#if withPrivateRegistry}} - imagePullSecrets: - - name: image-pull-secret -{{/if}} - containers: - - name: redis - image: bitnami/redis:latest - env: - - name: ALLOW_EMPTY_PASSWORD - value: "yes" - resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 250m - memory: 256Mi - ports: - - containerPort: 6379 - name: redis ---- -apiVersion: v1 -kind: Service -metadata: - name: redis -spec: - ports: - - port: 6379 - selector: - app: redis ---- \ No newline at end of file diff --git a/src/Aspirate.Cli/Templates/sqlserver.hbs b/src/Aspirate.Cli/Templates/sqlserver.hbs deleted file mode 100644 index e881739..0000000 --- a/src/Aspirate.Cli/Templates/sqlserver.hbs +++ /dev/null @@ -1,64 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: sqlserver-configuration - labels: - app: sqlserver -data: - ACCEPT_EULA: "Y" ---- -apiVersion: v1 -kind: Secret -metadata: - name: sqlserver-secrets -type: Opaque -data: - MSSQL_SA_PASSWORD: {{saPassword}} ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: sqlserver-statefulset - labels: - app: sqlserver -spec: - serviceName: "sqlserver" - replicas: 1 - selector: - matchLabels: - app: sqlserver - template: - metadata: - labels: - app: sqlserver - spec: -{{#if withPrivateRegistry}} - imagePullSecrets: - - name: image-pull-secret -{{/if}} - containers: - - name: sqlserver - image: mcr.microsoft.com/mssql/server:2022-latest - envFrom: - - configMapRef: - name: sqlserver-configuration - - secretRef: - name: sqlserver-secrets - ports: - - containerPort: 1433 - name: sqlserverdb ---- -apiVersion: v1 -kind: Service -metadata: - name: sqlserver-service - labels: - app: sqlserver -spec: - type: ClusterIP - ports: - - port: 1433 - name: sqlserver - selector: - app: sqlserver \ No newline at end of file diff --git a/src/Aspirate.Cli/Templates/statefulset.hbs b/src/Aspirate.Cli/Templates/statefulset.hbs new file mode 100644 index 0000000..824bb15 --- /dev/null +++ b/src/Aspirate.Cli/Templates/statefulset.hbs @@ -0,0 +1,68 @@ +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{name}} + {{#if hasAnyAnnotations}} + annotations: + {{#each annotations}} + {{@key}}: {{this}} + {{/each}} + {{/if}} +spec: + serviceName: "{{name}}" + replicas: 1 + selector: + matchLabels: + app: {{name}} + template: + metadata: + labels: + app: {{name}} + spec: + {{#if withPrivateRegistry}} + imagePullSecrets: + - name: image-pull-secret + {{/if}} + containers: + - name: {{name}} + image: {{containerImage}} + imagePullPolicy: {{imagePullPolicy}} + {{#if hasArgs}} + args: + {{#each args}} + - '{{this}}' + {{/each}} + {{/if}} + {{#if hasPorts}} + ports: + {{#each ports}} + - containerPort: {{port}} + {{/each}} + {{/if}} + envFrom: + - configMapRef: + name: {{name}}-env + {{#if hasAnySecrets}} + - secretRef: + name: {{name}}-secrets + {{/if}} + {{#if hasVolumes}} + volumeMounts: + {{#each volumes}} + - name: {{this.name}} + mountPath: {{this.target}} + {{/each}} + {{/if}} + {{#if hasVolumes}} + volumeClaimTemplates: + {{#each volumes}} + - metadata: + name: {{this.name}} + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi + {{/each}} + {{/if}} \ No newline at end of file diff --git a/src/Aspirate.Commands/Actions/Manifests/GenerateDockerComposeManifestAction.cs b/src/Aspirate.Commands/Actions/Manifests/GenerateDockerComposeManifestAction.cs index 978a6b5..7e64985 100644 --- a/src/Aspirate.Commands/Actions/Manifests/GenerateDockerComposeManifestAction.cs +++ b/src/Aspirate.Commands/Actions/Manifests/GenerateDockerComposeManifestAction.cs @@ -1,3 +1,5 @@ +using Volume = Aspirate.DockerCompose.Models.Volume; + namespace Aspirate.Commands.Actions.Manifests; public sealed class GenerateDockerComposeManifestAction(IServiceProvider serviceProvider, IFileSystem fileSystem) : BaseAction(serviceProvider) @@ -35,8 +37,11 @@ public override Task ExecuteAsync() private void WriteFile(List services, string outputFile) { + var volumes = CreateVolumes(services); + var composeFile = Builder.MakeCompose() .WithServices(services.ToArray()) + .WithVolumes(volumes.ToArray()) .Build(); var composeFileString = composeFile.Serialize(); @@ -49,14 +54,23 @@ private void WriteFile(List services, string outputFile) fileSystem.File.WriteAllText(outputFile, composeFileString); } - private void ProcessIndividualComponent(KeyValuePair resource, List services) + private static List CreateVolumes(List services) { - if (resource.Value.Type is null) + var volumes = new List(); + + foreach (var service in services) { - Logger.MarkupLine($"[yellow]Skipping resource '{resource.Key}' as its type is unknown.[/]"); - return; + if (service.Volumes is not null) + { + volumes.AddRange(service.Volumes.Select(volume => new Volume { Name = volume.Split(':')[0] })); + } } + return volumes; + } + + private void ProcessIndividualComponent(KeyValuePair resource, List services) + { if (AspirateState.IsNotDeployable(resource.Value)) { return; diff --git a/src/Aspirate.Processors/KubernetesDeploymentTemplateData.cs b/src/Aspirate.Processors/KubernetesDeploymentTemplateData.cs index 4ff2edc..bc64812 100644 --- a/src/Aspirate.Processors/KubernetesDeploymentTemplateData.cs +++ b/src/Aspirate.Processors/KubernetesDeploymentTemplateData.cs @@ -8,6 +8,7 @@ public class KubernetesDeploymentTemplateData public Dictionary? Env {get; private set;} public Dictionary? Secrets {get; private set;} public Dictionary? Annotations {get; private set;} + public List? Volumes {get; private set;} public IReadOnlyCollection? Manifests {get; private set;} public IReadOnlyCollection? Args {get; private set;} public bool? IsService { get; private set; } = true; @@ -73,6 +74,12 @@ public KubernetesDeploymentTemplateData SetIsProject(bool project) return this; } + public KubernetesDeploymentTemplateData SetVolumes(List? volumes) + { + Volumes = volumes ?? []; + return this; + } + public KubernetesDeploymentTemplateData SetWithPrivateRegistry(bool isPrivateRegistry) { WithPrivateRegistry = isPrivateRegistry; @@ -133,6 +140,7 @@ public KubernetesDeploymentTemplateData SetSecretsFromSecretState(KeyValuePair Ports?.Any() == true; + public bool HasVolumes => Volumes?.Any() == true; public bool HasAnySecrets => Secrets?.Any() == true; public bool HasAnyAnnotations => Annotations?.Any() == true; public bool HasArgs => Args?.Any() == true; diff --git a/src/Aspirate.Processors/Resources/AbstractProcessors/ContainerProcessor.cs b/src/Aspirate.Processors/Resources/AbstractProcessors/ContainerProcessor.cs index d890583..689d376 100644 --- a/src/Aspirate.Processors/Resources/AbstractProcessors/ContainerProcessor.cs +++ b/src/Aspirate.Processors/Resources/AbstractProcessors/ContainerProcessor.cs @@ -1,3 +1,6 @@ +using Aspirate.DockerCompose.Models; +using Volume = Aspirate.Shared.Models.AspireManifests.Components.V0.Volume; + namespace Aspirate.Processors.Resources.AbstractProcessors; /// @@ -16,12 +19,6 @@ public class ContainerProcessor( /// public override string ResourceType => AspireComponentLiterals.Container; - private readonly IReadOnlyCollection _manifests = - [ - $"{TemplateLiterals.DeploymentType}.yml", - $"{TemplateLiterals.ServiceType}.yml", - ]; - /// public override Resource? Deserialize(ref Utf8JsonReader reader) => JsonSerializer.Deserialize(ref reader); @@ -39,6 +36,16 @@ public override Task CreateManifests(KeyValuePair resour var container = resource.Value as ContainerResource; + var manifests = new List + { + container.Volumes.Count > 0 + ? $"{TemplateLiterals.StatefulSetType}.yml" + : $"{TemplateLiterals.DeploymentType}.yml", + $"{TemplateLiterals.ServiceType}.yml", + }; + + KuberizeVolumeNames(container.Volumes); + var containerPorts = container.Bindings?.Select(b => new Ports { Name = b.Key, Port = b.Value.TargetPort.GetValueOrDefault() }).ToList() ?? []; var data = new KubernetesDeploymentTemplateData() @@ -47,15 +54,24 @@ public override Task CreateManifests(KeyValuePair resour .SetImagePullPolicy(imagePullPolicy) .SetEnv(GetFilteredEnvironmentalVariables(resource.Value, disableSecrets)) .SetAnnotations(container.Annotations) + .SetVolumes(container.Volumes) .SetSecrets(GetSecretEnvironmentalVariables(resource.Value, disableSecrets)) .SetSecretsFromSecretState(resource, secretProvider, disableSecrets) .SetPorts(containerPorts) .SetArgs(container.Args) - .SetManifests(_manifests) + .SetManifests(manifests) .SetWithPrivateRegistry(withPrivateRegistry.GetValueOrDefault()) .Validate(); - _manifestWriter.CreateDeployment(resourceOutputPath, data, templatePath); + if (container.Volumes.Count > 0) + { + _manifestWriter.CreateStatefulSet(resourceOutputPath, data, templatePath); + } + else + { + _manifestWriter.CreateDeployment(resourceOutputPath, data, templatePath); + } + _manifestWriter.CreateService(resourceOutputPath, data, templatePath); _manifestWriter.CreateComponentKustomizeManifest(resourceOutputPath, data, templatePath); @@ -64,6 +80,19 @@ public override Task CreateManifests(KeyValuePair resour return Task.FromResult(true); } + private static void KuberizeVolumeNames(List containerVolumes) + { + if (containerVolumes.Count == 0) + { + return; + } + + foreach (var volume in containerVolumes) + { + volume.Name = volume.Name.Replace("/", "-").Replace(".", "-").ToLowerInvariant(); + } + } + public override ComposeService CreateComposeEntry(KeyValuePair resource) { var response = new ComposeService(); @@ -76,7 +105,7 @@ public override ComposeService CreateComposeEntry(KeyValuePair if (resource.Value is IResourceWithEnvironmentalVariables { Env: not null } resourceWithEnv) { - foreach (var entry in resourceWithEnv.Env) + foreach (var entry in resourceWithEnv.Env.Where(entry => !string.IsNullOrEmpty(entry.Value))) { environment.Add(entry.Key, entry.Value); } @@ -90,10 +119,20 @@ public override ComposeService CreateComposeEntry(KeyValuePair service.WithCommands(container.Args.ToArray()); } + var composeVolumes = new List(); + + if (container.Volumes.Count > 0) + { + KuberizeVolumeNames(container.Volumes); + + composeVolumes.AddRange(container.Volumes.Where(x=>!string.IsNullOrWhiteSpace(x.Name)).Select(volume => $"{volume.Name}:{volume.Target}")); + } + response.Service = service .WithEnvironment(environment) .WithContainerName(resource.Key) .WithRestartPolicy(RestartMode.UnlessStopped) + .WithVolumes(composeVolumes.ToArray()) .WithPortMappings(containerPorts.Select(x=> new Port { Target = x.Port, diff --git a/src/Aspirate.Processors/Resources/Project/ProjectProcessor.cs b/src/Aspirate.Processors/Resources/Project/ProjectProcessor.cs index 2da8fc1..4b25e42 100644 --- a/src/Aspirate.Processors/Resources/Project/ProjectProcessor.cs +++ b/src/Aspirate.Processors/Resources/Project/ProjectProcessor.cs @@ -148,12 +148,12 @@ protected override void PreSubstitutePlaceholders(Resource resource, Dictionary< return; } - if (resourceWithBinding.Bindings.TryGetValue("http", out var httpBinding) && httpBinding.TargetPort == 0 || httpBinding.TargetPort is null) + if (resourceWithBinding.Bindings.TryGetValue("http", out var httpBinding) && httpBinding.TargetPort is 0 or null) { httpBinding.TargetPort = 8080; } - if (resourceWithBinding.Bindings.TryGetValue("https", out var httpsBinding) && httpsBinding.TargetPort == 0 || httpsBinding.TargetPort is null) + if (resourceWithBinding.Bindings.TryGetValue("https", out var httpsBinding) && httpsBinding.TargetPort is 0 or null) { httpsBinding.TargetPort = 8443; } diff --git a/src/Aspirate.Services/Implementations/ContainerCompositionService.cs b/src/Aspirate.Services/Implementations/ContainerCompositionService.cs index 1961f29..0722978 100644 --- a/src/Aspirate.Services/Implementations/ContainerCompositionService.cs +++ b/src/Aspirate.Services/Implementations/ContainerCompositionService.cs @@ -1,3 +1,5 @@ +using System.Runtime.InteropServices; + namespace Aspirate.Services.Implementations; public sealed class ContainerCompositionService( @@ -157,9 +159,10 @@ private bool AskIfShouldRetryHandlingDuplicateFiles(bool nonInteractive) "\r\n[red bold]Implicitly, dotnet publish does not allow duplicate filenames to be output to the artefact directory at build time.\r\nWould you like to retry the build explicitly allowing them?[/]\r\n"); } - private async Task AddProjectPublishArguments(ArgumentsBuilder argumentsBuilder, string fullProjectPath, - string? runtimeIdentifier) + private async Task AddProjectPublishArguments(ArgumentsBuilder argumentsBuilder, string fullProjectPath, string? runtimeIdentifier) { + var defaultRuntimeIdentifier = GetRuntimeIdentifier(); + var propertiesJson = await projectPropertyService.GetProjectPropertiesAsync( fullProjectPath, MsBuildPropertiesLiterals.PublishSingleFileArgument, @@ -182,9 +185,11 @@ private async Task AddProjectPublishArguments(ArgumentsBuilder argumentsBuilder, .AppendArgument(DotNetSdkLiterals.PublishProfileArgument, DotNetSdkLiterals.ContainerPublishProfile) .AppendArgument(DotNetSdkLiterals.PublishSingleFileArgument, msbuildProperties.Properties.PublishSingleFile) .AppendArgument(DotNetSdkLiterals.PublishTrimmedArgument, msbuildProperties.Properties.PublishTrimmed) - .AppendArgument(DotNetSdkLiterals.SelfContainedArgument, DotNetSdkLiterals.DefaultSelfContained); + .AppendArgument(DotNetSdkLiterals.SelfContainedArgument, DotNetSdkLiterals.DefaultSelfContained) + .AppendArgument(DotNetSdkLiterals.VerbosityArgument, DotNetSdkLiterals.DefaultVerbosity) + .AppendArgument(DotNetSdkLiterals.NoLogoArgument, string.Empty, quoteValue: false); - argumentsBuilder.AppendArgument(DotNetSdkLiterals.RuntimeIdentifierArgument, string.IsNullOrEmpty(runtimeIdentifier) ? DotNetSdkLiterals.DefaultRuntimeIdentifier : runtimeIdentifier); + argumentsBuilder.AppendArgument(DotNetSdkLiterals.RuntimeIdentifierArgument, string.IsNullOrEmpty(runtimeIdentifier) ? defaultRuntimeIdentifier : runtimeIdentifier); } private static void AddContainerDetailsToArguments(ArgumentsBuilder argumentsBuilder, @@ -267,4 +272,11 @@ private static void CheckSuccess(ShellCommandResult result) ActionCausesExitException.ExitNow(result.ExitCode); } } + + private static string GetRuntimeIdentifier() + { + var architecture = RuntimeInformation.OSArchitecture; + + return architecture == Architecture.Arm64 ? "linux-arm64" : "linux-x64"; + } } diff --git a/src/Aspirate.Services/Implementations/ManifestWriter.cs b/src/Aspirate.Services/Implementations/ManifestWriter.cs index 0d91811..c9ecde1 100644 --- a/src/Aspirate.Services/Implementations/ManifestWriter.cs +++ b/src/Aspirate.Services/Implementations/ManifestWriter.cs @@ -8,15 +8,10 @@ public class ManifestWriter(IFileSystem fileSystem) : IManifestWriter private readonly Dictionary _templateFileMapping = new() { [TemplateLiterals.DeploymentType] = $"{TemplateLiterals.DeploymentType}.hbs", + [TemplateLiterals.StatefulSetType] = $"{TemplateLiterals.StatefulSetType}.hbs", [TemplateLiterals.DaprComponentType] = $"{TemplateLiterals.DaprComponentType}.hbs", [TemplateLiterals.ServiceType] = $"{TemplateLiterals.ServiceType}.hbs", [TemplateLiterals.ComponentKustomizeType] = $"{TemplateLiterals.ComponentKustomizeType}.hbs", - [TemplateLiterals.RedisType] = $"{TemplateLiterals.RedisType}.hbs", - [TemplateLiterals.SqlServerType] = $"{TemplateLiterals.SqlServerType}.hbs", - [TemplateLiterals.MysqlServerType] = $"{TemplateLiterals.MysqlServerType}.hbs", - [TemplateLiterals.RabbitMqType] = $"{TemplateLiterals.RabbitMqType}.hbs", - [TemplateLiterals.MongoDbServerType] = $"{TemplateLiterals.MongoDbServerType}.hbs", - [TemplateLiterals.PostgresServerType] = $"{TemplateLiterals.PostgresServerType}.hbs", [TemplateLiterals.NamespaceType] = $"{TemplateLiterals.NamespaceType}.hbs", }; @@ -45,6 +40,14 @@ public void CreateDeployment(string outputPath, TTemplateData dat CreateFile(templateFile, deploymentOutputPath, data, templatePath); } + public void CreateStatefulSet(string outputPath, TTemplateData data, string? templatePath) + { + _templateFileMapping.TryGetValue(TemplateLiterals.StatefulSetType, out var templateFile); + var deploymentOutputPath = Path.Combine(outputPath, $"{TemplateLiterals.StatefulSetType}.yml"); + + CreateFile(templateFile, deploymentOutputPath, data, templatePath); + } + public void CreateDaprManifest(string outputPath, TTemplateData data, string name, string? templatePath) { var daprOutputPath = Path.Combine(outputPath, "dapr"); diff --git a/src/Aspirate.Services/Interfaces/IManifestWriter.cs b/src/Aspirate.Services/Interfaces/IManifestWriter.cs index 82594b0..fde1b22 100644 --- a/src/Aspirate.Services/Interfaces/IManifestWriter.cs +++ b/src/Aspirate.Services/Interfaces/IManifestWriter.cs @@ -17,6 +17,14 @@ public interface IManifestWriter /// The path of the template file (optional). void CreateDeployment(string outputPath, TTemplateData data, string? templatePath); + /// + /// Create a statefulset file using the specified template file and template data. + /// + /// The path where the statefulset file will be created. + /// The template data. + /// The path of the template file (optional). + void CreateStatefulSet(string outputPath, TTemplateData data, string? templatePath); + /// /// Creates a Dapr manifest file based on the provided template data and saves it to the specified output path. /// diff --git a/src/Aspirate.Shared/Literals/DotNetSdkLiterals.cs b/src/Aspirate.Shared/Literals/DotNetSdkLiterals.cs index 5c53d3c..53cb6ea 100644 --- a/src/Aspirate.Shared/Literals/DotNetSdkLiterals.cs +++ b/src/Aspirate.Shared/Literals/DotNetSdkLiterals.cs @@ -11,11 +11,13 @@ public static class DotNetSdkLiterals public const string RunArgument = "run"; public const string PublishArgument = "publish"; public const string ArgumentDelimiter = "--"; + public const string VerbosityArgument = "--verbosity"; public const string ProjectArgument = "--project"; public const string PublisherArgument = "--publisher"; public const string OutputPathArgument = "--output-path"; public const string SelfContainedArgument = "--self-contained"; + public const string NoLogoArgument = "--nologo"; public const string RuntimeIdentifierArgument = "-r"; public const string OsArgument = "--os"; public const string ArchArgument = "--arch"; @@ -37,6 +39,7 @@ public static class DotNetSdkLiterals public const string DefaultRuntimeIdentifier = "linux-x64"; public const string DefaultOs = "linux"; public const string DefaultArch = "x64"; + public const string DefaultVerbosity = "quiet"; public const string DotNetCommand = "dotnet"; diff --git a/src/Aspirate.Shared/Literals/TemplateLiterals.cs b/src/Aspirate.Shared/Literals/TemplateLiterals.cs index 4d23c59..351ebf6 100644 --- a/src/Aspirate.Shared/Literals/TemplateLiterals.cs +++ b/src/Aspirate.Shared/Literals/TemplateLiterals.cs @@ -5,14 +5,9 @@ public static class TemplateLiterals { public const string TemplatesFolder = "Templates"; public const string DeploymentType = "deployment"; + public const string StatefulSetType = "statefulset"; public const string DaprComponentType = "dapr-component"; public const string ServiceType = "service"; - public const string RedisType = "redis"; - public const string SqlServerType = "sqlserver"; - public const string MysqlServerType = "mysql"; - public const string RabbitMqType = "rabbitmq"; - public const string PostgresServerType = "postgres-server"; - public const string MongoDbServerType = "mongo-server"; public const string ComponentKustomizeType = "kustomization"; public const string NamespaceType = "namespace"; public const string ImagePullSecretType = "image-pull-secret"; diff --git a/src/Aspirate.Shared/Models/AspireManifests/Components/V0/Container/ContainerResource.cs b/src/Aspirate.Shared/Models/AspireManifests/Components/V0/Container/ContainerResource.cs index 20f6c63..2245def 100644 --- a/src/Aspirate.Shared/Models/AspireManifests/Components/V0/Container/ContainerResource.cs +++ b/src/Aspirate.Shared/Models/AspireManifests/Components/V0/Container/ContainerResource.cs @@ -1,6 +1,12 @@ namespace Aspirate.Shared.Models.AspireManifests.Components.V0.Container; -public class ContainerResource : Resource, IResourceWithBinding, IResourceWithConnectionString, IResourceWithArgs, IResourceWithAnnotations, IResourceWithEnvironmentalVariables +public class ContainerResource : Resource, + IResourceWithBinding, + IResourceWithConnectionString, + IResourceWithArgs, + IResourceWithAnnotations, + IResourceWithEnvironmentalVariables, + IResourceWithVolumes { [JsonPropertyName("image")] public required string Image { get; set; } @@ -19,4 +25,7 @@ public class ContainerResource : Resource, IResourceWithBinding, IResourceWithCo [JsonPropertyName("env")] public Dictionary? Env { get; set; } = []; + + [JsonPropertyName("volumes")] + public List? Volumes { get; set; } = []; } diff --git a/src/Aspirate.Shared/Models/AspireManifests/Components/V0/Volume.cs b/src/Aspirate.Shared/Models/AspireManifests/Components/V0/Volume.cs new file mode 100644 index 0000000..f974c26 --- /dev/null +++ b/src/Aspirate.Shared/Models/AspireManifests/Components/V0/Volume.cs @@ -0,0 +1,13 @@ +namespace Aspirate.Shared.Models.AspireManifests.Components.V0; + +public class Volume +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("target")] + public string? Target { get; set; } + + [JsonPropertyName("readOnly")] + public bool ReadOnly { get; set; } +} diff --git a/src/Aspirate.Shared/Models/AspireManifests/Interfaces/IResourceWithVolumes.cs b/src/Aspirate.Shared/Models/AspireManifests/Interfaces/IResourceWithVolumes.cs new file mode 100644 index 0000000..21a357e --- /dev/null +++ b/src/Aspirate.Shared/Models/AspireManifests/Interfaces/IResourceWithVolumes.cs @@ -0,0 +1,8 @@ +using Volume = Aspirate.Shared.Models.AspireManifests.Components.V0.Volume; + +namespace Aspirate.Shared.Models.AspireManifests.Interfaces; + +public interface IResourceWithVolumes +{ + List? Volumes { get; set; } +} diff --git a/tests/Aspirate.Tests/Aspirate.Tests.csproj b/tests/Aspirate.Tests/Aspirate.Tests.csproj index 984827f..93421a0 100644 --- a/tests/Aspirate.Tests/Aspirate.Tests.csproj +++ b/tests/Aspirate.Tests/Aspirate.Tests.csproj @@ -39,6 +39,9 @@ + + Always + diff --git a/tests/Aspirate.Tests/ServiceTests/ManifestFileParserServiceTests.cs b/tests/Aspirate.Tests/ServiceTests/ManifestFileParserServiceTests.cs index cc718a4..738d793 100644 --- a/tests/Aspirate.Tests/ServiceTests/ManifestFileParserServiceTests.cs +++ b/tests/Aspirate.Tests/ServiceTests/ManifestFileParserServiceTests.cs @@ -114,6 +114,7 @@ public void LoadAndParseAspireManifest_ReturnsResource_WhenResourceTypeIsSupport [InlineData("pg-endtoend.json", 22)] [InlineData("sqlserver-endtoend.json", 4)] [InlineData("starter-with-redis.json", 3)] + [InlineData("project-no-binding.json", 1)] public async Task EndToEnd_ParsesSuccessfully(string manifestFile, int expectedCount) { // Arrange @@ -149,7 +150,28 @@ public async Task EndToEndWithManualEntry_ParsesSuccessfully() await PerformEndToEndTests(manifestFile, 8, serviceProvider, service, inputPopulator, valueSubstitutor); } - private static async Task PerformEndToEndTests(string manifestFile, int expectedCount, IServiceProvider serviceProvider, IManifestFileParserService service, IAction inputPopulator, IAction valueSubstitutor) + [Fact] + public async Task EndToEndShop_ParsesSuccessfully() + { + // Arrange + var fileSystem = new MockFileSystem(); + var manifestFile = "shop.json"; + var testData = Path.Combine(AppContext.BaseDirectory, "TestData", manifestFile); + fileSystem.AddFile(manifestFile, new(await File.ReadAllTextAsync(testData))); + var serviceProvider = CreateServiceProvider(fileSystem); + + var service = serviceProvider.GetRequiredService(); + var inputPopulator = serviceProvider.GetRequiredKeyedService(nameof(PopulateInputsAction)); + var valueSubstitutor = serviceProvider.GetRequiredKeyedService(nameof(SubstituteValuesAspireManifestAction)); + + var results = await PerformEndToEndTests(manifestFile, 12, serviceProvider, service, inputPopulator, valueSubstitutor); + + var shopResource = results["basketcache"] as ContainerResource; + shopResource.Volumes.Should().HaveCount(1); + shopResource.Volumes[0].Name.Should().Be("basketcache-data"); + } + + private static async Task> PerformEndToEndTests(string manifestFile, int expectedCount, IServiceProvider serviceProvider, IManifestFileParserService service, IAction inputPopulator, IAction valueSubstitutor) { // Act var state = serviceProvider.GetRequiredService(); @@ -190,6 +212,8 @@ private static async Task PerformEndToEndTests(string manifestFile, int expected envVar.Value.Should().NotContain("}"); } } + + return result; } private static IServiceProvider CreateServiceProvider(IFileSystem? fileSystem = null, IAnsiConsole? console = null) diff --git a/tests/Aspirate.Tests/TestData/project-no-binding.json b/tests/Aspirate.Tests/TestData/project-no-binding.json new file mode 100644 index 0000000..609ca13 --- /dev/null +++ b/tests/Aspirate.Tests/TestData/project-no-binding.json @@ -0,0 +1,13 @@ +{ + "resources": { + "no-bindings": { + "type": "project.v0", + "path": "../ProjectWithoutBindings/ProjectWithoutBindings.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory" + } + } + } +} diff --git a/tests/Aspirate.Tests/TestData/shop.json b/tests/Aspirate.Tests/TestData/shop.json new file mode 100644 index 0000000..514f3eb --- /dev/null +++ b/tests/Aspirate.Tests/TestData/shop.json @@ -0,0 +1,246 @@ +{ + "resources": { + "postgres": { + "type": "container.v0", + "connectionString": "Host={postgres.bindings.tcp.host};Port={postgres.bindings.tcp.port};Username=postgres;Password={postgres-password.value}", + "image": "postgres:16.2", + "env": { + "POSTGRES_HOST_AUTH_METHOD": "scram-sha-256", + "POSTGRES_INITDB_ARGS": "--auth-host=scram-sha-256 --auth-local=scram-sha-256", + "POSTGRES_USER": "postgres", + "POSTGRES_PASSWORD": "{postgres-password.value}" + }, + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 5432 + } + } + }, + "catalogdb": { + "type": "value.v0", + "connectionString": "{postgres.connectionString};Database=catalogdb" + }, + "basketcache": { + "type": "container.v0", + "connectionString": "{basketcache.bindings.tcp.host}:{basketcache.bindings.tcp.port}", + "image": "redis:7.2.4", + "args": [ + "--save", + "60", + "1" + ], + "volumes": [ + { + "name": "basketcache-data", + "target": "/data", + "readOnly": false + } + ], + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 6379 + } + } + }, + "catalogservice": { + "type": "project.v0", + "path": "../CatalogService/CatalogService.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "ConnectionStrings__catalogdb": "{catalogdb.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + }, + "rabbitmq-password": { + "type": "parameter.v0", + "value": "{rabbitmq-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22 + } + } + } + } + }, + "messaging": { + "type": "container.v0", + "connectionString": "amqp://guest:{rabbitmq-password.value}@{messaging.bindings.tcp.host}:{messaging.bindings.tcp.port}", + "image": "rabbitmq:3-management", + "volumes": [ + { + "name": "TestShop.AppHost-messaging-data", + "target": "/var/lib/rabbitmq", + "readOnly": false + } + ], + "env": { + "RABBITMQ_DEFAULT_USER": "guest", + "RABBITMQ_DEFAULT_PASS": "{rabbitmq-password.value}" + }, + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 5672 + }, + "management": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 15672 + } + } + }, + "basketservice": { + "type": "project.v0", + "path": "../BasketService/BasketService.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "ConnectionStrings__basketcache": "{basketcache.connectionString}", + "ConnectionStrings__messaging": "{messaging.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http2" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http2" + } + } + }, + "frontend": { + "type": "project.v0", + "path": "../MyFrontend/MyFrontend.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "services__basketservice__http__0": "{basketservice.bindings.http.url}", + "services__basketservice__https__0": "{basketservice.bindings.https.url}", + "services__catalogservice__http__0": "{catalogservice.bindings.http.url}", + "services__catalogservice__https__0": "{catalogservice.bindings.https.url}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "external": true + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http", + "external": true + } + } + }, + "orderprocessor": { + "type": "project.v0", + "path": "../OrderProcessor/OrderProcessor.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ConnectionStrings__messaging": "{messaging.connectionString}" + } + }, + "apigateway": { + "type": "project.v0", + "path": "../ApiGateway/ApiGateway.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "services__basketservice__http__0": "{basketservice.bindings.http.url}", + "services__basketservice__https__0": "{basketservice.bindings.https.url}", + "services__catalogservice__http__0": "{catalogservice.bindings.http.url}", + "services__catalogservice__https__0": "{catalogservice.bindings.https.url}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + }, + "catalogdbapp": { + "type": "project.v0", + "path": "../CatalogDb/CatalogDb.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "ConnectionStrings__catalogdb": "{catalogdb.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + }, + "postgres-password": { + "type": "parameter.v0", + "value": "{postgres-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22 + } + } + } + } + } + } +}