diff --git a/.editorconfig b/.editorconfig index f8bf508..621d278 100644 --- a/.editorconfig +++ b/.editorconfig @@ -59,7 +59,7 @@ indent_size = 2 # Organize usings dotnet_separate_import_directive_groups = true dotnet_sort_system_directives_first = true -file_header_template = # this. and Me. preferences +file_header_template = # this. and Me. preferences dotnet_style_qualification_for_event = false:silent dotnet_style_qualification_for_field = false:silent @@ -287,31 +287,31 @@ dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase dotnet_naming_symbols.interfaces.applicable_kinds = interface dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interfaces.required_modifiers = +dotnet_naming_symbols.interfaces.required_modifiers = dotnet_naming_symbols.enums.applicable_kinds = enum dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.enums.required_modifiers = +dotnet_naming_symbols.enums.required_modifiers = dotnet_naming_symbols.events.applicable_kinds = event dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.events.required_modifiers = +dotnet_naming_symbols.events.required_modifiers = dotnet_naming_symbols.methods.applicable_kinds = method dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.methods.required_modifiers = +dotnet_naming_symbols.methods.required_modifiers = dotnet_naming_symbols.properties.applicable_kinds = property dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.properties.required_modifiers = +dotnet_naming_symbols.properties.required_modifiers = dotnet_naming_symbols.public_fields.applicable_kinds = field dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal -dotnet_naming_symbols.public_fields.required_modifiers = +dotnet_naming_symbols.public_fields.required_modifiers = dotnet_naming_symbols.private_fields.applicable_kinds = field dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_fields.required_modifiers = +dotnet_naming_symbols.private_fields.required_modifiers = dotnet_naming_symbols.private_static_fields.applicable_kinds = field dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected @@ -319,15 +319,15 @@ dotnet_naming_symbols.private_static_fields.required_modifiers = static dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types_and_namespaces.required_modifiers = +dotnet_naming_symbols.types_and_namespaces.required_modifiers = dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = +dotnet_naming_symbols.non_field_members.required_modifiers = dotnet_naming_symbols.type_parameters.applicable_kinds = namespace dotnet_naming_symbols.type_parameters.applicable_accessibilities = * -dotnet_naming_symbols.type_parameters.required_modifiers = +dotnet_naming_symbols.type_parameters.required_modifiers = dotnet_naming_symbols.private_constant_fields.applicable_kinds = field dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected @@ -335,7 +335,7 @@ dotnet_naming_symbols.private_constant_fields.required_modifiers = const dotnet_naming_symbols.local_variables.applicable_kinds = local dotnet_naming_symbols.local_variables.applicable_accessibilities = local -dotnet_naming_symbols.local_variables.required_modifiers = +dotnet_naming_symbols.local_variables.required_modifiers = dotnet_naming_symbols.local_constants.applicable_kinds = local dotnet_naming_symbols.local_constants.applicable_accessibilities = local @@ -343,7 +343,7 @@ dotnet_naming_symbols.local_constants.required_modifiers = const dotnet_naming_symbols.parameters.applicable_kinds = parameter dotnet_naming_symbols.parameters.applicable_accessibilities = * -dotnet_naming_symbols.parameters.required_modifiers = +dotnet_naming_symbols.parameters.required_modifiers = dotnet_naming_symbols.public_constant_fields.applicable_kinds = field dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal @@ -359,39 +359,40 @@ dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readon dotnet_naming_symbols.local_functions.applicable_kinds = local_function dotnet_naming_symbols.local_functions.applicable_accessibilities = * -dotnet_naming_symbols.local_functions.required_modifiers = +dotnet_naming_symbols.local_functions.required_modifiers = # Naming styles -dotnet_naming_style.pascalcase.required_prefix = -dotnet_naming_style.pascalcase.required_suffix = -dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = dotnet_naming_style.pascalcase.capitalization = pascal_case dotnet_naming_style.ipascalcase.required_prefix = I -dotnet_naming_style.ipascalcase.required_suffix = -dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = dotnet_naming_style.ipascalcase.capitalization = pascal_case dotnet_naming_style.tpascalcase.required_prefix = T -dotnet_naming_style.tpascalcase.required_suffix = -dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = dotnet_naming_style.tpascalcase.capitalization = pascal_case dotnet_naming_style._camelcase.required_prefix = _ -dotnet_naming_style._camelcase.required_suffix = -dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = dotnet_naming_style._camelcase.capitalization = camel_case -dotnet_naming_style.camelcase.required_prefix = -dotnet_naming_style.camelcase.required_suffix = -dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = dotnet_naming_style.camelcase.capitalization = camel_case # ReSharper properties resharper_arguments_literal = named resharper_arguments_string_literal = named resharper_autodetect_indent_settings = true +resharper_blank_lines_before_control_transfer_statements = 1 resharper_blank_lines_between_using_groups = 1 resharper_braces_for_for = required resharper_braces_for_foreach = required @@ -399,9 +400,11 @@ resharper_braces_for_ifelse = required resharper_braces_for_while = required resharper_braces_redundant = false resharper_csharp_case_block_braces = next_line_shifted_2 +resharper_csharp_wrap_after_declaration_lpar = true resharper_csharp_wrap_after_invocation_lpar = true resharper_csharp_wrap_arguments_style = chop_if_long resharper_csharp_wrap_before_invocation_rpar = true +resharper_csharp_wrap_parameters_style = chop_if_long resharper_default_value_when_type_not_evident = default_expression resharper_enforce_line_ending_style = true resharper_formatter_off_tag = @formatter:off @@ -411,10 +414,12 @@ resharper_for_built_in_types = use_explicit_type resharper_for_other_types = use_explicit_type resharper_for_simple_types = use_explicit_type resharper_keep_existing_declaration_block_arrangement = false -resharper_keep_existing_embedded_block_arrangement = true +resharper_keep_existing_embedded_block_arrangement = false resharper_keep_existing_enum_arrangement = true resharper_keep_existing_initializer_arrangement = false -resharper_keep_existing_linebreaks = false +resharper_keep_existing_linebreaks = true +resharper_keep_existing_primary_constructor_declaration_parens_arrangement = true +resharper_keep_user_linebreaks = true resharper_max_array_initializer_elements_on_line = 1 resharper_modifiers_order = public private protected internal static extern new virtual abstract sealed override readonly unsafe volatile async file required resharper_parentheses_group_non_obvious_operations = none, arithmetic, relational, conditional @@ -423,7 +428,10 @@ resharper_place_simple_initializer_on_single_line = false resharper_space_within_single_line_array_initializer_braces = false resharper_use_heuristics_for_body_style = false resharper_use_indent_from_vs = false +resharper_wrap_before_first_method_call = true resharper_wrap_before_primary_constructor_declaration_lpar = false +resharper_wrap_before_primary_constructor_declaration_rpar = true +resharper_wrap_chained_method_calls = chop_always # ReSharper inspection severities resharper_arrange_accessor_owner_body_highlighting = none diff --git a/.project-metadata.json b/.project-metadata.json index 5669bcb..f28874c 100644 --- a/.project-metadata.json +++ b/.project-metadata.json @@ -2,7 +2,7 @@ "name": "cicee", "description": "Runs continuous integration workloads via docker-compose, locally or on a build server.", "title": "Continuous Integration Containerized Execution Environment (CICEE)", - "version": "1.13.0", + "version": "1.14.0", "ciEnvironment": { "variables": [ { diff --git a/bin/cicee-local-compose.sh b/bin/cicee-local-compose.sh new file mode 100755 index 0000000..db0ccda --- /dev/null +++ b/bin/cicee-local-compose.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -o errexit # Fail or exit immediately if there is an error. +set -o nounset # Fail if an unset variable is used. +set -o pipefail # Fail pipelines if any command errors, not just the last one. + +# Build a local CICEE executable and use its exec command to invoke 'ci/bin/compose.sh'. + +# Context +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd .. && pwd)" +SCRIPT_LOCATION="$(dirname "${BASH_SOURCE[0]}")" + +"${SCRIPT_LOCATION}/cicee-local.sh" exec --harness direct --project-root "${PROJECT_ROOT}" --command "ci/bin/compose.sh" --verbosity "Verbose" diff --git a/bin/cicee-local-validate.sh b/bin/cicee-local-validate.sh new file mode 100755 index 0000000..9e7b485 --- /dev/null +++ b/bin/cicee-local-validate.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -o errexit # Fail or exit immediately if there is an error. +set -o nounset # Fail if an unset variable is used. +set -o pipefail # Fail pipelines if any command errors, not just the last one. + +# Build a local CICEE executable and use its exec command to invoke 'ci/bin/validate.sh'. + +# Context +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd .. && pwd)" +SCRIPT_LOCATION="$(dirname "${BASH_SOURCE[0]}")" + +"${SCRIPT_LOCATION}/cicee-local.sh" exec --harness direct --project-root "${PROJECT_ROOT}" --command "ci/bin/validate.sh" --verbosity "Verbose" diff --git a/bin/cicee-local.sh b/bin/cicee-local.sh new file mode 100755 index 0000000..192cdff --- /dev/null +++ b/bin/cicee-local.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -o errexit # Fail or exit immediately if there is an error. +set -o nounset # Fail if an unset variable is used. +set -o pipefail # Fail pipelines if any command errors, not just the last one. + +# Execute the normal cicee-exec.sh entrypoint (normally used by CICEE) from current source, providing the variables which CICEE would normally provide. + +# Context +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd .. && pwd)" +LOCAL_CICEE_TEMP_DIR="${TMPDIR}/cicee-entrypoint" + +function __generate_cicee_binary(){ + targetFramework="net8.0" + # Publish the application so we can work with CICEE similarly to other projects + # NOTE: Previous implementations used `dotnet run -- lib`. This stopped working. + # When using `dotnet run` the raw output to STDOUT is prefixed with invisible control characters. Those characters trigger file not found responses from `source `. + # However, if the DLL is executed with `dotnet ` then the output of STDOUT lacks the control characters and it can be loaded with `source`. + rm -rf "${LOCAL_CICEE_TEMP_DIR}" && + mkdir -p "${LOCAL_CICEE_TEMP_DIR}" && + dotnet publish "${PROJECT_ROOT}/src" --framework "${targetFramework}" --output "${LOCAL_CICEE_TEMP_DIR}" +} + +# Now run 'exec' using the Direct harness +__generate_cicee_binary && dotnet "${LOCAL_CICEE_TEMP_DIR}/cicee.dll" $@ diff --git a/ci/example.env.local.sh b/ci/example.env.local.sh old mode 100644 new mode 100755 diff --git a/src/Cicee.csproj b/src/Cicee.csproj index bfbd60d..effcaec 100644 --- a/src/Cicee.csproj +++ b/src/Cicee.csproj @@ -12,7 +12,7 @@ jds - Copyright (c) 2024 Jeremiah Sanders + Copyright (c) 2025 Jeremiah Sanders Continuous Integration Containerized Execution Environment cicee MIT diff --git a/src/Commands/Exec/ExecCommand.cs b/src/Commands/Exec/ExecCommand.cs index ed194cb..a661b78 100644 --- a/src/Commands/Exec/ExecCommand.cs +++ b/src/Commands/Exec/ExecCommand.cs @@ -51,28 +51,73 @@ private static Option ServiceCommandOption() }; } + private static Option InvocationHarnessOption() + { + return new Option( + new[] + { + "--harness", + "-h" + }, + () => ExecInvocationHarness.Script, + description: + "Invocation harness. Determines if CICEE directly invokes Docker commands or uses a shell script to invoke Docker commands." + ) + { + IsRequired = false + }; + } + + private static Option VerbosityOption() + { + return new Option( + new[] + { + "--verbosity", + "-v" + }, + () => ExecVerbosity.Normal, + description: + "Execution progress verbosity. Only applicable when using 'Direct' harness." + ) + { + IsRequired = false + }; + } + public static Command Create(CommandDependencies dependencies) { Option projectRoot = ProjectRootOption.Create(dependencies); Option serviceCommand = ServiceCommandOption(); Option serviceEntrypoint = ServiceEntrypointOption(); Option image = ImageOption(); + Option harness = InvocationHarnessOption(); + Option verbosity = VerbosityOption(); Command command = new(name: "exec", description: "Execute a command in a containerized execution environment.") { - projectRoot, serviceCommand, serviceEntrypoint, image + projectRoot, + serviceCommand, + serviceEntrypoint, + image, + harness, + verbosity }; - command.SetHandler( - (rootValue, commandValue, entrypointValue, imageValue) => ExecEntrypoint.HandleAsync( + command.SetHandler( + (rootValue, commandValue, entrypointValue, imageValue, harnessValue, verbosityValue) => ExecEntrypoint.HandleAsync( dependencies, rootValue, commandValue, entrypointValue, - imageValue + imageValue, + harnessValue, + verbosityValue ), projectRoot, serviceCommand, serviceEntrypoint, - image + image, + harness, + verbosity ); return command; } diff --git a/src/Commands/Exec/ExecEntrypoint.cs b/src/Commands/Exec/ExecEntrypoint.cs index 416f406..5d00da8 100644 --- a/src/Commands/Exec/ExecEntrypoint.cs +++ b/src/Commands/Exec/ExecEntrypoint.cs @@ -6,15 +6,21 @@ namespace Cicee.Commands.Exec; public static class ExecEntrypoint { - public static async Task HandleAsync(CommandDependencies dependencies, string projectRoot, string? command, - string? entrypoint, string? image) + public static async Task HandleAsync( + CommandDependencies dependencies, + string projectRoot, + string? command, + string? entrypoint, + string? image, + ExecInvocationHarness harness, + ExecVerbosity verbosity + ) { - return (await ExecHandling.HandleAsync(dependencies, new ExecRequest(projectRoot, command, entrypoint, image))) - .TapFailure( - exception => - { - dependencies.StandardErrorWriteLine(exception.ToExecutionFailureMessage()); - } - ).ToExitCode(); + ExecHandler handler = new(dependencies); + ExecRequest request = new(projectRoot, command, entrypoint, image, harness, verbosity); + + return (await handler.HandleAsync(request)) + .TapFailure(exception => dependencies.StandardErrorWriteLine(exception.ToExecutionFailureMessage())) + .ToExitCode(); } } diff --git a/src/Commands/Exec/ExecHandling.cs b/src/Commands/Exec/ExecHandling.cs index bf093c7..36a0ef8 100644 --- a/src/Commands/Exec/ExecHandling.cs +++ b/src/Commands/Exec/ExecHandling.cs @@ -1,214 +1,63 @@ -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; using System.Threading.Tasks; -using Cicee.CiEnv; +using Cicee.Commands.Exec.Handling; using Cicee.Dependencies; +using LanguageExt; using LanguageExt.Common; namespace Cicee.Commands.Exec; -public static class ExecHandling +public class ExecHandler { - private const string CiDirectoryName = Conventions.CiDirectoryName; - private const string ProjectName = "PROJECT_NAME"; - private const string ProjectRoot = "PROJECT_ROOT"; - private const string LibRoot = "LIB_ROOT"; - private const string CiCommand = "CI_COMMAND"; - private const string CiEntrypoint = "CI_ENTRYPOINT"; - private const string CiExecImage = "CI_EXEC_IMAGE"; + private readonly CommandDependencies _dependencies; - /// - /// Build context for ci-exec service. - /// - private const string CiExecContext = "CI_EXEC_CONTEXT"; - - private const string CiceeExecScriptName = "cicee-exec.sh"; - - public static Result CreateProcessStartInfo(CommandDependencies dependencies, - ExecRequestContext execRequestContext) - { - string ciceeExecPath = dependencies.CombinePath(dependencies.GetLibraryRootPath(), CiceeExecScriptName); - return dependencies.EnsureFileExists(ciceeExecPath).MapFailure( - exception => exception is FileNotFoundException - ? new BadRequestException($"Failed to find library file: {ciceeExecPath}") - : exception - ).Bind( - validatedCiceeExecPath => - { - string ciceeExecLinuxPath = Io.NormalizeToLinuxPath(validatedCiceeExecPath); - - return ProcessHelpers.TryCreateBashProcessStartInfo( - GetExecEnvironment(dependencies, execRequestContext), - new Dictionary(), - ciceeExecLinuxPath - ); - } - ); - } - - public static async Task> HandleAsync(CommandDependencies dependencies, ExecRequest request) - { - dependencies.StandardOutWriteLine(obj: "Beginning exec...\n"); - dependencies.StandardOutWriteLine($"Project root: {request.ProjectRoot}"); - dependencies.StandardOutWriteLine($"Entrypoint : {request.Entrypoint}"); - dependencies.StandardOutWriteLine($"Command : {request.Command}"); - - return (await TryCreateRequestContext(dependencies, request) - .Bind(execContext => ValidateContext(dependencies, execContext)) - .Map(context => DisplayExecContext(dependencies, context)) - .BindAsync(execContext => TryExecute(dependencies, execContext))).Map(context => new ExecResult(request)); - } - - private static string CreateCiDockerfilePath(CommandDependencies dependencies, ExecRequest request) + public ExecHandler(CommandDependencies dependencies) { - return dependencies.CombinePath(request.ProjectRoot, dependencies.CombinePath(CiDirectoryName, arg2: "Dockerfile")); + _dependencies = dependencies; } - public static Result TryCreateRequestContext(CommandDependencies dependencies, - ExecRequest request) + public Task> HandleAsync(ExecRequest request) { - return dependencies.EnsureDirectoryExists(request.ProjectRoot).Bind( - validatedProjectRoot => ProjectMetadataLoader.TryFindProjectMetadata( - dependencies.EnsureDirectoryExists, - dependencies.EnsureFileExists, - dependencies.TryLoadFileString, - dependencies.CombinePath, - validatedProjectRoot - ).Match( - value => new Result(value.ProjectMetadata), - loadFailure => - // We failed to load metadata, but we know that the project root exists. - ProjectMetadataLoader.InferProjectMetadata(dependencies, validatedProjectRoot) - ) - ).Bind( - projectMetadata => Require.AsResult.NotNullOrWhitespace(request.Image) - .BindFailure(_ => dependencies.EnsureFileExists(CreateCiDockerfilePath(dependencies, request))).MapFailure( - exception => new BadRequestException( - $"Image argument was not provided and '{CreateCiDockerfilePath(dependencies, request)}; does not exist.", - exception - ) - ).Map(_ => projectMetadata) - ).Map( - projectMetadata => - { - string? dockerfile = dependencies.EnsureFileExists( - dependencies.CombinePath(request.ProjectRoot, dependencies.CombinePath(CiDirectoryName, arg2: "Dockerfile")) - ).Match(file => (string?)file, _ => null); - return new ExecRequestContext( - request.ProjectRoot, - projectMetadata, - request.Command, - request.Entrypoint, - dockerfile, - request.Image - ); - } - ); - } - - private static ExecRequestContext DisplayExecContext(CommandDependencies dependencies, - ExecRequestContext execRequestContext) - { - DisplayProjectEnvironmentValues(); - DisplayExecEnvironmentValues(); - - return execRequestContext; - - void WriteEnvironmentVariables(IReadOnlyDictionary environmentDisplay) - { - int width = environmentDisplay.Keys.Max(value => value.Length) + 1; - foreach ((string key, string value) in environmentDisplay.OrderBy(kvp => kvp.Key)) - { - dependencies.StandardOutWriteLine($" {key.PadRight(width, paddingChar: ' ')}: {value}"); - } - } - - void DisplayProjectEnvironmentValues() - { - ProjectEnvironmentHelpers.DisplayProjectEnvironmentValues( - dependencies.StandardOutWriteLine, - dependencies.StandardOutWrite, - ProjectEnvironmentHelpers.GetEnvironmentDisplay( - dependencies.GetEnvironmentVariables, - execRequestContext.ProjectMetadata - ) - ); - } - - void DisplayExecEnvironmentValues() - { - IReadOnlyDictionary environmentDisplay = GetExecEnvironment(dependencies, execRequestContext); - dependencies.StandardOutWriteLine(obj: "CICEE Execution Environment:"); - WriteEnvironmentVariables(environmentDisplay); - } + return HandleAsync(_dependencies, request); } - private static IReadOnlyDictionary GetExecEnvironment(CommandDependencies dependencies, - ExecRequestContext context) + public static async Task> HandleAsync(CommandDependencies dependencies, ExecRequest request) { - Dictionary environment = new() - { - [CiExecContext] = Io.NormalizeToLinuxPath(dependencies.CombinePath(context.ProjectRoot, CiDirectoryName)), - [ProjectName] = context.ProjectMetadata.Name, - [ProjectRoot] = Io.NormalizeToLinuxPath(context.ProjectRoot), - [LibRoot] = Io.NormalizeToLinuxPath(dependencies.GetLibraryRootPath()) - }; + DisplayRequest(dependencies, request); - ConditionallyAdd(CiCommand, context.Command); - ConditionallyAdd(CiEntrypoint, context.Entrypoint); - ConditionallyAdd(CiExecImage, context.Image); - - return environment; - - void ConditionallyAdd(string key, string? possibleValue) - { - if (!string.IsNullOrWhiteSpace(possibleValue)) - { - environment[key] = possibleValue!; - } - } + return (await IoContext + .TryCreateRequestContext(dependencies, request) + .Bind(execContext => IoContext.ValidateContext(dependencies, execContext)) + .Map(context => IoContext.DisplayExecContext(dependencies, context)) + .BindAsync(execContext => TryExecute(dependencies, execContext))).Map(context => new ExecResult(request)); } - - private static async Task> TryExecute(CommandDependencies dependencies, - ExecRequestContext execRequestContext) + private static void DisplayRequest(CommandDependencies dependencies, ExecRequest request) { - return (await CreateProcessStartInfo(dependencies, execRequestContext).BindAsync(dependencies.ProcessExecutor)).Map( - _ => execRequestContext - ); + dependencies.StandardOutWriteLine(obj: "Beginning exec...\n"); + dependencies.StandardOutWriteLine($"Project root: {request.ProjectRoot}"); + dependencies.StandardOutWriteLine($"Entrypoint : {request.Entrypoint}"); + dependencies.StandardOutWriteLine($"Command : {request.Command}"); } - private static Result ValidateContext(CommandDependencies dependencies, + private static Task> TryExecute(CommandDependencies dependencies, ExecRequestContext execRequestContext) { - return RequireStartupCommand(execRequestContext).Bind(RequireProjectRoot); + return execRequestContext.Harness is ExecInvocationHarness.Direct ? HandleDirectAsync() : HandleScriptAsync(); - static Result RequireStartupCommand(ExecRequestContext context) + async Task> HandleScriptAsync() { - // Require either a command - return Require.AsResult.NotNullOrWhitespace(context.Command).BindFailure( - missingCommandException => - // ... or an entrypoint - Require.AsResult.NotNullOrWhitespace(context.Entrypoint) - ).MapFailure( - exception => new BadRequestException( - message: "At least one of command or entrypoint must be provided.", - exception - ) - ).Map(_ => context); + return (await ScriptHarness + .CreateProcessStartInfo(dependencies, execRequestContext) + .BindAsync(dependencies.ProcessExecutor)).Map(_ => execRequestContext); } - Result RequireProjectRoot(ExecRequestContext contextWithStartupCommand) + async Task> HandleDirectAsync() { - return dependencies.EnsureDirectoryExists(contextWithStartupCommand.ProjectRoot).MapFailure( - exception => exception is DirectoryNotFoundException - ? new BadRequestException($"Project root '{contextWithStartupCommand.ProjectRoot}' cannot be found.") - : exception - ).Map(projectRoot => contextWithStartupCommand); + return await Prelude + .TryAsync(() => DirectHarness.InvokeDockerCommandsAsync(dependencies, execRequestContext)) + .Try(); } } } diff --git a/src/Commands/Exec/ExecInvocationHarness.cs b/src/Commands/Exec/ExecInvocationHarness.cs new file mode 100644 index 0000000..da88756 --- /dev/null +++ b/src/Commands/Exec/ExecInvocationHarness.cs @@ -0,0 +1,7 @@ +namespace Cicee.Commands.Exec; + +public enum ExecInvocationHarness +{ + Script, + Direct +} diff --git a/src/Commands/Exec/ExecRequest.cs b/src/Commands/Exec/ExecRequest.cs index da9ffbd..3adc658 100644 --- a/src/Commands/Exec/ExecRequest.cs +++ b/src/Commands/Exec/ExecRequest.cs @@ -3,4 +3,11 @@ namespace Cicee.Commands.Exec; [ExcludeFromCodeCoverage] -public record ExecRequest(string ProjectRoot, string? Command, string? Entrypoint, string? Image); +public record ExecRequest( + string ProjectRoot, + string? Command, + string? Entrypoint, + string? Image, + ExecInvocationHarness Harness, + ExecVerbosity Verbosity +); diff --git a/src/Commands/Exec/ExecRequestContext.cs b/src/Commands/Exec/ExecRequestContext.cs index 0b267b9..7056c81 100644 --- a/src/Commands/Exec/ExecRequestContext.cs +++ b/src/Commands/Exec/ExecRequestContext.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Cicee.CiEnv; @@ -11,4 +13,34 @@ public record ExecRequestContext( string? Command, string? Entrypoint, string? Dockerfile, - string? Image); + string? Image, + ExecInvocationHarness Harness, + ExecVerbosity Verbosity, + string? CiDirectory, + IReadOnlyList DockerComposeFiles, + string? LibRoot, + string CiDockerfileImageTag +) +{ + /// + /// Gets a value indicating whether Docker commands issued by direct harness have a "quiet" arguments passed. + /// + public bool DockerQuiet => Verbosity switch + { + ExecVerbosity.Normal => false, + ExecVerbosity.Quiet => true, + ExecVerbosity.Verbose => false, + _ => throw new ArgumentOutOfRangeException() + }; + + /// + /// Gets a value indicating whether debug logs are emitted by direct harness. + /// + public bool WorkflowQuiet => Verbosity switch + { + ExecVerbosity.Normal => true, + ExecVerbosity.Quiet => true, + ExecVerbosity.Verbose => false, + _ => throw new ArgumentOutOfRangeException() + }; +} diff --git a/src/Commands/Exec/ExecVerbosity.cs b/src/Commands/Exec/ExecVerbosity.cs new file mode 100644 index 0000000..5c26d5d --- /dev/null +++ b/src/Commands/Exec/ExecVerbosity.cs @@ -0,0 +1,8 @@ +namespace Cicee.Commands.Exec; + +public enum ExecVerbosity +{ + Normal, + Quiet, + Verbose, +} diff --git a/src/Commands/Exec/Handling/DirectHarness.cs b/src/Commands/Exec/Handling/DirectHarness.cs new file mode 100644 index 0000000..2a93d1f --- /dev/null +++ b/src/Commands/Exec/Handling/DirectHarness.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; + +using Cicee.Dependencies; + +using LanguageExt; +using LanguageExt.Common; + +namespace Cicee.Commands.Exec.Handling; + +public static class DirectHarness +{ + + /// + /// Executes when + /// is false. + /// + private static void MaybeLogDebug( + this CommandDependencies dependencies, + ExecRequestContext context, + string message, + ConsoleColor? color = null) + { + if (!context.WorkflowQuiet) + { + dependencies.LogDebug(message, color); + } + } + + public static Task InvokeDockerCommandsAsync( + CommandDependencies dependencies, + ExecRequestContext execRequestContext + ) + { + return Prelude + .Try( + () => + { + dependencies.MaybeLogDebug(execRequestContext, message: "Preparing to Arrange."); + + return Prelude.unit; + } + ) + .ToAsync() + .MapAsync(async _ => await Arrange(dependencies, execRequestContext)) + .TapSuccess( + res => dependencies.MaybeLogDebug(execRequestContext, message: "Arrangement complete. Preparing to Act.") + ) + .MapAsync(updatedContext => Act(dependencies, updatedContext)) + .TapSuccess( + res => dependencies.MaybeLogDebug(execRequestContext, message: "Act complete. Preparing to Cleanup.") + ) + .MapAsync(updatedContext => Cleanup(dependencies, updatedContext)) + .TapSuccess(res => dependencies.MaybeLogDebug(execRequestContext, message: "Cleanup complete.")) + .IfFail(exception => Cleanup(dependencies, execRequestContext, exception)); + } + + private static Task Arrange( + CommandDependencies dependencies, + ExecRequestContext execRequestContext + ) + { + return Prelude + // TODO: Reconsider local Dockerfile build and cache. Might be useful for performance. + // .TryAsync(ConditionallyBuildProjectCi) + // .MapAsync(_ => PullDependencies()) + .TryAsync(PullDependencies) + .IfFailThrow(); + + async Task ConditionallyBuildProjectCi() + { + // This method is currently unused. + bool skipBuild = execRequestContext.Dockerfile == null || execRequestContext.CiDirectory == null; + + return skipBuild + ? execRequestContext + : await ExecuteCommandRequiringSuccess( + dependencies, + execRequestContext, + (commandDependencies, context) => DockerBuild( + commandDependencies, + context, + execRequestContext.Dockerfile!, + execRequestContext.CiDirectory!, + execRequestContext.CiDockerfileImageTag + ) + ); + } + + Task PullDependencies() + { + return ExecuteCommandRequiringSuccess(dependencies, execRequestContext, DockerComposePull); + } + } + + private static async Task Act( + CommandDependencies dependencies, + ExecRequestContext execRequestContext) + { + return await ExecuteCommandRequiringSuccess( + dependencies, + execRequestContext, + DockerComposeUp + ); + } + + private static async Task Cleanup( + CommandDependencies dependencies, + ExecRequestContext execRequestContext, + Exception? exception = null + ) + { + ExecRequestContext composeDownResult = await ExecuteCommandRequiringSuccess( + dependencies, + execRequestContext, + DockerComposeDown + ); + // ExecRequestContext ciImageRemoveResult = await ExecuteCommandRequiringSuccess( + // dependencies, + // composeDownResult, + // DockerCiImageRemove + // ); + + return exception != null ? Prelude.raise(exception) : composeDownResult; + } + + private static ProcessStartInfo DockerCommand( + ExecRequestContext context, + CommandDependencies dependencies, + string command) + { + ProcessStartInfo info = new(HandlingConstants.DockerCommand, command) + { + WorkingDirectory = context.ProjectRoot, + UseShellExecute = false, + Environment = + { + [HandlingConstants.DockerComposeProjectName] = context.ProjectMetadata.Name + } + }; + + ApplyExecEnvironment(info); + + return info; + + ProcessStartInfo ApplyExecEnvironment(ProcessStartInfo initialInfo) + { + IReadOnlyDictionary execEnvironment = IoEnvironment.GetExecEnvironment( + dependencies, + context, + forcePathsToLinux: false + ); + foreach (KeyValuePair keyValuePair in execEnvironment) + { + initialInfo.Environment[keyValuePair.Key] = keyValuePair.Value; + } + + return initialInfo; + } + } + + private static ProcessStartInfo DockerBuild( + CommandDependencies dependencies, + ExecRequestContext context, + string verifiedCiFilePath, + string verifiedCiDirectory, + string imageTag + ) + { + string quiet = context.DockerQuiet + ? "--quiet " + : string.Empty; + + return DockerCommand( + context, + dependencies, + $"build {quiet}--pull --tag \"{imageTag}\" --file \"{verifiedCiFilePath}\" \"{verifiedCiDirectory}\"" + ); + } + + private static ProcessStartInfo DockerComposePull( + CommandDependencies dependencies, + ExecRequestContext execRequestContext + ) + { + /* +__pull_dependencies() { + # Explicit empty string default applied to prevent Docker Compose from reporting that it is defaulting to empty strings. + LIB_ROOT="${LIB_ROOT:-}" \ + CI_EXEC_CONTEXT="${CI_EXEC_CONTEXT:-}" \ + CI_ENTRYPOINT="${CI_ENTRYPOINT:-}" \ + CI_COMMAND="${CI_COMMAND:-}" \ + ${docker_compose_executable} \ + "${COMPOSE_FILE_ARGS[@]}" \ + pull \ + --ignore-pull-failures \ + --include-deps \ + ci-exec +} + */ + string composeFiles = string.Join( + separator: " ", + execRequestContext.DockerComposeFiles.Select(file => $"--file {file}") + ); + string quiet = execRequestContext.DockerQuiet + ? "--quiet " + : string.Empty; + + return DockerCommand( + execRequestContext, + dependencies, + $"compose {composeFiles} pull {quiet}--ignore-pull-failures --include-deps {HandlingConstants.DockerComposeServiceCiExec}" + ); + } + + private static ProcessStartInfo DockerComposeUp( + CommandDependencies dependencies, + ExecRequestContext execRequestContext + ) + { + /* + # Explicit empty string default applied to prevent Docker Compose from reporting that it is defaulting to empty strings. + LIB_ROOT="${LIB_ROOT:-}" \ + CI_EXEC_CONTEXT="${CI_EXEC_CONTEXT:-}" \ + CI_ENTRYPOINT="${CI_ENTRYPOINT:-}" \ + CI_COMMAND="${CI_COMMAND:-}" \ + COMPOSE_PROJECT_NAME="${PROJECT_NAME}" \ + ${docker_compose_executable} \ + "${COMPOSE_FILE_ARGS[@]}" \ + up \ + --abort-on-container-exit \ + --build \ + --renew-anon-volumes \ + --remove-orphans \ + ci-exec + */ + string composeFiles = string.Join( + separator: " ", + execRequestContext.DockerComposeFiles.Select(file => $"--file {file}") + ); + + return DockerCommand( + execRequestContext, + dependencies, + $"compose {composeFiles} up --abort-on-container-exit --build --renew-anon-volumes --remove-orphans {HandlingConstants.DockerComposeServiceCiExec}" + ); + } + + private static ProcessStartInfo DockerCiImageRemove( + CommandDependencies dependencies, + ExecRequestContext execRequestContext + ) + { + return DockerCommand( + execRequestContext, + dependencies, + $"image rm {execRequestContext.CiDockerfileImageTag}" + ); + } + + private static ProcessStartInfo DockerComposeDown( + CommandDependencies dependencies, + ExecRequestContext execRequestContext + ) + { + /* + # Explicit empty string default applied to prevent Docker Compose from reporting that it is defaulting to empty strings. + LIB_ROOT="${LIB_ROOT:-}" \ + CI_EXEC_CONTEXT="${CI_EXEC_CONTEXT:-}" \ + CI_ENTRYPOINT="${CI_ENTRYPOINT:-}" \ + CI_COMMAND="${CI_COMMAND:-}" \ + COMPOSE_PROJECT_NAME="${PROJECT_NAME}" \ + ${docker_compose_executable} \ + "${COMPOSE_FILE_ARGS[@]}" \ + down \ + --volumes \ + --remove-orphans + */ + string composeFiles = string.Join( + separator: " ", + execRequestContext.DockerComposeFiles.Select(file => $"--file {file}") + ); + + return DockerCommand( + execRequestContext, + dependencies, + $"compose {composeFiles} down --volumes --remove-orphans" + ); + } + + private static async Task ExecuteCommandRequiringSuccess( + CommandDependencies dependencies, + ExecRequestContext execRequestContext, + Func creatorFunc + ) + { + ProcessStartInfo processStartInfo = creatorFunc(dependencies, execRequestContext); + dependencies.MaybeLogDebug( + execRequestContext, + $"Preparing to execute: {processStartInfo.FileName} {processStartInfo.Arguments}" + ); + // dependencies.LogDebug($" Execution environment:{Environment.NewLine}{string.Join(Environment.NewLine, processStartInfo.Environment.Select(kvp=>$" {kvp.Key}={kvp.Value}"))}"); + + Result result = await dependencies.ProcessExecutor(processStartInfo); + + string? resultDisplay = result + .Map(_ => "successfully") + .IfFail( + exception => exception is ExecutionException executionException + ? $"in failure: {executionException.Message}" + : $"in failure: {exception}" + ); + + dependencies.MaybeLogDebug( + execRequestContext, + $"Completed execution {resultDisplay}.", + result.IsFaulted ? ConsoleColor.Red : null + ); + + ProcessExecResult success = result.IfFailThrow(); + success.RequireExitCodeZero(); + + return execRequestContext; + } +} diff --git a/src/Commands/Exec/Handling/IoContext.cs b/src/Commands/Exec/Handling/IoContext.cs new file mode 100644 index 0000000..8d8031f --- /dev/null +++ b/src/Commands/Exec/Handling/IoContext.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; + +using Cicee.CiEnv; +using Cicee.Dependencies; + +using LanguageExt.Common; + +namespace Cicee.Commands.Exec.Handling; + +[SuppressMessage(category: "ReSharper", checkId: "UnusedParameter.Local")] +public static class IoContext +{ + private static string CreateCiDockerfilePath(CommandDependencies dependencies, ExecRequest request) + { + return dependencies.CombinePath( + request.ProjectRoot, + dependencies.CombinePath(HandlingConstants.CiDirectoryName, arg2: "Dockerfile") + ); + } + + public static string CreateCiDockerfileImageTag(string projectMetadataName) + { + // TODO: Add a hash... preferably the Dockerfile + string modified = projectMetadataName + .Replace(oldValue: " ", string.Empty) + .Replace(oldValue: "\t", string.Empty) + .Replace(oldValue: "\r", string.Empty) + .Replace(oldValue: "\n", string.Empty) + .ToLowerInvariant() + .ToKebabCase(); + if (string.IsNullOrWhiteSpace(modified)) + { + modified = DateTime.Now.ToString(format: "yyyyMMdd-HHmmss"); + } + + return $"ci-env-{modified}"; + } + + public static ExecRequestContext DisplayExecContext( + CommandDependencies dependencies, + ExecRequestContext execRequestContext + ) + { + DisplayProjectEnvironmentValues(); + DisplayExecEnvironmentValues(); + + return execRequestContext; + + void WriteEnvironmentVariables(IReadOnlyDictionary environmentDisplay) + { + int width = environmentDisplay.Keys.Max(value => value.Length) + 1; + foreach ((string key, string value) in environmentDisplay.OrderBy(kvp => kvp.Key)) + { + dependencies.StandardOutWriteLine($" {key.PadRight(width, paddingChar: ' ')}: {value}"); + } + } + + void DisplayProjectEnvironmentValues() + { + ProjectEnvironmentHelpers.DisplayProjectEnvironmentValues( + dependencies.StandardOutWriteLine, + dependencies.StandardOutWrite, + ProjectEnvironmentHelpers.GetEnvironmentDisplay( + dependencies.GetEnvironmentVariables, + execRequestContext.ProjectMetadata + ) + ); + } + + void DisplayExecEnvironmentValues() + { + IReadOnlyDictionary environmentDisplay = + IoEnvironment.GetExecEnvironment(dependencies, execRequestContext, forcePathsToLinux: false); + dependencies.StandardOutWriteLine(obj: "CICEE Execution Environment:"); + WriteEnvironmentVariables(environmentDisplay); + } + } + + public static Result TryCreateRequestContext( + CommandDependencies dependencies, + ExecRequest request) + { + return dependencies + .EnsureDirectoryExists(request.ProjectRoot) + .Bind( + validatedProjectRoot => ProjectMetadataLoader + .TryFindProjectMetadata( + dependencies.EnsureDirectoryExists, + dependencies.EnsureFileExists, + dependencies.TryLoadFileString, + dependencies.CombinePath, + validatedProjectRoot + ) + .Match( + value => new Result(value.ProjectMetadata), + loadFailure => + // We failed to load metadata, but we know that the project root exists. + ProjectMetadataLoader.InferProjectMetadata(dependencies, validatedProjectRoot) + ) + ) + .Bind( + projectMetadata => Require + .AsResult.NotNullOrWhitespace(request.Image) + .BindFailure(_ => dependencies.EnsureFileExists(CreateCiDockerfilePath(dependencies, request))) + .MapFailure( + exception => new BadRequestException( + $"Image argument was not provided and '{CreateCiDockerfilePath(dependencies, request)}; does not exist.", + exception + ) + ) + .Map(_ => projectMetadata) + ) + .Map( + projectMetadata => + { + string? ciDirectory = dependencies + .EnsureDirectoryExists(dependencies.CombinePath(request.ProjectRoot, HandlingConstants.CiDirectoryName)) + .Match(string? (dir) => dir, _ => null); + string? dockerfile = ciDirectory == null + ? null + : dependencies + .EnsureFileExists(dependencies.CombinePath(ciDirectory, arg2: "Dockerfile")) + .Match(string? (file) => file, _ => null); + string[] dockerComposeFiles = GetDockerComposeFiles(); + + string? libRoot = dependencies + .EnsureDirectoryExists( + dependencies.CombinePath( + request.ProjectRoot, + dependencies.CombinePath(HandlingConstants.CiDirectoryName, Conventions.CiLibDirectoryName) + ) + ) + .Match(string? (dir) => dir, _ => null); + + // TODO: Reconsider keeping this image tag. Could be useful for caching. Currently automatic image build in direct harness is disabled. + string ciDockerfileImageTag = CreateCiDockerfileImageTag(projectMetadata.Name); + + string? image = !string.IsNullOrWhiteSpace(request.Image) + ? request.Image + : null; + + return new ExecRequestContext( + request.ProjectRoot, + projectMetadata, + request.Command, + request.Entrypoint, + dockerfile, + image, + request.Harness, + request.Verbosity, + ciDirectory, + dockerComposeFiles, + libRoot, + ciDockerfileImageTag + ); + } + ); + + IEnumerable EnumerateIfExists(string pathToCheck, string? fallback = null) + { + return dependencies + .EnsureFileExists(pathToCheck) + .BindFailure( + exception => fallback != null ? dependencies.EnsureFileExists(fallback) : new Result(exception) + ) + .Map( + value => (IEnumerable)new[] + { + value + } + ) + .IfFail(Enumerable.Empty()); + } + + IEnumerable EnumerateChildFileIfExists( + string directory, + string fileName, + string? fallbackDirectory = null, + string? fallbackFileName = null) + { + return EnumerateIfExists( + dependencies.CombinePath(directory, fileName), + fallbackFileName == null ? null : dependencies.CombinePath(fallbackDirectory ?? directory, fallbackFileName) + ); + } + + string[] GetDockerComposeFiles() + { + /* +declare -r DOCKERCOMPOSE_DEPENDENCIES_CI="${PROJECT_ROOT}/ci/docker-compose.dependencies.yml" +declare -r DOCKERCOMPOSE_DEPENDENCIES_ROOT="${PROJECT_ROOT}/docker-compose.ci.dependencies.yml" +declare -r DOCKERCOMPOSE_CICEE="${LIB_ROOT}/docker-compose.yml" +declare -r DOCKERCOMPOSE_PROJECT_CI="${PROJECT_ROOT}/ci/docker-compose.project.yml" +declare -r DOCKERCOMPOSE_PROJECT_ROOT="${PROJECT_ROOT}/docker-compose.ci.project.yml" + */ + string ciceeLibRoot = dependencies.GetLibraryRootPath(); + string projectRoot = request.ProjectRoot; + string ciRoot = dependencies.CombinePath(projectRoot, HandlingConstants.CiDirectoryName); + + IEnumerable composeFiles = ArraySegment.Empty; +// Use project docker-compose as the primary file (by loading it first). Affects docker container name generation. + composeFiles = composeFiles.Concat( + EnumerateChildFileIfExists( + ciRoot, + fileName: "docker-compose.project.yml", + projectRoot, + fallbackFileName: "docker-compose.ci.project.yml" + ) + ); +// Add dependencies + composeFiles = composeFiles.Concat( + EnumerateChildFileIfExists( + ciRoot, + fileName: "docker-compose.dependencies.yml", + projectRoot, + fallbackFileName: "docker-compose.ci.dependencies.yml" + ) + ); +// Add CICEE + composeFiles = composeFiles.Concat( + new[] + { + dependencies.CombinePath(ciceeLibRoot, arg2: "docker-compose.yml") + } + ); +// - Import the ci-exec service image source (Dockerfile or image) + bool useImage = !string.IsNullOrWhiteSpace(request.Image); // || request.Harness == ExecInvocationHarness.Direct; + composeFiles = composeFiles.Concat( + useImage + ? new[] + { + dependencies.CombinePath(ciceeLibRoot, arg2: "docker-compose.image.yml") + } + : new[] + { + dependencies.CombinePath(ciceeLibRoot, arg2: "docker-compose.dockerfile.yml") + } + ); +// Re-add project, to load project settings last (to override all other dependencies, e.g., CICEE defaults). + composeFiles = composeFiles.Concat( + EnumerateChildFileIfExists( + ciRoot, + fileName: "docker-compose.project.yml", + projectRoot, + fallbackFileName: "docker-compose.ci.project.yml" + ) + ); + + + return composeFiles.ToArray(); + } + } + + public static Result ValidateContext( + CommandDependencies dependencies, + ExecRequestContext execRequestContext + ) + { + return RequireStartupCommand(execRequestContext) + .Bind(RequireProjectRoot); + + static Result RequireStartupCommand(ExecRequestContext context) + { + // Require either a command + return Require + .AsResult.NotNullOrWhitespace(context.Command) + .BindFailure( + missingCommandException => + // ... or an entrypoint + Require.AsResult.NotNullOrWhitespace(context.Entrypoint) + ) + .MapFailure( + exception => new BadRequestException( + message: "At least one of command or entrypoint must be provided.", + exception + ) + ) + .Map(_ => context); + } + + Result RequireProjectRoot(ExecRequestContext contextWithStartupCommand) + { + return dependencies + .EnsureDirectoryExists(contextWithStartupCommand.ProjectRoot) + .MapFailure( + exception => exception is DirectoryNotFoundException + ? new BadRequestException($"Project root '{contextWithStartupCommand.ProjectRoot}' cannot be found.") + : exception + ) + .Map(projectRoot => contextWithStartupCommand); + } + } +} diff --git a/src/Commands/Exec/Handling/IoEnvironment.cs b/src/Commands/Exec/Handling/IoEnvironment.cs new file mode 100644 index 0000000..f25b0a7 --- /dev/null +++ b/src/Commands/Exec/Handling/IoEnvironment.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +using Cicee.Dependencies; + +namespace Cicee.Commands.Exec.Handling; + +public static class IoEnvironment +{ + public static IReadOnlyDictionary GetExecEnvironment( + CommandDependencies dependencies, + ExecRequestContext context, + bool forcePathsToLinux + ) + { + Dictionary environment = new() + { + [HandlingConstants.CiExecContext] = ConditionalConvert( + dependencies.CombinePath(context.ProjectRoot, HandlingConstants.CiDirectoryName) + ), + [HandlingConstants.ProjectName] = context.ProjectMetadata.Name, + [HandlingConstants.ProjectRoot] = ConditionalConvert(context.ProjectRoot), + [HandlingConstants.LibRoot] = ConditionalConvert(dependencies.GetLibraryRootPath()), + // Explicit empty string default applied to prevent Docker Compose reporting that it is defaulting to empty strings. + [HandlingConstants.CiCommand] = context.Command ?? string.Empty, + [HandlingConstants.CiEntrypoint] = context.Entrypoint ?? string.Empty + }; + + ConditionallyAdd(HandlingConstants.CiExecImage, context.Image); + + return environment; + + void ConditionallyAdd(string key, string? possibleValue) + { + if (!string.IsNullOrWhiteSpace(possibleValue)) + { + environment[key] = possibleValue!; + } + } + + string ConditionalConvert(string path) + { + return forcePathsToLinux ? Io.NormalizeToLinuxPath(path) : path; + } + } +} diff --git a/src/Commands/Exec/Handling/ScriptHarness.cs b/src/Commands/Exec/Handling/ScriptHarness.cs new file mode 100644 index 0000000..f443c08 --- /dev/null +++ b/src/Commands/Exec/Handling/ScriptHarness.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +using Cicee.Dependencies; + +using LanguageExt.Common; + +namespace Cicee.Commands.Exec.Handling; + +public static class ScriptHarness +{ + public static Result CreateProcessStartInfo( + CommandDependencies dependencies, + ExecRequestContext execRequestContext + ) + { + string ciceeExecPath = dependencies.CombinePath( + dependencies.GetLibraryRootPath(), + HandlingConstants.CiceeExecScriptName + ); + + return dependencies + .EnsureFileExists(ciceeExecPath) + .MapFailure( + exception => exception is FileNotFoundException + ? new BadRequestException($"Failed to find library file: {ciceeExecPath}") + : exception + ) + .Bind( + validatedCiceeExecPath => + { + string ciceeExecLinuxPath = Io.NormalizeToLinuxPath(validatedCiceeExecPath); + + return ProcessHelpers.TryCreateBashProcessStartInfo( + IoEnvironment.GetExecEnvironment(dependencies, execRequestContext, true), + new Dictionary(), + ciceeExecLinuxPath + ); + } + ); + } +} diff --git a/src/Commands/Exec/HandlingConstants.cs b/src/Commands/Exec/HandlingConstants.cs new file mode 100644 index 0000000..b2f8de7 --- /dev/null +++ b/src/Commands/Exec/HandlingConstants.cs @@ -0,0 +1,27 @@ +using Cicee.CiEnv; + +namespace Cicee.Commands.Exec; + +internal static class HandlingConstants +{ + public const string CiDirectoryName = Conventions.CiDirectoryName; + public const string ProjectName = "PROJECT_NAME"; + public const string ProjectRoot = "PROJECT_ROOT"; + public const string LibRoot = "LIB_ROOT"; + public const string CiCommand = "CI_COMMAND"; + public const string CiEntrypoint = "CI_ENTRYPOINT"; + public const string CiExecImage = "CI_EXEC_IMAGE"; + + public const string DockerComposeServiceCiExec = "ci-exec"; + + /// + /// Build context for ci-exec service. + /// + public const string CiExecContext = "CI_EXEC_CONTEXT"; + + public const string CiceeExecScriptName = "cicee-exec.sh"; + + public const string DockerCommand = "docker"; + public const string DockerComposeArgument = "compose"; + public const string DockerComposeProjectName = "COMPOSE_PROJECT_NAME"; +} diff --git a/src/Dependencies/CommandDependencies.cs b/src/Dependencies/CommandDependencies.cs index 8ee8abd..92544fa 100644 --- a/src/Dependencies/CommandDependencies.cs +++ b/src/Dependencies/CommandDependencies.cs @@ -19,7 +19,7 @@ namespace Cicee.Dependencies; /// /// /// -/// +/// Gets the CICEE assembly's lib content directory path. /// /// /// @@ -48,7 +48,8 @@ public record CommandDependencies( Func>> TryCopyDirectoryAsync, Func> TryGetCurrentDirectory, Func> TryGetParentDirectory, - Action StandardOutWrite) + Action StandardOutWrite +) { /// /// Initializes a new instance of using the default environment providers. @@ -109,4 +110,14 @@ public void StandardOutWriteAsLine(IEnumerable<(ConsoleColor? OptionalColor, str StandardOutWriteAll(items); StandardOutWrite(arg1: null, Environment.NewLine); } + + public void LogDebug(string message, ConsoleColor? color = null) + { + StandardOutWriteAsLine( + new[] + { + ((ConsoleColor?)(color ?? ConsoleColor.Magenta), message) + } + ); + } } diff --git a/src/Dependencies/Io.cs b/src/Dependencies/Io.cs index 587f1cc..b06b9fc 100644 --- a/src/Dependencies/Io.cs +++ b/src/Dependencies/Io.cs @@ -54,6 +54,9 @@ static string WindowsToLinuxPath(string path) } } + /// + /// Gets the CICEE assembly's lib content directory path. + /// public static string GetLibraryRootPath() { string executionPath = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)!; diff --git a/src/Dependencies/ProcessExecResultExtensions.cs b/src/Dependencies/ProcessExecResultExtensions.cs new file mode 100644 index 0000000..a2d9c54 --- /dev/null +++ b/src/Dependencies/ProcessExecResultExtensions.cs @@ -0,0 +1,16 @@ +using System; + +namespace Cicee.Dependencies; + +public static class ProcessExecResultExtensions +{ + public static ProcessExecResult RequireExitCodeZero(this ProcessExecResult processExecResult) + { + if (processExecResult.ExitCode != 0) + { + throw new InvalidOperationException($"Process returned non-zero exit code: {processExecResult.ExitCode}"); + } + + return processExecResult; + } +} diff --git a/src/Dependencies/ProcessHelpers.cs b/src/Dependencies/ProcessHelpers.cs index 7f57995..4b48bb6 100644 --- a/src/Dependencies/ProcessHelpers.cs +++ b/src/Dependencies/ProcessHelpers.cs @@ -25,9 +25,10 @@ public static (string BashPath, bool IsWsl) TryFindBash() // On Windows, 'bash' is provided by WSL. That maintains separate environment variables. // We need to use Git Bash, if it exists. string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - if (!string.IsNullOrWhiteSpace(programFiles)) + + if (!string.IsNullOrWhiteSpace(programFiles) && IsEnvironmentWindows()) { - // Program Files exists; we must be on Windows. + // Program Files is defined and OS appears to be Windows. string expectedGitBashPath = Path.Combine(programFiles, path2: "Git", path3: "bin", path4: "bash.exe"); if (File.Exists(expectedGitBashPath)) @@ -50,40 +51,59 @@ public static (string BashPath, bool IsWsl) TryFindBash() } return (path, isWsl); + + bool IsEnvironmentWindows() + { + return Environment.OSVersion.Platform switch + { + PlatformID.Win32S => true, + PlatformID.Win32Windows => true, + PlatformID.Win32NT => true, + PlatformID.WinCE => true, + PlatformID.Unix => false, + PlatformID.Xbox => false, + PlatformID.MacOSX => false, + PlatformID.Other => false, + _ => false + }; + } } - public static Task> ExecuteProcessAsync(ProcessStartInfo processStartInfo, + public static Task> ExecuteProcessAsync( + ProcessStartInfo processStartInfo, Action? debugLogger = null) { - return Prelude.TryAsync( - async () => - { - debugLogger?.Invoke( - $"Starting process.\n Filename: {processStartInfo.FileName}\n Arguments: {processStartInfo.Arguments}" - ); - Process? process = Process.Start(processStartInfo); - if (process == null) + return Prelude + .TryAsync( + async () => { - throw new ExecutionException($"Failed to start process {processStartInfo.FileName}", exitCode: 1); - } - - debugLogger?.Invoke($"Process filename {processStartInfo.FileName} started with id {process.Id}."); - await process.WaitForExitAsync(); - debugLogger?.Invoke($"Process {process.Id} exited with exit code {process.ExitCode}."); - if (process.ExitCode != 0) - { - throw new ExecutionException( - $"{processStartInfo.FileName} returned non-zero exit code: {process.ExitCode}", - process.ExitCode + debugLogger?.Invoke( + $"Starting process.\n Filename: {processStartInfo.FileName}\n Arguments: {processStartInfo.Arguments}" ); + Process? process = Process.Start(processStartInfo); + if (process == null) + { + throw new ExecutionException($"Failed to start process {processStartInfo.FileName}", exitCode: 1); + } + + debugLogger?.Invoke($"Process filename {processStartInfo.FileName} started with id {process.Id}."); + await process.WaitForExitAsync(); + debugLogger?.Invoke($"Process {process.Id} exited with exit code {process.ExitCode}."); + if (process.ExitCode != 0) + { + throw new ExecutionException( + $"{processStartInfo.FileName} returned non-zero exit code: {process.ExitCode}", + process.ExitCode + ); + } + + return new ProcessExecResult + { + ExitCode = process.ExitCode + }; } - - return new ProcessExecResult - { - ExitCode = process.ExitCode - }; - } - ).Try(); + ) + .Try(); } /// @@ -109,27 +129,31 @@ public static Task> ExecuteProcessAsync(ProcessStartIn /// /// public static Result TryCreateBashProcessStartInfo( - IReadOnlyDictionary requiredEnvironment, IReadOnlyDictionary ambientEnvironment, + IReadOnlyDictionary requiredEnvironment, + IReadOnlyDictionary ambientEnvironment, string arguments) { (string bashPath, bool isWslBash) = TryFindBash(); - return new Result(CreateProcessArguments(isWslBash)).Bind(ValidateArgumentsLength).Map( - validatedProcessArguments => - { - ProcessStartInfo startInfo = new(bashPath, validatedProcessArguments); - foreach (KeyValuePair keyValuePair in ambientEnvironment) - { - startInfo.Environment[keyValuePair.Key] = keyValuePair.Value; - } - foreach (KeyValuePair keyValuePair in requiredEnvironment) + return new Result(CreateProcessArguments(isWslBash)) + .Bind(ValidateArgumentsLength) + .Map( + validatedProcessArguments => { - startInfo.Environment[keyValuePair.Key] = keyValuePair.Value; + ProcessStartInfo startInfo = new(bashPath, validatedProcessArguments); + foreach (KeyValuePair keyValuePair in ambientEnvironment) + { + startInfo.Environment[keyValuePair.Key] = keyValuePair.Value; + } + + foreach (KeyValuePair keyValuePair in requiredEnvironment) + { + startInfo.Environment[keyValuePair.Key] = keyValuePair.Value; + } + + return startInfo; } - - return startInfo; - } - ); + ); string CreateProcessArguments(bool isWsl) { @@ -137,7 +161,8 @@ string CreateProcessArguments(bool isWsl) string environmentVariableAssignments = isWsl ? string.Join( separator: ' ', - requiredEnvironment.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) + requiredEnvironment + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) .Select(kvp => $"{kvp.Key}=\\\"{kvp.Value}\\\"") ) + " " : string.Empty; @@ -150,6 +175,7 @@ private static Result ValidateArgumentsLength(string processArguments) { // Maximum argument length reference: https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.arguments?view=net-5.0 const int inclusiveMaxArgumentsLength = 32698; + return processArguments.Length > inclusiveMaxArgumentsLength ? new Result( new ArgumentException( diff --git a/src/README.md b/src/README.md index aa228c7..bea9b21 100644 --- a/src/README.md +++ b/src/README.md @@ -10,7 +10,7 @@ CICEE also provides a [continuous integration shell function library][cicee-lib] * `bash`: bash shell * `docker`: Docker command-line interface -* `dotnet`: .NET SDK (`6.x`, `7.x`, and `8.x` supported) +* `dotnet`: .NET SDK (`6.x`, `7.x`, `8.x`, and `9.x` supported) ## Why use CICEE? diff --git a/tests/integration/Cicee.Tests.Integration.csproj b/tests/integration/Cicee.Tests.Integration.csproj index 7f724bc..7811f1d 100644 --- a/tests/integration/Cicee.Tests.Integration.csproj +++ b/tests/integration/Cicee.Tests.Integration.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0 10 enable false diff --git a/tests/unit/Assertions.cs b/tests/unit/Assertions.cs index 98783b2..c0d3658 100644 --- a/tests/unit/Assertions.cs +++ b/tests/unit/Assertions.cs @@ -2,6 +2,8 @@ using LanguageExt.Common; +using Shouldly; + using Xunit; namespace Cicee.Tests.Unit; @@ -18,7 +20,7 @@ public static void Equal(Result expectedResult, Result actualResult) actualResult.IfSucc( actual => { - Assert.Equal(expected, actual); + actual.ShouldBeEquivalentTo(expected); } ); _ = actualResult.IfFail( @@ -33,8 +35,8 @@ public static void Equal(Result expectedResult, Result actualResult) actualResult.IfFail( actual => { - Assert.Equal(exception.GetType(), actual.GetType()); - Assert.Equal(exception.Message, actual.Message); + actual.ShouldBeOfType(exception.GetType()); + actual.Message.ShouldBe(exception.Message); } ); } diff --git a/tests/unit/Cicee.Tests.Unit.csproj b/tests/unit/Cicee.Tests.Unit.csproj index 0da0b6f..87491e8 100644 --- a/tests/unit/Cicee.Tests.Unit.csproj +++ b/tests/unit/Cicee.Tests.Unit.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0 false 10 enable @@ -9,6 +9,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/unit/Commands/Exec/ExecCommandTests.cs b/tests/unit/Commands/Exec/ExecCommandTests.cs index fe9dca1..14fc044 100644 --- a/tests/unit/Commands/Exec/ExecCommandTests.cs +++ b/tests/unit/Commands/Exec/ExecCommandTests.cs @@ -63,6 +63,27 @@ public void ReturnsExpectedCommand() "-i" }, IsRequired: false + ), + new OptionValues( + Name: "harness", + Description: + "Invocation harness. Determines if CICEE directly invokes Docker commands or uses a shell script to invoke Docker commands.", + new[] + { + "--harness", + "-h" + }, + IsRequired: false + ), + new OptionValues( + Name: "verbosity", + Description: "Execution progress verbosity. Only applicable when using 'Direct' harness.", + new[] + { + "--verbosity", + "-v" + }, + IsRequired: false ) } ); diff --git a/tests/unit/Commands/Exec/ExecHandlingTests/CreateProcessStartInfo.cs b/tests/unit/Commands/Exec/ExecHandlingTests/CreateProcessStartInfo.cs index 2d2cf3f..3b44d9b 100644 --- a/tests/unit/Commands/Exec/ExecHandlingTests/CreateProcessStartInfo.cs +++ b/tests/unit/Commands/Exec/ExecHandlingTests/CreateProcessStartInfo.cs @@ -5,12 +5,15 @@ using Cicee.CiEnv; using Cicee.Commands.Exec; +using Cicee.Commands.Exec.Handling; using Cicee.Dependencies; using Jds.LanguageExt.Extras; using LanguageExt.Common; +using Shouldly; + using Xunit; namespace Cicee.Tests.Unit.Commands.Exec.ExecHandlingTests; @@ -19,19 +22,31 @@ public class CreateProcessStartInfo { private static ExecRequestContext CreateExecRequestContext() { + ProjectMetadata metadata = new() + { + CiEnvironment = new ProjectContinuousIntegrationEnvironmentDefinition(), + Name = "sample-project", + Title = "Sample Project", + Version = "0.7.2" + }; + return new ExecRequestContext( ProjectRoot: "/sample-project", - new ProjectMetadata - { - CiEnvironment = new ProjectContinuousIntegrationEnvironmentDefinition(), - Name = "sample-project", - Title = "Sample Project", - Version = "0.7.2" - }, + metadata, Command: "ls", Entrypoint: "-al", Dockerfile: "ci/Dockerfile", - Image: null + Image: null, + ExecInvocationHarness.Script, + ExecVerbosity.Normal, + CiDirectory: "/sample-project/ci/Dockerfile", + new[] + { + "/sample-project/ci/docker-compose.dependencies.yml", + "/sample-project/ci/docker-compose.project.yml" + }, + LibRoot: null, + IoContext.CreateCiDockerfileImageTag(metadata.Name) ); } @@ -54,7 +69,7 @@ public static IEnumerable GenerateTestCases() Arguments = "-c \"" + $"{happyPathDependencies.CombinePath(happyPathDependencies.GetLibraryRootPath(), arg2: "cicee-exec.sh")}\"", - Environment = happyPathDependencies.GetEnvironmentVariables() + Environment = IoEnvironment.GetExecEnvironment(happyPathDependencies, happyPathRequest, forcePathsToLinux: false) }; Result happyPathExpected = new(happyPathExpectedResult); @@ -73,10 +88,13 @@ public static IEnumerable GenerateTestCases() [Theory] [MemberData(nameof(GenerateTestCases))] - public void ReturnsExpectedProcessStartInfo(CommandDependencies dependencies, ExecRequestContext execRequestContext, + public void ReturnsExpectedProcessStartInfo( + CommandDependencies dependencies, + ExecRequestContext execRequestContext, Result expectedResult) { - Result actualResult = ExecHandling.CreateProcessStartInfo(dependencies, execRequestContext) + Result actualResult = ScriptHarness + .CreateProcessStartInfo(dependencies, execRequestContext) .Map( result => new ProcessStartInfoResult( result.FileName, @@ -93,14 +111,15 @@ public void ReturnsExpectedProcessStartInfo(CommandDependencies dependencies, Ex actualResult.IfSucc( actual => { - Assert.Equal(expected.FileName, actual.FileName); - Assert.Equal(expected.Arguments, actual.Arguments); - Assert.Equal( - expected.Environment, - // Selecting only those which are expected because actual keys will contain everything from the current test execution process (when ProcessStartInfo is created). - actual.Environment.Where(kvp => expected.Environment.Keys.Contains(kvp.Key)), - new KeyValuePairValueComparer() - ); + actual.FileName.ShouldBe(expected.FileName); + actual.Arguments.ShouldBe(expected.Arguments); + // Selecting only those which are expected because actual keys will contain everything from the current test execution process (when ProcessStartInfo is created). + var actualFilteredEnvironment = actual + .Environment.Where(kvp => expected.Environment.Keys.Contains(kvp.Key)) + .OrderBy(kvp => kvp.Key) + .ToList(); + var expectedEnv = expected.Environment.OrderBy(kvp => kvp.Key).ToList(); + actualFilteredEnvironment.ShouldBeEquivalentTo(expectedEnv); } ); actualResult.IfFailThrow(); @@ -124,5 +143,6 @@ public void ReturnsExpectedProcessStartInfo(CommandDependencies dependencies, Ex public record ProcessStartInfoResult( string FileName, string Arguments, - IReadOnlyDictionary Environment); + IReadOnlyDictionary Environment + ); } diff --git a/tests/unit/Commands/Exec/ExecHandlingTests/HandleAsync.cs b/tests/unit/Commands/Exec/ExecHandlingTests/HandleAsync.cs index 9f8c78c..5286695 100644 --- a/tests/unit/Commands/Exec/ExecHandlingTests/HandleAsync.cs +++ b/tests/unit/Commands/Exec/ExecHandlingTests/HandleAsync.cs @@ -35,7 +35,11 @@ public static IEnumerable GenerateTestCases() new ProjectEnvironmentVariable { Name = $"VARIABLE_{Guid.NewGuid().ToString(format: "D").Replace(oldValue: "-", newValue: "_")}", - DefaultValue = Randomization.Boolean() ? string.Empty : Guid.NewGuid().ToString(), + DefaultValue = Randomization.Boolean() + ? string.Empty + : Guid + .NewGuid() + .ToString(), Description = $"Description {Guid.NewGuid():D}", Required = Randomization.Boolean(), Secret = Randomization.Boolean() @@ -44,20 +48,21 @@ public static IEnumerable GenerateTestCases() } }; - Func combinePath = (path1, path2) => $"{path1}/{path2}"; CommandDependencies baseDependencies = DependencyHelper.CreateMockDependencies() with { DoesFileExist = file => { - string ciEnvPath = combinePath(defaultProjectRoot, combinePath(arg1: "ci", arg2: "ci.env")); - string projectMetadataPath = combinePath(defaultProjectRoot, arg2: ".project-metadata.json"); - string ciceeExecPath = combinePath(defaultLibraryRoot, arg2: "cicee-exec.sh"); - string ciDockerfilePath = combinePath(defaultProjectRoot, combinePath(arg1: "ci", arg2: "Dockerfile")); + string ciEnvPath = CombinePath(defaultProjectRoot, CombinePath(path1: "ci", path2: "ci.env")); + string projectMetadataPath = CombinePath(defaultProjectRoot, path2: ".project-metadata.json"); + string ciceeExecPath = CombinePath(defaultLibraryRoot, path2: "cicee-exec.sh"); + string ciDockerfilePath = CombinePath(defaultProjectRoot, CombinePath(path1: "ci", path2: "Dockerfile")); + return file == ciEnvPath || file == projectMetadataPath || file == ciceeExecPath || file == ciDockerfilePath; }, TryLoadFileString = file => { - string projectMetadataPath = combinePath(defaultProjectRoot, arg2: ".project-metadata.json"); + string projectMetadataPath = CombinePath(defaultProjectRoot, path2: ".project-metadata.json"); + return file == projectMetadataPath ? Json.TrySerialize(defaultProjectMetadata) : new Result(new FileNotFoundException(file)); @@ -69,18 +74,41 @@ public static IEnumerable GenerateTestCases() ), GetLibraryRootPath = () => defaultLibraryRoot }; - ExecRequest baseRequest = new(defaultProjectRoot, Command: "-al", Entrypoint: "ls", Image: null); - ExecResult baseResult = new(baseRequest); + ExecRequest baseScriptHandlingRequest = new( + defaultProjectRoot, + Command: "-al", + Entrypoint: "ls", + Image: null, + ExecInvocationHarness.Script, + ExecVerbosity.Normal + ); + ExecResult baseScriptHandlingResult = new(baseScriptHandlingRequest); + + ExecRequest baseDirectHandlingRequest = baseScriptHandlingRequest with + { + Harness = ExecInvocationHarness.Direct + }; + ExecResult baseDirectHandlingResult = new(baseDirectHandlingRequest); CommandDependencies happyPathDependencies = baseDependencies; - ExecRequest happyPathRequest = baseRequest; - Result happyPathResult = new(baseResult); + + ExecRequest happyPathScriptHandlingRequest = baseScriptHandlingRequest; + Result happyPathScriptHandlingResult = new(baseScriptHandlingResult); + + ExecRequest happyPathDirectHandlingRequest = baseDirectHandlingRequest; + ExecResult happyPathDirectHandlingResult = baseDirectHandlingResult; return new[] { - TestCase(happyPathDependencies, happyPathRequest, happyPathResult) + TestCase(happyPathDependencies, happyPathScriptHandlingRequest, happyPathScriptHandlingResult), + TestCase(happyPathDependencies, happyPathDirectHandlingRequest, happyPathDirectHandlingResult) }; + string CombinePath(string path1, string path2) + { + return $"{path1}/{path2}"; + } + object[] TestCase(CommandDependencies dependencies, ExecRequest request, Result expected) { return new object[] @@ -94,10 +122,12 @@ object[] TestCase(CommandDependencies dependencies, ExecRequest request, Result< [Theory] [MemberData(nameof(GenerateTestCases))] - public async Task ReturnsExpectedResult(CommandDependencies dependencies, ExecRequest execRequest, + public async Task ReturnsExpectedResult( + CommandDependencies dependencies, + ExecRequest execRequest, Result expectedResult) { - Result actualResult = await ExecHandling.HandleAsync(dependencies, execRequest); + Result actualResult = await ExecHandler.HandleAsync(dependencies, execRequest); Assertions.Results.Equal(expectedResult, actualResult); } diff --git a/tests/unit/Commands/Exec/ExecHandlingTests/TryCreateRequestContext.cs b/tests/unit/Commands/Exec/ExecHandlingTests/TryCreateRequestContext.cs index 3058301..b118596 100644 --- a/tests/unit/Commands/Exec/ExecHandlingTests/TryCreateRequestContext.cs +++ b/tests/unit/Commands/Exec/ExecHandlingTests/TryCreateRequestContext.cs @@ -4,6 +4,7 @@ using Cicee.CiEnv; using Cicee.Commands.Exec; +using Cicee.Commands.Exec.Handling; using Cicee.Dependencies; using LanguageExt.Common; @@ -32,7 +33,11 @@ public static IEnumerable GenerateTestCases() new ProjectEnvironmentVariable { Name = $"VARIABLE_{Guid.NewGuid().ToString(format: "D").Replace(oldValue: "-", newValue: "_")}", - DefaultValue = Randomization.Boolean() ? string.Empty : Guid.NewGuid().ToString(), + DefaultValue = Randomization.Boolean() + ? string.Empty + : Guid + .NewGuid() + .ToString(), Description = $"Description {Guid.NewGuid():D}", Required = Randomization.Boolean(), Secret = Randomization.Boolean() @@ -49,24 +54,46 @@ public static IEnumerable GenerateTestCases() { string projectMetadataPath = combinePath(defaultProjectRoot, arg2: ".project-metadata.json"); string ciDockerfilePath = combinePath(defaultProjectRoot, combinePath(arg1: "ci", arg2: "Dockerfile")); + return file == projectMetadataPath || file == ciDockerfilePath; }, TryLoadFileString = file => { string projectMetadataPath = combinePath(defaultProjectRoot, arg2: ".project-metadata.json"); + return file == projectMetadataPath ? Json.TrySerialize(defaultProjectMetadata) : new Result(new FileNotFoundException(file)); } }; - ExecRequest baseRequest = new(defaultProjectRoot, Command: "-al", Entrypoint: "ls", Image: null); + ExecRequest baseRequest = new( + defaultProjectRoot, + Command: "-al", + Entrypoint: "ls", + Image: null, + ExecInvocationHarness.Script, + ExecVerbosity.Normal + ); ExecRequestContext baseResult = new( baseRequest.ProjectRoot, defaultProjectMetadata, baseRequest.Command, baseRequest.Entrypoint, combinePath(baseRequest.ProjectRoot, combinePath(arg1: "ci", arg2: "Dockerfile")), - Image: null + Image: null, + ExecInvocationHarness.Script, + ExecVerbosity.Normal, + combinePath(baseRequest.ProjectRoot, arg2: "ci"), + new[] + { + combinePath(baseRequest.ProjectRoot, combinePath(arg1: "ci", arg2: "docker-compose.project.yml")), + combinePath(baseRequest.ProjectRoot, combinePath(arg1: "ci", arg2: "docker-compose.dependencies.yml")), + combinePath(baseDependencies.GetLibraryRootPath(), arg2: "docker-compose.yml"), + combinePath(baseDependencies.GetLibraryRootPath(), arg2: "docker-compose.dockerfile.yml"), + combinePath(baseRequest.ProjectRoot, combinePath(arg1: "ci", arg2: "docker-compose.project.yml")) + }, + baseDependencies.CombinePath(baseRequest.ProjectRoot, baseDependencies.CombinePath(arg1: "ci", arg2: "lib")), + IoContext.CreateCiDockerfileImageTag(defaultProjectMetadata.Name) ); CommandDependencies happyPathDependencies = baseDependencies; @@ -91,10 +118,12 @@ object[] TestCase(CommandDependencies dependencies, ExecRequest request, Result< [Theory] [MemberData(nameof(GenerateTestCases))] - public void ReturnsExpectedProcessStartInfo(CommandDependencies dependencies, ExecRequest execRequest, + public void ReturnsExpectedProcessStartInfo( + CommandDependencies dependencies, + ExecRequest execRequest, Result expectedResult) { - Result actualResult = ExecHandling.TryCreateRequestContext(dependencies, execRequest); + Result actualResult = IoContext.TryCreateRequestContext(dependencies, execRequest); Assertions.Results.Equal(expectedResult, actualResult); }