From 2eabf437a175f311eb27bd83d542b62be1f76caf Mon Sep 17 00:00:00 2001 From: Mark Gottselig Date: Thu, 27 Jun 2024 13:20:27 -0600 Subject: [PATCH] Add support for passing secrets as docker build args --- docs/Writerside/topics/Build-Command.md | 2 + src/Aspirate.Commands/Commands/BaseCommand.cs | 2 +- .../Commands/Build/BuildCommand.cs | 16 ++++--- .../Commands/Build/BuildCommandHandler.cs | 2 + .../Commands/Build/BuildOptions.cs | 1 + .../Commands/Generate/GenerateOptions.cs | 1 + .../Options/UseSecretsOption.cs | 16 +++++++ .../Transformation/Literals.cs | 1 + .../ResourceExpressionProcessor.cs | 9 ++++ .../Commands/Contracts/IBuildOptions.cs | 1 + .../Models/Aspirate/AspirateState.cs | 3 ++ .../ManifestFileParserServiceTests.cs | 48 +++++++++++++++++++ .../TestData/dockerfile-with-build-args.json | 30 ++++++++++++ 13 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 src/Aspirate.Commands/Options/UseSecretsOption.cs create mode 100644 tests/Aspirate.Tests/TestData/dockerfile-with-build-args.json diff --git a/docs/Writerside/topics/Build-Command.md b/docs/Writerside/topics/Build-Command.md index 273f9fd..6398900 100644 --- a/docs/Writerside/topics/Build-Command.md +++ b/docs/Writerside/topics/Build-Command.md @@ -31,3 +31,5 @@ The command will first create the manifest file, however, this can be overridden | --runtime-identifier | | `ASPIRATE_RUNTIME_IDENTIFIER` | Sets the runtime identifier for project builds. Defaults to `linux-x64`. | | --compose-build | | | Can be included one or more times to set certain dockerfile resource building to be handled by the compose file. This will skip build and push in aspirate. | | --launch-profile | -lp | 'ASPIRATE_LAUNCH_PROFILE' | The launch profile to use when building the Aspire Manifest. | +| --use-secrets | | 'ASPIRATE_USE_SECRETS' | Will unlock the secrets so their values can be used for docker build arguments | +| --secret-password | | `ASPIRATE_SECRET_PASSWORD` | If using secrets, or you have a secret file - Specify the password to decrypt them. Required if `--use-secrets` and `--non-interactive` are set | diff --git a/src/Aspirate.Commands/Commands/BaseCommand.cs b/src/Aspirate.Commands/Commands/BaseCommand.cs index aa3646b..de8c269 100644 --- a/src/Aspirate.Commands/Commands/BaseCommand.cs +++ b/src/Aspirate.Commands/Commands/BaseCommand.cs @@ -56,7 +56,7 @@ private void LoadSecrets(TOptions options, ISecretService secretService, TOption DisableSecrets = handler.CurrentState.DisableSecrets, NonInteractive = options.NonInteractive, SecretPassword = options.SecretPassword, - CommandUnlocksSecrets = CommandUnlocksSecrets, + CommandUnlocksSecrets = CommandUnlocksSecrets || handler.CurrentState.UseSecrets == true, State = handler.CurrentState, }); diff --git a/src/Aspirate.Commands/Commands/Build/BuildCommand.cs b/src/Aspirate.Commands/Commands/Build/BuildCommand.cs index ea9c035..4818fc8 100644 --- a/src/Aspirate.Commands/Commands/Build/BuildCommand.cs +++ b/src/Aspirate.Commands/Commands/Build/BuildCommand.cs @@ -6,12 +6,14 @@ public sealed class BuildCommand : BaseCommand HandleAsync(BuildOptions options) => .QueueAction(nameof(LoadConfigurationAction)) .QueueAction(nameof(GenerateAspireManifestAction)) .QueueAction(nameof(LoadAspireManifestAction)) + .QueueAction(nameof(PopulateInputsAction)) + .QueueAction(nameof(SubstituteValuesAspireManifestAction)) .QueueAction(nameof(PopulateContainerDetailsForProjectsAction)) .QueueAction(nameof(BuildAndPushContainersFromProjectsAction)) .QueueAction(nameof(BuildAndPushContainersFromDockerfilesAction)) diff --git a/src/Aspirate.Commands/Commands/Build/BuildOptions.cs b/src/Aspirate.Commands/Commands/Build/BuildOptions.cs index c2f14e0..396b4a5 100644 --- a/src/Aspirate.Commands/Commands/Build/BuildOptions.cs +++ b/src/Aspirate.Commands/Commands/Build/BuildOptions.cs @@ -12,4 +12,5 @@ public sealed class BuildOptions : BaseCommandOptions, IBuildOptions, IContainer public List? ContainerImageTags { get; set; } public string? RuntimeIdentifier { get; set; } public List? ComposeBuilds { get; set; } + public bool? UseSecrets { get; set; } } diff --git a/src/Aspirate.Commands/Commands/Generate/GenerateOptions.cs b/src/Aspirate.Commands/Commands/Generate/GenerateOptions.cs index d49c326..a020e54 100644 --- a/src/Aspirate.Commands/Commands/Generate/GenerateOptions.cs +++ b/src/Aspirate.Commands/Commands/Generate/GenerateOptions.cs @@ -23,6 +23,7 @@ public sealed class GenerateOptions : BaseCommandOptions, public string? OutputFormat { get; set; } public string? RuntimeIdentifier { get; set; } public List? ComposeBuilds { get; set; } + public bool? UseSecrets { get; set; } public string? PrivateRegistryUrl { get; set; } public string? PrivateRegistryUsername { get; set; } public string? PrivateRegistryPassword { get; set; } diff --git a/src/Aspirate.Commands/Options/UseSecretsOption.cs b/src/Aspirate.Commands/Options/UseSecretsOption.cs new file mode 100644 index 0000000..dd55318 --- /dev/null +++ b/src/Aspirate.Commands/Options/UseSecretsOption.cs @@ -0,0 +1,16 @@ +namespace Aspirate.Commands.Options; + +public sealed class UseSecretsOption : BaseOption +{ + private static readonly string[] _aliases = ["--use-secrets"]; + + private UseSecretsOption() : base(_aliases, "ASPIRATE_USE_SECRETS", false) + { + Name = nameof(IBuildOptions.UseSecrets); + Description = "Include secrets as part of the build process"; + Arity = ArgumentArity.ZeroOrOne; + IsRequired = false; + } + + public static UseSecretsOption Instance { get; } = new(); +} diff --git a/src/Aspirate.Processors/Transformation/Literals.cs b/src/Aspirate.Processors/Transformation/Literals.cs index 75ebbc7..ed972cb 100644 --- a/src/Aspirate.Processors/Transformation/Literals.cs +++ b/src/Aspirate.Processors/Transformation/Literals.cs @@ -11,4 +11,5 @@ public static class Literals public const string ConnectionString = "connectionString"; public const string Host = "host"; public const string Url = "url"; + public const string BuildArgs = "buildArgs"; } diff --git a/src/Aspirate.Processors/Transformation/ResourceExpressionProcessor.cs b/src/Aspirate.Processors/Transformation/ResourceExpressionProcessor.cs index 48a94a2..c980b29 100644 --- a/src/Aspirate.Processors/Transformation/ResourceExpressionProcessor.cs +++ b/src/Aspirate.Processors/Transformation/ResourceExpressionProcessor.cs @@ -29,6 +29,15 @@ private static void HandleSubstitutions(Dictionary resources, case IResourceWithConnectionString resourceWithConnectionString when !string.IsNullOrEmpty(resourceWithConnectionString.ConnectionString): resourceWithConnectionString.ConnectionString = rootNode[key]![Literals.ConnectionString]!.ToString(); break; + case DockerfileResource dockerfileResource: + { + foreach (var buildArg in dockerfileResource.BuildArgs?.Keys ?? new(new ())) + { + dockerfileResource.BuildArgs[buildArg] = rootNode[key]![Literals.BuildArgs]![buildArg].ToString(); + } + + break; + } case ValueResource valueResource: { foreach (var resourceValue in valueResource.Values.ToList()) diff --git a/src/Aspirate.Shared/Interfaces/Commands/Contracts/IBuildOptions.cs b/src/Aspirate.Shared/Interfaces/Commands/Contracts/IBuildOptions.cs index 271eafe..7eb0c27 100644 --- a/src/Aspirate.Shared/Interfaces/Commands/Contracts/IBuildOptions.cs +++ b/src/Aspirate.Shared/Interfaces/Commands/Contracts/IBuildOptions.cs @@ -4,4 +4,5 @@ public interface IBuildOptions { string? RuntimeIdentifier { get; set; } List? ComposeBuilds { get; set; } + bool? UseSecrets { get; set; } } diff --git a/src/Aspirate.Shared/Models/Aspirate/AspirateState.cs b/src/Aspirate.Shared/Models/Aspirate/AspirateState.cs index 91eb387..949afa4 100644 --- a/src/Aspirate.Shared/Models/Aspirate/AspirateState.cs +++ b/src/Aspirate.Shared/Models/Aspirate/AspirateState.cs @@ -127,6 +127,9 @@ public class AspirateState : [JsonIgnore] public bool? SkipBuild { get; set; } + + [JsonIgnore] + public bool? UseSecrets { get; set; } [JsonIgnore] public string? AspireManifest { get; set; } diff --git a/tests/Aspirate.Tests/ServiceTests/ManifestFileParserServiceTests.cs b/tests/Aspirate.Tests/ServiceTests/ManifestFileParserServiceTests.cs index 86b4fb3..c5311ae 100644 --- a/tests/Aspirate.Tests/ServiceTests/ManifestFileParserServiceTests.cs +++ b/tests/Aspirate.Tests/ServiceTests/ManifestFileParserServiceTests.cs @@ -175,6 +175,40 @@ public async Task EndToEndShop_ParsesSuccessfully() shopResource.Volumes[0].Name.Should().Be("basketcache-data"); } + [Fact] + public async Task EndToEndDockerfile_ParsesSuccessfully() + { + // Arrange + var fileSystem = new MockFileSystem(); + var manifestFile = "dockerfile-with-build-args.json"; + var testData = Path.Combine(AppContext.BaseDirectory, "TestData", manifestFile); + fileSystem.AddFile(manifestFile, new MockFileData(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 cachePopulator = + serviceProvider.GetRequiredKeyedService(nameof(BuildAndPushContainersFromDockerfilesAction)); + + // Act + var results = await PerformEndToEndTests(manifestFile, 2, serviceProvider, service, inputPopulator, valueSubstitutor); + var state = serviceProvider.GetRequiredService(); + state.AspireComponentsToProcess = state.LoadedAspireManifestResources + .Where(x => x.Value.Type == AspireComponentLiterals.Dockerfile).Select(x => x.Key).ToList(); + state.ContainerBuilder = "docker"; + await cachePopulator.ExecuteAsync(); + + //Assert + var clientResource = results["client"] as DockerfileResource; + clientResource.BuildArgs["NPM_TOKEN"].Length.Should().Be(22); + var shellExecutor = serviceProvider.GetRequiredService(); + await shellExecutor.Received(1).ExecuteCommand(Arg.Is(x => + x.Command == "docker" && + x.ArgumentsBuilder.RenderArguments(' ').Contains($"NPM_TOKEN=\"{clientResource.BuildArgs["NPM_TOKEN"]}\"") + )); + } + [Fact] public async Task EndToEndNodeJs_ParsesSuccessfully() { @@ -251,8 +285,10 @@ private static IServiceProvider CreateServiceProvider(IFileSystem? fileSystem = services.RegisterAspirateEssential(); services.RemoveAll(); services.RemoveAll(); + services.RemoveAll(); services.AddSingleton(console); services.AddSingleton(fileSystem); + services.AddSingleton(GetMockShellExecutionService()); services.AddSingleton(); return services.BuildServiceProvider(); @@ -268,4 +304,16 @@ private static void EnterPasswordInput(TestConsole console, string password) console.Input.PushTextWithEnter(password); console.Input.PushKey(ConsoleKey.Enter); } + + private static IShellExecutionService GetMockShellExecutionService() + { + var mock = Substitute.For(); + mock.IsCommandAvailable(Arg.Any()).Returns(new CommandAvailableResult() + { + FullPath = "", + IsAvailable = true, + }); + mock.ExecuteCommand(Arg.Any()).Returns(new ShellCommandResult(true,"","",0)); + return mock; + } } diff --git a/tests/Aspirate.Tests/TestData/dockerfile-with-build-args.json b/tests/Aspirate.Tests/TestData/dockerfile-with-build-args.json new file mode 100644 index 0000000..8d2d3c6 --- /dev/null +++ b/tests/Aspirate.Tests/TestData/dockerfile-with-build-args.json @@ -0,0 +1,30 @@ +{ + "resources": { + "npmToken": { + "type": "parameter.v0", + "value": "{npmToken.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22 + } + } + } + } + }, + "client": { + "type": "dockerfile.v0", + "path": "../aspirate-buildargs-bug.Client/Dockerfile", + "context": "../aspirate-buildargs-bug.Client", + "buildArgs": { + "NPM_TOKEN": "{npmToken.value}" + }, + "env": { + "NODE_ENV": "development" + } + } + } +}