diff --git a/docs/website/02-fundamentals/06-parameters.md b/docs/website/02-fundamentals/06-parameters.md index 012397a1c..6b0f0780f 100644 --- a/docs/website/02-fundamentals/06-parameters.md +++ b/docs/website/02-fundamentals/06-parameters.md @@ -85,6 +85,10 @@ SET MY_PARAMETER = SET FALLOUT_MY_PARAMETER = ``` +Environment variables are checked in this order: the exact parameter name, then the `FALLOUT_` or `NUKE_` prefixed name, then the bare name with casing and underscores ignored. Use the prefix when the bare name collides with an unrelated variable. For example, login shells on some systems export `HOST` with the machine's hostname, which would otherwise be read as the `Host` parameter. + +If a value cannot be converted, Fallout logs a warning and uses the default for that parameter. It does not stop the build. + ## Required Parameters You can specify a parameter as a [target requirement](../02-fundamentals/05-targets.md#requirements) using the following shorthand syntax: diff --git a/src/Fallout.Build/Execution/ParameterService.cs b/src/Fallout.Build/Execution/ParameterService.cs index 9ad5d6fe4..70ecd68ec 100644 --- a/src/Fallout.Build/Execution/ParameterService.cs +++ b/src/Fallout.Build/Execution/ParameterService.cs @@ -190,10 +190,18 @@ static string GetTrimmedName(string name) if (!Variables.TryGetValue(variableName, out var value)) { var trimmedVariableName = GetTrimmedName(variableName); - var alternativeValues = Variables - .Where(x => GetTrimmedName(x.Key).EqualsOrdinalIgnoreCase(trimmedVariableName) || + + // Prefixed names are matched before the bare parameter name. Otherwise an unrelated + // bare variable would shadow the prefixed one, for example the HOST variable that login + // shells on openSUSE export would shadow FALLOUT_HOST. + var prefixedValues = Variables + .Where(x => GetTrimmedName(x.Key).EqualsOrdinalIgnoreCase($"FALLOUT{trimmedVariableName}") || GetTrimmedName(x.Key).EqualsOrdinalIgnoreCase($"NUKE{trimmedVariableName}")).ToList(); + var alternativeValues = prefixedValues.Count > 0 + ? prefixedValues + : Variables.Where(x => GetTrimmedName(x.Key).EqualsOrdinalIgnoreCase(trimmedVariableName)).ToList(); + if (alternativeValues.Count > 1) { Log.Warning("Could not resolve {VariableName} since multiple values are provided", variableName); @@ -215,15 +223,17 @@ static string GetTrimmedName(string name) } catch (Exception ex) { - Assert.Fail(new[] - { - ex.Message, - $"Resolving parameter '{variableName}' failed. Environment variable was:", - value - }.JoinNewLine()); - - // ReSharper disable once HeuristicUnreachableCode - return null; + // The environment is shared with the operating system, so an unrelated variable such as + // the Unix HOST can match a parameter by accident. Built-in parameters are also resolved + // in FalloutBuild's static constructor, where throwing aborts the process before any + // target runs. Warn and use the default instead. + Log.Warning( + "Could not resolve {VariableName} from environment variable value {Value}. Using the default value instead. {Reason}", + variableName, + value, + ex.Message); + + return destinationType.GetDefaultValue(); } } } diff --git a/tests/Fallout.Build.Specs/ParameterServiceSpecs.cs b/tests/Fallout.Build.Specs/ParameterServiceSpecs.cs index 484212288..ff37345ef 100644 --- a/tests/Fallout.Build.Specs/ParameterServiceSpecs.cs +++ b/tests/Fallout.Build.Specs/ParameterServiceSpecs.cs @@ -1,13 +1,18 @@ using System; using System.Collections.Generic; using Fallout.Build.Execution; +using Fallout.Common.Execution; using Fallout.Common.IO; using FluentAssertions; +using Serilog; +using Serilog.Core; +using Serilog.Events; using Xunit; using static Fallout.Common.Utilities.ReflectionUtility; namespace Fallout.Common.Specs; +[Collection(ProcessGlobalStateCollection.Name)] public class ParameterServiceSpecs { private ParameterService GetService(string[] commandLineArguments = null, @@ -171,6 +176,147 @@ public void TestPrecedence() service.GetParameter("string", typeof(string), separator: null).Should().Be("commitMessage"); } + [Fact] + public void UnusableHostEnvironmentValue_UsesDefaultAndWarns() + { + // Arrange. The standard Unix HOST variable. FalloutBuild resolves the Host parameter + // from the environment before any target runs. + var environmentVariables = new Dictionary { ["HOST"] = "M2" }; + + // Act + var (value, warnings) = ResolveWithEnvironment("Host", typeof(Host), environmentVariables); + + // Assert + value.Should().BeNull(); + warnings.Should().ContainSingle().Which.Should().Contain("Host").And.Contain("M2"); + } + + [Fact] + public void UnusableEnvironmentValue_UsesDefaultAndWarns() + { + // Arrange + var environmentVariables = new Dictionary { ["VERBOSITY"] = "bogus" }; + + // Act + var (value, warnings) = ResolveWithEnvironment("Verbosity", typeof(Verbosity?), environmentVariables); + + // Assert + value.Should().BeNull(); + warnings.Should().ContainSingle().Which.Should().Contain("Verbosity").And.Contain("bogus"); + } + + [Fact] + public void SeveralMatchingEnvironmentValues_UseDefaultAndWarn() + { + // Arrange. Two spellings of the same parameter name, neither an exact match. + var environmentVariables = new Dictionary + { + ["verbosity"] = "Normal", + ["VERBOSITY"] = "Verbose" + }; + + // Act + var (value, warnings) = ResolveWithEnvironment("Verbosity", typeof(Verbosity?), environmentVariables); + + // Assert + value.Should().BeNull(); + warnings.Should().ContainSingle().Which.Should().Contain("multiple values are provided"); + } + + [Theory] + [InlineData("FALLOUT_VERBOSITY")] + [InlineData("fallout_verbosity")] + [InlineData("Fallout_Verbosity")] + public void PrefixedEnvironmentValue_IsResolved(string name) + { + // Arrange + var environmentVariables = new Dictionary { [name] = "Verbose" }; + + // Act + var (value, _) = ResolveWithEnvironment("Verbosity", typeof(Verbosity?), environmentVariables); + + // Assert + value.Should().Be(Verbosity.Verbose); + } + + [Fact] + public void PrefixedHostValue_TakesPrecedenceOverBareHost() + { + // Arrange. The Unix hostname variable is present and cannot be converted. + var environmentVariables = new Dictionary + { + ["HOST"] = "M2", + ["FALLOUT_HOST"] = "Terminal" + }; + + // Act + var (value, warnings) = ResolveWithEnvironment("Host", typeof(Host), environmentVariables); + + // Assert + value.Should().BeOfType(); + warnings.Should().BeEmpty(); + } + + [Fact] + public void LegacyPrefixedHostValue_TakesPrecedenceOverBareHost() + { + // Arrange + var environmentVariables = new Dictionary + { + ["HOST"] = "M2", + ["NUKE_HOST"] = "Terminal" + }; + + // Act + var (value, warnings) = ResolveWithEnvironment("Host", typeof(Host), environmentVariables); + + // Assert + value.Should().BeOfType(); + warnings.Should().BeEmpty(); + } + + /// + /// Resolves one parameter from the given environment and returns it together with the warnings + /// logged for that parameter. Swaps the process-wide logger for the duration of the call, so + /// the sink keeps only events tagged with the parameter name. Test classes outside the shared + /// collection run in parallel and would otherwise leak their log output into the result. + /// + private (object Value, IReadOnlyList Warnings) ResolveWithEnvironment( + string parameterName, + Type destinationType, + IDictionary environmentVariables) + { + var service = GetService(environmentVariables: environmentVariables); + var sink = new ParameterWarningSink(parameterName); + var original = Log.Logger; + + try + { + Log.Logger = new LoggerConfiguration().WriteTo.Sink(sink, LogEventLevel.Warning).CreateLogger(); + var value = service.GetParameter(parameterName, destinationType, separator: null); + return (value, sink.Messages); + } + finally + { + Log.Logger = original; + } + } + + private class ParameterWarningSink(string parameterName) : ILogEventSink + { + public List Messages { get; } = new(); + + public void Emit(LogEvent logEvent) + { + if (logEvent.Properties.TryGetValue("VariableName", out var property) && + property is ScalarValue { Value: string name } && + name == parameterName) + { + Messages.Add(logEvent.RenderMessage()); + } + } + } + #pragma warning disable CS0649 private class TestBuild : FalloutBuild, ITestComponent {