Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experiment to use Microsoft.Extensions.Configuration #306

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

clrudolphi
Copy link
Contributor

to load the reqroll configuration json file and allow for Environment Variable Overrides.

🤔 What's changed?

Modified the configuration loading to leverage the Microsoft.Extensions.Configuration system to load the reqnroll.json file.

⚡️ What's your motivation?

  • Allow for Environment Variable overrides of configuration settings
  • Allow an extension point such that Plugins and other extensions of Reqnroll can add their own sections to the configuration file.

🏷️ What kind of change is this?

  • 🏦 Refactoring/debt/DX (improvement to code design, tooling, etc. without changing behaviour)
  • ⚡ New feature (non-breaking change which adds new behaviour)

♻️ Anything particular you want feedback on?

This PR just demonstrates the feasibility. Other areas to confirm:

  • is this the best way to go about this? Perhaps there is another seam to use in place of squirming in between the Configuration Loader and the JsonConfigurationLoader?
  • Impacts to Generation? (This only demonstrates loading at runtime)
  • Determine whether this approach could be used to load the old appSettings.config xml

📋 Checklist:

  • [X ] I've changed the behaviour of the code
    • [ X] I have added/updated tests to cover my changes.
  • [ X] My change requires a change to the documentation.
    • I have updated the documentation accordingly.
  • [X ] Users should know about my change
    • I have added an entry to the "[vNext]" section of the CHANGELOG, linking to this pull request & included my GitHub handle to the release contributors list.

…roll configuration json file and allow for Environment Variable Overrides.
@Code-Grump
Copy link
Contributor

This seems like a reasonable way to leverage the MS Configuration components. It does unfortunately highlight the problem of having a "JSON" configuration type as we're loading that information from multiple sources.

@clrudolphi
Copy link
Contributor Author

@gasparnagy in researching this configuration stuff, I see in a few places a couple of data structures that have the [Serializable] attribute on them and a serious warning in the comments about not modifying these for fear of breaking integration with the VS extension. Is that mechanism still in use to convey configuration information to the Extension?
These data structures seem to rely on the contents of the configuration files being read in as text (eg, the ReqnrollConfigurationHolder) and then parsed later after reading. This approach is going to get in the way of using the MS.Extension.Configuration system.

@Code-Grump
Copy link
Contributor

Does the extension have a good reason for needing to parse content itself? I expect we'll need to do some refactoring to providing configuration in a less-coupled way.

@gasparnagy
Copy link
Contributor

@clrudolphi @Code-Grump Good news, this issue has been eliminated once I ported SpecFlow to Reqnroll, so these restrictions are not applied anymore.

History: In the SpecFlow times, the VS extension loaded the bindings by invoking several SpecFlow classes and with an overridden implementations of different services, particularly the one that loads the configuration (because the configuration in that case was just available as a string and not necessarily in a file at a position where SpecFlow loaded it). This required that the VS extension loads a couple of SpecFlow types, but if these types are changed the integration broke. (Note that one version of VS extension has to work with many versions of SpecFlow/Reqnroll, so changing these interfaces were really a pain.)

Now with Reqnroll, I introduced a single static method: BindingProviderService.DiscoverBindings that communicates only with common types (string and Assembly), so the VS extension does not need to load Reqnroll types and therefore the cross-compatibility is much simpler. (The string it returns contains a JSON serialized data structure that cannot be changed, but this structure is special and only used for this interface. See https://github.com/reqnroll/Reqnroll/tree/main/Reqnroll/Bindings/Provider/Data)

So the [Serializable] attributes can be ignored/removed and you can now also change the classes that have the warning (and/or remove the warning).

Eliminated [Serializable] attributes in Generator classes as no longer needed.
@clrudolphi
Copy link
Contributor Author

clrudolphi commented Nov 19, 2024

Working on incorporating the use of the MS Extensions Configuration package in to the Generator.

What will need to be done to the nuspec so that the Microsoft.Extensions.Configuration.* assemblies get loaded properly?

At this point, the RuntimeTests and GeneratorTests can be executed successfully.

However, the SystemTests fail at the point that the generated solution is compiled ("Could not find file '\DefaultTestProject\DefaultTestProject.csproj'). The folder for the generated project is empty.

And the simplest Spec project fails with an error that the GenerateFeatureFileCodeBehindTask failed b/c the 'Microsoft.Extensions.Configuration.Json' assembly could not be found.

I've pushed my latest updates to this branch if you'd like to take a look.

…t}.json file content; where Environment is the value of the DOTNET_Environment environment variable.

Modified the ConfigurationLoader such that both code paths that load json-based config use the MSE loader.
@clrudolphi
Copy link
Contributor Author

Added support for Environment-based overrides.

@gasparnagy
Copy link
Contributor

@clrudolphi What will need to be done to the nuspec so that the Microsoft.Extensions.Configuration.* assemblies get loaded properly?

This is a highly undiscovered area as we never had a framework-related dependency so far in the generator that was not included in the framework by default. In order to be able to load the assembly, somehow you need to ensure that it is included in the tools\netstandard2.0 and the tools\net462 folders of the generated NuGet package. We ensure these with the <file> includes in the nuspec file.

For the tools\net462 this should work by default as we include everything that is in the bin/Debug folder, see here.

For the tools\netstandard2.0 this is a bit more tricky, because there we exclude by default everything that starts with System or Microsoft. See here. So you would need to add one more file include for the Microsoft.Extensions.Configuration.* assemblies to add them.

With this, the build will technically work, but then you need to test it with projects that have different versions of the Microsoft.Extensions.Configuration. But since during generation the user's assemblies are not loaded, there should be no conflict. But we need to test this.

@gasparnagy
Copy link
Contributor

gasparnagy commented Dec 3, 2024

@clrudolphi I did a quick review, but there is another issue that we need to clarify (cc @Code-Grump).

There are 5 (non-distinct!) groups of configuration data we have right now.

  1. Settings that are needed during runtime

    • language/*
    • runtime/*
    • trace/*
    • bindingAssemblies/*
  2. In the future: The configuration settings that users/plugins can add to our config system

  3. Settings that are needed for generation

    • language/feature
    • generator/*
  4. Settings that are needed for discovering the binging registry (what step definitions, hooks and transformations we have)

    • bindingAssemblies/*
    • language/binding - needed for regex calculator based on the step def method name
  5. Settings that are needed by the Visual Studio extension (or other IDE extensions)

    • language/feature - to parse the feature file
    • language/binding - to suggest culture-specific step definition snippets, e.g. understand either 1.2 (en) or 1,2 (de) as a floating point number
    • trace/stepDefinitionSkeletonStyle - to offer the expected step definition snippets
    • bindingAssemblies/* - to track step def usages from shared projects (not directly used currently)
    • everything that is in group 3, because it needs to discover the bindings

For group 1 (runtime) and group 2 (user config) the use of MS config is a quick win, because

  • it provides an easy extension point for group 2
  • these are the ones that change the runtime behavior and therefore might be overridden at runtime (env vars, different environments, etc.)

The other groups are more problematic, especially from the dynamic configuration nature's point of view.

  • Group 3 is effective at compile time. During compile time you cannot select "environments" (too early) and also the environment variables are also not so relevant. Also important to mention that the generation runs before compilation, so any config-file copy/transform magic you can do with MsBuild will not be applied.

  • Group 4 is also tricky, because it has to be performed by the VS extension as well where it is not possible to know how users will override these settings dynamically.

  • Group 5 needs to be processed by the VS extension and any other tooling that wants to parse the Gherkin files.

Currently Reqnroll works in a way, that for group 3, 4, 5 it allows to delegate the discovery of the JSON config content to the caller and provides an overload where you can pass in the JSON config content as a string. This is used quite heavily:

  • The generator finds the config file (and loads the content) based on the project settings
  • The VS extension finds the config file, tracks it's changes and loads the values for group 5 and passes in the config for discovering the bindings (group 4).

I have seen that in the PR (here) the content-based loading of configuration has been disabled. Maybe this is temporary only, but I need to highlight that this is potentially breaks the usage of groups 3,4,5 including the VS extension. So we will need to make this working somehow.

My suggestion would be the following: We should not allow dynamic config overrides for config settings in group 3,4,5. This is anyway only the minority of the config settings and it is anyway does not make sense to make these dynamic, so this would not limit the possibilities significantly, but would make the generator and the VS extension (and their ongoing development) much easier. Basically we would need to highlight in the docs that these settings cannot be overridden but always read from renqroll.json. If the user still tries, they will observe inconsistent behavior.

What do you think?

@clrudolphi
Copy link
Contributor Author

What do you think?

Thank you for the clarifications. This helps untangle several points of confusion for me. I will revert the change that dropped the ability to load the config as text content. My intent was to consolidate and simplify the codebase by eventually ripping that stuff out. Now I better understand why it is there.

I see three options for moving forward and would like input on which path to use. In considering these, I would propose that our set-up of the use of the MS.Ext.Configuration be something like this:

            configurationManager.AddEnvironmentVariables("DOTNET_");
            string envName = configurationManager["Environment"];

            _jsonConfigurationFilePath = reqnrollJsonLocator.GetReqnrollJsonFilePath();


            if (_jsonConfigurationFilePath != null)
            {
                var pathWithoutExt = Path.Combine(Path.GetDirectoryName(_jsonConfigurationFilePath),Path.GetFileNameWithoutExtension(_jsonConfigurationFilePath));
                var envOverridePath = $"{pathWithoutExt}.{envName}.json";
                configurationManager.AddJsonFile(_jsonConfigurationFilePath, optional: true, reloadOnChange: false);
                configurationManager.AddJsonFile(envOverridePath, optional: true, reloadOnChange: false);
            }
            configurationManager.AddEnvironmentVariables(prefix: "REQNROLL__");

In the above, the variable configurationManager is an instance of Microsoft.Extensions.Configuration.IConfigurationManager; the code pulls the Environment Name from the standard DotNet environment variable (DOTNET_Environment) and uses that to craft the name of the environment-specific override file for reqnroll.json (such as reqnroll.Development.json). It then configures the MS.Configuration.ConfigurationManager to use both the reqnroll.json and the reqnroll.{Environment}.json files and then overrides those values with any environment variables found that are prefixed with "REQNROLL__".

The three options:

  • Option 1: Use this setup ONLY for plugins. Leave existing configuration mechanisms and loading code-paths unchanged. This means that we would not support overriding the runtime/* elements.
  • Option 2: Use this setup for plugins and runtime/* elements. The existing json configuration loading mechanisms would be largely unchanged, except the JsonConfigurationLoader would use the MS.Configuration subsystem to fetch overrides for the runtime/* elements.
  • Option 3: Build a custom MS.Configuration.ConfigurationSource that mimics the JsonFile source that gets created when the .AddJsonFile() extension method is called on the ConfigurationManager. This custom source would be aware of the override behaviors we want and would not allow overrides of the groups 3,4, & 5 elements.

Option 1 is the simplest to implement as we initialize the MS.Config.ConfigurationManager, put it in the global container, and let the plug-ins use it from there.
Option 2 is slightly more effort. Has the benefit of allowing environmental overrides of the runtime/* elements.
Option 3 is significantly more effort, but with the advantage of maybe better long-term positioning for refactoring the entire configuration system to be cleaner.

If up to me, I would choose option 1. Its incremental and relatively clean. Doesn't preclude moving to the other options later.

@clrudolphi
Copy link
Contributor Author

@gasparnagy - side question - I see in the code that runtime/* supports a sub-element called dependencies which appear to be dependency registrations. This capability is not included in the Documentation. What are these used for?

@clrudolphi
Copy link
Contributor Author

So you would need to add one more file include for the Microsoft.Extensions.Configuration.* assemblies to add them.

My first attempt at this has not helped. The nuspec for Reqnroll.Tools.MsBuild.Generation now includes the following:

    <file src="bin\$config$\net462\*.dll" target="tasks\$Reqnroll_FullFramework_Tools_TFM$" />
    <file src="bin\$config$\netstandard2.0\*.dll" 
          exclude="bin\$config$\netstandard2.0\System.*;bin\$config$\netstandard2.0\Microsoft.*" 
          target="tasks\$Reqnroll_Core_Tools_TFM$" />
    <file src="bin\$config$\netstandard2.0\Microsoft.Extensions.*" target="tasks\$Reqnroll_Core_Tools_TFM$" />
    <file src="bin\$config$\netstandard2.0\*.deps.json" target="tasks\$Reqnroll_Core_Tools_TFM$" />

The same two behaviors are occurring. The first is an error reported when building Specs:

The "GenerateFeatureFileCodeBehindTask" task failed unexpectedly.
System.Reflection.TargetInvocationException->Microsoft.Build.Framework.BuildException.GenericBuildTransferredException: Exception has been thrown by the target of an invocation. ---> Microsoft.Build.Framework.BuildException.GenericBuildTransferredException: Could not load file or assembly 'Microsoft.Extensions.Configuration.Json, Version=8.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60' or one of its dependencies. The system cannot find the file specified.
   --- End of inner exception stack trace ---

I've also experimented with removing the 'exclude' line completely from the nuspec for the MsBuild generator and get the same result. I have confirmed by looking at the nuget package via the nuget package explorer that the first-level dependencies are there in the /tasks folder of the package.
Is there a straightforward way of determining which transitive dependency is not loading?

The second is that the SystemTest generator is failing when generating a project file. The test error output includes:

Message: 
Test method Reqnroll.SystemTests.Smoke.SmokeTest.Handles_the_simplest_scenario threw exception: 
System.IO.FileNotFoundException: Could not find file 'C:\Users\clrud\AppData\Local\Temp\RR\R0ffacae4\Sb9c30e7f\DefaultTestProject\DefaultTestProject.csproj'.

  Stack Trace: 
SafeFileHandle.CreateFile(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options)
SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
OSFileStreamStrategy.ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
FileStreamHelpers.ChooseStrategyCore(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
FileStream.ctor(String path, FileMode mode, FileAccess access, FileShare share, Int32 bufferSize)
XmlDownloadManager.GetStream(Uri uri, ICredentials credentials, IWebProxy proxy)
XmlTextReaderImpl.FinishInitUriString()
XmlReaderSettings.CreateReader(String inputUri, XmlParserContext inputContext)
XDocument.Load(String uri, LoadOptions options)
NewFormatProjectWriter.WriteProject(NetCoreSdkInfo sdk, Project project, String projRootPath) line 38
SolutionWriter.WriteProject(NetCoreSdkInfo sdk, Project project, String outputPath, IProjectWriter formatProjectWriter, String solutionFilePath) line 112
SolutionWriter.WriteProjects(NetCoreSdkInfo sdk, Solution solution, String outputPath, String solutionFilePath) line 99
SolutionWriter.WriteToFileSystem(Solution solution, String outputPath) line 65
SolutionWriteToDiskDriver.WriteSolutionToDisk(Nullable`1 treatWarningsAsErrors) line 36
CompilationDriver.CompileSolutionTimes(UInt32 times, BuildTool buildTool, Nullable`1 treatWarningsAsErrors, Boolean failOnError) line 33
CompilationDriver.CompileSolution(BuildTool buildTool, Nullable`1 treatWarningsAsErrors, Boolean failOnError) line 27
ExecutionDriver.ExecuteTestsTimes(UInt32 times) line 30
ExecutionDriver.ExecuteTests() line 23
SystemTestBase.ExecuteTests() line 220
SmokeTest.Handles_the_simplest_scenario() line 15
RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)

  Standard Output: 
00:00:00.0001310(+0ms):Start
Detected 1 tests
00:00:00.1806704(+181ms):Copying 'C:\Users\clrud\AppData\Local\Temp\RRC\RRC_8.0.100_newsln-nTName-oTName\TName' to 'C:\Users\clrud\AppData\Local\Temp\RR\R0ffacae4\Sb9c30e7f'...
00:00:00.1811789(+1ms):Copying done.
00:00:00.1823760(+1ms):Copying 'C:\Users\clrud\AppData\Local\Temp\RRC\RRC_8.0.100_newclasslib--no-update-check--no-restore-o-langC#DefaultTestProject\DefaultTestProject' to 'C:\Users\clrud\AppData\Local\Temp\RR\R0ffacae4\Sb9c30e7f\DefaultTestProject'...
00:00:00.1826759(+0ms):Copying done.

At this point, I'm presuming that solving the first problem also solves the second. But let me know if you feel otherwise.

@gasparnagy
Copy link
Contributor

@clrudolphi could you please open the generated nupkg file as zip and confirm that the necessary dll-s are in the tools folder? If not maybe they were not even in the bin/debug...

@gasparnagy
Copy link
Contributor

@clrudolphi also check if the "building the Specs" problem is from Visual Studio build (the tools/net461 folder is used) or from dotbet build (the tools/netstandard2.0 folder is used).

@clrudolphi
Copy link
Contributor Author

@clrudolphi could you please open the generated nupkg file as zip and confirm that the necessary dll-s are in the tools folder? If not maybe they were not even in the bin/debug...

In the zip, there is no "tools" folder, but there is a "tasks" folder and all the Microsoft.Extensions.Configuration.* assemblies are in that folder.

also check if the "building the Specs" problem is from Visual Studio build (the tools/net461 folder is used) or from dotbet build (the tools/netstandard2.0 folder is used).

Fails when building from the command-line as well.

PS C:\Users\clrud\source\repos\Reqnroll\Tests\Reqnroll.Specs> dotnet build .\Reqnroll.Specs.csproj
Restore complete (1.0s)
  Reqnroll.Utils netstandard2.0 succeeded (0.3s) → C:\Users\clrud\source\repos\Reqnroll\Reqnroll.Utils\bin\Debug\netstandard2.0\Reqnroll.Utils.dll
  Reqnroll netstandard2.0 succeeded (0.3s) → C:\Users\clrud\source\repos\Reqnroll\Reqnroll\bin\Debug\netstandard2.0\Reqnroll.dll
  Reqnroll.Parser netstandard2.0 succeeded (0.2s) → C:\Users\clrud\source\repos\Reqnroll\Reqnroll.Parser\bin\Debug\netstandard2.0\Reqnroll.Parser.dll
  Reqnroll.Generator netstandard2.0 succeeded (0.3s) → C:\Users\clrud\source\repos\Reqnroll\Reqnroll.Generator\bin\Debug\netstandard2.0\Reqnroll.Generator.dll
  Reqnroll.Tools.MsBuild.Generation netstandard2.0 succeeded (0.1s) → C:\Users\clrud\source\repos\Reqnroll\Reqnroll.Tools.MsBuild.Generation\bin\Debug\netstandard2.0\Reqnroll.Tools.MsBuild.Generation.dll
  Reqnroll.xUnit.ReqnrollPlugin netstandard2.0 succeeded (0.1s) → C:\Users\clrud\source\repos\Reqnroll\Plugins\Reqnroll.xUnit.ReqnrollPlugin\bin\Debug\netstandard2.0\Reqnroll.xUnit.ReqnrollPlugin.dll
  Reqnroll.xUnit.Generator.ReqnrollPlugin netstandard2.0 succeeded (0.1s) → C:\Users\clrud\source\repos\Reqnroll\Plugins\Reqnroll.xUnit.Generator.ReqnrollPlugin\bin\Debug\netstandard2.0\Reqnroll.xUnit.Generator.ReqnrollPlugin.dll
  Reqnroll.Specs failed with 1 error(s) (0.2s)
    C:\Users\clrud\source\repos\Reqnroll\Reqnroll.Tools.MsBuild.Generation\build\Reqnroll.Tools.MsBuild.Generation.targets(93,5): error MSB4018:
      The "GenerateFeatureFileCodeBehindTask" task failed unexpectedly.
      System.Reflection.TargetInvocationException->Microsoft.Build.Framework.BuildException.GenericBuildTransferredExcep
      tion: Exception has been thrown by the target of an invocation.
       ---> System.IO.FileNotFoundException->Microsoft.Build.Framework.BuildException.GenericBuildTransferredException:
      Could not load file or assembly 'Microsoft.Extensions.Configuration.EnvironmentVariables, Version=8.0.0.0, Culture
      =neutral, PublicKeyToken=adb9793829ddae60'. The system cannot find the file specified.
         at Reqnroll.Configuration.MicrosoftConfiguration_RuntimeConfigurationLoader..ctor(IReqnrollJsonLocator reqnroll
      JsonLocator, IConfigurationManager configurationManager)
         at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructo
      r)
         at System.Reflection.MethodBaseInvoker.InvokeDirectByRefWithFewArgs(Object obj, Span`1 copyOfArgs, BindingFlags
       invokeAttr)
         --- End of inner exception stack trace ---

@Code-Grump
Copy link
Contributor

I have some challenges with the cognitive model we have here.

First of all, it seems clear from gasparnagy's explanation that we're currently mixing a lot of configuration concepts into a single file with reqnroll.json. This is made really clear now we're talking about using environment-specific configuration values, where environment means something only during runtime.

I think there's an expectation that if you have a configuration file, all the values inside it apply equally - having some values only apply during certain circumstances is non-intuitive behaviour that will trip somebody up eventually. Even if we document "these values cannot be overidden" somebody will first have to try, discover it doesn't work, then read the docs, then raise an issue.

We should not allow dynamic config overrides for config settings in group 3,4,5.

Although I don't have any usage information to back this up I think there is a desire for this kind of dynamic configuration. The two default environment values used in ASP.NET projects are "Development" and "Production"; the former is set on machines with the ASP.NET SDK installed, the latter explicitly on production servers. It's often used to enable behaviours that only make sense during design-time and aren't valid otherwise.

So I think we should either support the same hierarchical configuration in all contexts, or split up our configuration into distinct design-time and runtime blocks, but mixing configuration scopes within a single file feels wrong.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants