diff --git a/.github/workflows/dotnet.yaml b/.github/workflows/dotnet.yaml index fca803d..08baa99 100644 --- a/.github/workflows/dotnet.yaml +++ b/.github/workflows/dotnet.yaml @@ -31,7 +31,7 @@ jobs: - name: Publish run: dotnet publish Example - name: Run example - run: ".\\Example\\bin\\Release\\net8.0\\win-x64\\Example.exe" + run: ".\\Example\\bin\\Release\\net8.0\\Example.exe" build: strategy: @@ -57,10 +57,14 @@ jobs: run: nix develop --command dotnet build --no-restore --configuration ${{matrix.config}} - name: Test run: nix develop --command dotnet test --no-build --verbosity normal --configuration ${{matrix.config}} - - name: Publish example - run: nix develop --command dotnet publish --no-build --verbosity normal --configuration ${{matrix.config}} Example + - name: Publish example self-contained + run: nix develop --command dotnet publish --self-contained --runtime linux-x64 --verbosity normal --configuration ${{matrix.config}} Example - name: Run example self-contained - run: "./Example/bin/${{matrix.config}}/*/*/Example" + run: "./Example/bin/${{matrix.config}}/net*/*-*/Example" + - name: Publish example non-self-contained + run: nix develop --command dotnet publish --verbosity normal --configuration ${{matrix.config}} Example + - name: Run example non-self-contained + run: "./Example/bin/${{matrix.config}}/net*/Example" build-nix: runs-on: ubuntu-latest diff --git a/Example/Example.fsproj b/Example/Example.fsproj index cfdf871..3efbbc4 100644 --- a/Example/Example.fsproj +++ b/Example/Example.fsproj @@ -3,7 +3,6 @@ Exe net8.0 - true diff --git a/Example/Program.fs b/Example/Program.fs index 870afcd..7f8be35 100644 --- a/Example/Program.fs +++ b/Example/Program.fs @@ -1,6 +1,8 @@ -namespace Example +namespace Example open System +open System.IO +open System.Reflection open WoofWare.DotnetRuntimeLocator module Program = @@ -18,4 +20,16 @@ module Program = for f in info.Frameworks do Console.WriteLine $"Framework: %O{f}" + // Identify the runtime which would execute this DLL + let self = Assembly.GetExecutingAssembly().Location + let runtimeSearchDirs = DotnetRuntime.SelectForDll self + // For example, the System.Text.Json.dll which this DLL would load: + runtimeSearchDirs + |> Seq.tryPick (fun dir -> + let attempt = Path.Combine (dir, "System.Text.Json.dll") + if File.Exists attempt then Some attempt else None + ) + |> Option.get + |> fun s -> Console.WriteLine $"System.Text.Json location: %s{s}" + 0 diff --git a/README.md b/README.md index 3cfc182..e61b1a2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ See [the example](Example/Program.fs). let info = DotnetEnvironmentInfo.Get () // or, if you already know a path to the `dotnet` executable... let info = DotnetEnvironmentInfo.GetSpecific "/path/to/dotnet" + +// identify the directories containing the framework which would execute a given DLL +let dirsToSearch : string seq = DotnetRuntime.SelectForDll "/path/to/dll.dll" +// or, if you would be running with a specific `/path/to/dotnet exec /path/to/dll.dll`: +let dirsToSearch : string seq = DotnetRuntime.SelectForDll ("/path/to/dll.dll", "/path/to/dotnet") ``` ## Troubleshooting diff --git a/WoofWare.DotnetRuntimeLocator/DotnetRuntime.cs b/WoofWare.DotnetRuntimeLocator/DotnetRuntime.cs new file mode 100644 index 0000000..acfa246 --- /dev/null +++ b/WoofWare.DotnetRuntimeLocator/DotnetRuntime.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace WoofWare.DotnetRuntimeLocator; + +/// +/// The result of a call to `DotnetRuntime.Select`. +/// This is `type DotnetRuntimeSelection = | Framework of DotnetEnvironmentFrameworkInfo | Sdk of +/// DotnetEnvironmentSdkInfo | Absent`. +/// +internal class DotnetRuntimeSelection +{ + private readonly int _discriminator; + private readonly DotnetEnvironmentFrameworkInfo? _framework; + private readonly DotnetEnvironmentSdkInfo? _sdk; + + /// + /// The constructor which means "We found the right runtime, and it's from this framework". + /// + /// For example, + public DotnetRuntimeSelection(DotnetEnvironmentFrameworkInfo framework) + { + _discriminator = 1; + _framework = framework; + } + + /// + /// The constructor which means "We found the right runtime, and it's from this SDK". + /// + /// For example, + public DotnetRuntimeSelection(DotnetEnvironmentSdkInfo sdk) + { + _discriminator = 2; + _sdk = sdk; + } + + /// + /// The constructor which means "We were unable to find an appropriate runtime". + /// + public DotnetRuntimeSelection() + { + _discriminator = 3; + } + + /// + /// Exhaustive match on this discriminated union. + /// + /// If `this` is a `Framework`, call this continuation with its value. + /// If `this` is a `Sdk`, call this continuation with its value. + /// If `this` represents the absence of a result, call this continuation. + /// The result of the continuation which was called. + public TRet Visit(Func withFramework, + Func withSdk, + Func withNone) + { + return _discriminator switch + { + 1 => withFramework.Invoke(_framework!), + 2 => withSdk.Invoke(_sdk!), + 3 => withNone.Invoke(), + _ => throw new InvalidOperationException($"unrecognised union discriminator %i{_discriminator}") + }; + } +} + +/// +/// Module to hold methods for automatically identifying a .NET runtime. +/// +public static class DotnetRuntime +{ + /// For each requested runtime in the RuntimeOptions, the resolved place in which to find that runtime. + private static IReadOnlyDictionary SelectRuntime(RuntimeOptions options, + DotnetEnvironmentInfo env) + { + var rollForwardEnvVar = Environment.GetEnvironmentVariable("DOTNET_ROLL_FORWARD"); + RollForward rollForward; + if (rollForwardEnvVar == null) + { + rollForward = options.RollForward ?? RollForward.Minor; + } + else + { + if (!Enum.TryParse(rollForwardEnvVar, out rollForward)) + throw new ArgumentException( + $"Unable to parse the value of environment variable DOTNET_ROLL_FORWARD, which was: {rollForwardEnvVar}"); + } + + IReadOnlyDictionary desiredVersions; + if (options.IncludedFrameworks == null) + { + if (options.Framework == null) + { + if (options.Frameworks == null) + throw new InvalidDataException( + "Expected runtimeconfig.json file to have either a framework or frameworks entry, but it had neither"); + + desiredVersions = options.Frameworks.Select(x => (x.Name, new Version(x.Version))).GroupBy(x => x.Name) + .Select(data => + { + var versions = (IReadOnlyList)data.Select(datum => datum.Item2).ToList(); + if (versions.Count != 1) + { + var description = string.Join(", ", versions.Select(x => x.ToString())); + throw new InvalidDataException( + $"Unexpectedly had not-exactly-one version desired for framework {data.Key}: {description}"); + } + + return (data.Key, versions[0]); + }) + .ToDictionary(); + } + else + { + var result = new Dictionary + { { options.Framework.Name, new Version(options.Framework.Version) } }; + desiredVersions = result; + } + } + else + { + desiredVersions = options.IncludedFrameworks.Select(x => (x.Name, new Version(x.Version))) + .GroupBy(x => x.Name) + .Select(data => + { + var versions = (IReadOnlyList)data.Select(datum => datum.Item2).ToList(); + if (versions.Count != 1) + { + var description = string.Join(", ", versions.Select(x => x.ToString())); + throw new InvalidDataException( + $"Unexpectedly had not-exactly-one version desired for framework {data.Key}: {description}"); + } + + return (data.Key, versions[0]); + }) + .ToDictionary(); + } + + IReadOnlyDictionary> availableRuntimes = env + .Frameworks.SelectMany(availableFramework => + { + var availableVersion = new Version(availableFramework.Version); + if (!desiredVersions.TryGetValue(availableFramework.Name, out var desiredVersion)) + { + // we don't desire this framework at any version; skip it + return []; + } + + if (availableVersion < desiredVersion) + { + // It's never desired to roll *backward*. + return []; + } + return new List<(string, DotnetEnvironmentFrameworkInfo)> + { (availableFramework.Name, availableFramework) }; + }).GroupBy(x => x.Item1) + .Select(group => + { + var grouping = group.Select(x => new RuntimeOnDisk(x.Item2, new Version(x.Item2.Version))).ToList(); + return (group.Key, (IReadOnlyList)grouping); + }) + .ToDictionary(); + + switch (rollForward) + { + case RollForward.Minor: + { + return desiredVersions.Select(desired => + { + if (!availableRuntimes.TryGetValue(desired.Key, out var available)) + { + return (desired.Key, new DotnetRuntimeSelection()); + } + + if (ReferenceEquals(available, null)) + { + throw new NullReferenceException("logic error: contents of non-nullable dict can't be null"); + } + + // If there's a correct major and minor version, take the latest patch. + var correctMajorAndMinorVersion = + available.Where(data => + data.InstalledVersion.Major == desired.Value.Major && + data.InstalledVersion.Minor == desired.Value.Minor).ToList(); + if (correctMajorAndMinorVersion.Count > 0) + { + return (desired.Key, new DotnetRuntimeSelection(correctMajorAndMinorVersion.MaxBy(v => v.InstalledVersion)!.Installed)); + } + + // Otherwise roll forward to lowest higher minor version + var candidate = available.Where(data => data.InstalledVersion.Major == desired.Value.Major) + .MinBy(v => (v.InstalledVersion.Minor, -v.InstalledVersion.Build)); + + return (desired.Key, candidate == null ? new DotnetRuntimeSelection() : new DotnetRuntimeSelection(candidate.Installed)); + }).ToDictionary(); + } + case RollForward.Major: + { + throw new NotImplementedException(); + } + case RollForward.LatestPatch: + { + return desiredVersions.Select(desired => + { + var matches = availableRuntimes[desired.Key] + .Where(data => + data.InstalledVersion.Minor == desired.Value.Minor && + data.InstalledVersion.Major == desired.Value.Major).MaxBy(data => data.InstalledVersion); + return matches == null + ? (desired.Key, new DotnetRuntimeSelection()) + : (desired.Key, new DotnetRuntimeSelection(matches.Installed)); + }).ToDictionary(); + } + case RollForward.LatestMinor: + { + return desiredVersions.Select(desired => + { + var matches = availableRuntimes[desired.Key] + .Where(data => + data.InstalledVersion.Major == desired.Value.Major).MaxBy(data => data.InstalledVersion); + return matches == null + ? (desired.Key, new DotnetRuntimeSelection()) + : (desired.Key, new DotnetRuntimeSelection(matches.Installed)); + }).ToDictionary(); + } + case RollForward.LatestMajor: + { + return desiredVersions.Select(desired => + { + var match = availableRuntimes[desired.Key].MaxBy(data => data.InstalledVersion); + return match == null ? (desired.Key, new DotnetRuntimeSelection()) : (desired.Key, new DotnetRuntimeSelection(match.Installed)); + }).ToDictionary(); + } + case RollForward.Disable: + { + return desiredVersions.Select(desired => + { + var exactMatch = availableRuntimes[desired.Key] + .FirstOrDefault(available => available.InstalledVersion == desired.Value); + if (exactMatch != null) + { + return (desired.Key, new DotnetRuntimeSelection(exactMatch.Installed)); + } + else + { + return (desired.Key, new DotnetRuntimeSelection()); + } + } + ).ToDictionary(); + } + default: + { + throw new ArgumentOutOfRangeException(); + } + } + } + + /// + /// Given a .NET executable DLL, identify the most appropriate .NET runtime to run it. + /// This is pretty half-baked at the moment; test this yourself to make sure it does what you want it to! + /// + /// Path to an OutputType=Exe .dll file. + /// + /// Path to the `dotnet` binary which you would use e.g. in `dotnet exec` to run the DLL specified by + /// `dllPath`. + /// + /// + /// An ordered collection of folder paths. When resolving any particular DLL during the execution of the input + /// DLL, search these folders; if a DLL name appears in multiple of these folders, the earliest is correct for that + /// DLL. + /// + public static IReadOnlyList SelectForDll(string dllPath, string? dotnet = null) + { + if (!dllPath.EndsWith(".dll", StringComparison.Ordinal)) + throw new ArgumentException( + $"SelectForDll requires the input DLL to have the extension '.dll'; provided: {dllPath}"); + + var dll = new FileInfo(dllPath); + var dllParentDir = dll.Directory ?? throw new ArgumentException($"dll path {dllPath} had no parent"); + var name = dll.Name.Substring(0, dll.Name.Length - ".dll".Length); + + var configFilePath = Path.Combine(dllParentDir.FullName, $"{name}.runtimeconfig.json"); + + // It appears to be undocumented why this returns a nullable, and the Rider decompiler doesn't suggest there are + // any code paths where it can return null? + var runtimeConfig = + JsonSerializer.Deserialize(File.ReadAllText(configFilePath)) ?? + throw new NullReferenceException($"Failed to parse contents of file {configFilePath} as a runtime config"); + + var availableRuntimes = dotnet == null + ? DotnetEnvironmentInfo.Get() + : DotnetEnvironmentInfo.GetSpecific(new FileInfo(dotnet)); + + var runtimes = SelectRuntime(runtimeConfig.RuntimeOptions, availableRuntimes); + + return runtimes.SelectMany(runtime => runtime.Value.Visit(framework => new[] { $"{framework.Path}/{framework.Version}" }, + sdk => [sdk.Path], + () => [] + )).Prepend(dllParentDir.FullName).ToList(); + } + + private record RuntimeOnDisk( + DotnetEnvironmentFrameworkInfo Installed, + Version InstalledVersion); +} diff --git a/WoofWare.DotnetRuntimeLocator/RuntimeConfigOptions.cs b/WoofWare.DotnetRuntimeLocator/RuntimeConfigOptions.cs index 75ef06f..fe6ef9f 100644 --- a/WoofWare.DotnetRuntimeLocator/RuntimeConfigOptions.cs +++ b/WoofWare.DotnetRuntimeLocator/RuntimeConfigOptions.cs @@ -86,6 +86,12 @@ public record RuntimeOptions [JsonPropertyName("frameworks")] public IReadOnlyList? Frameworks { get; init; } + /// + /// This is a self-contained executable which has these framework entirely contained next to it. + /// + [JsonPropertyName("includedFrameworks")] + public IReadOnlyList? IncludedFrameworks { get; init; } + /// /// This application advertises that it's fine with running under this roll-forward. /// diff --git a/WoofWare.DotnetRuntimeLocator/SurfaceBaseline.txt b/WoofWare.DotnetRuntimeLocator/SurfaceBaseline.txt index 1671595..df58d7e 100644 --- a/WoofWare.DotnetRuntimeLocator/SurfaceBaseline.txt +++ b/WoofWare.DotnetRuntimeLocator/SurfaceBaseline.txt @@ -45,6 +45,8 @@ WoofWare.DotnetRuntimeLocator.DotnetEnvironmentSdkInfo.Path [property]: string WoofWare.DotnetRuntimeLocator.DotnetEnvironmentSdkInfo.set_Path [method]: string -> unit WoofWare.DotnetRuntimeLocator.DotnetEnvironmentSdkInfo.set_Version [method]: string -> unit WoofWare.DotnetRuntimeLocator.DotnetEnvironmentSdkInfo.Version [property]: string +WoofWare.DotnetRuntimeLocator.DotnetRuntime inherit obj +WoofWare.DotnetRuntimeLocator.DotnetRuntime.SelectForDll [static method]: (string, string) -> string System.Collections.Generic.IReadOnlyList WoofWare.DotnetRuntimeLocator.RollForward inherit System.Enum WoofWare.DotnetRuntimeLocator.RollForward.Disable [static field]: WoofWare.DotnetRuntimeLocator.RollForward = Disable WoofWare.DotnetRuntimeLocator.RollForward.LatestMajor [static field]: WoofWare.DotnetRuntimeLocator.RollForward = LatestMajor @@ -79,13 +81,16 @@ WoofWare.DotnetRuntimeLocator.RuntimeOptions.Framework [property]: WoofWare.Dotn WoofWare.DotnetRuntimeLocator.RuntimeOptions.Frameworks [property]: WoofWare.DotnetRuntimeLocator.RuntimeConfigFramework System.Collections.Generic.IReadOnlyList WoofWare.DotnetRuntimeLocator.RuntimeOptions.get_Framework [method]: unit -> WoofWare.DotnetRuntimeLocator.RuntimeConfigFramework WoofWare.DotnetRuntimeLocator.RuntimeOptions.get_Frameworks [method]: unit -> WoofWare.DotnetRuntimeLocator.RuntimeConfigFramework System.Collections.Generic.IReadOnlyList +WoofWare.DotnetRuntimeLocator.RuntimeOptions.get_IncludedFrameworks [method]: unit -> WoofWare.DotnetRuntimeLocator.RuntimeConfigFramework System.Collections.Generic.IReadOnlyList WoofWare.DotnetRuntimeLocator.RuntimeOptions.get_RollForward [method]: unit -> WoofWare.DotnetRuntimeLocator.RollForward System.Nullable WoofWare.DotnetRuntimeLocator.RuntimeOptions.get_Tfm [method]: unit -> string +WoofWare.DotnetRuntimeLocator.RuntimeOptions.IncludedFrameworks [property]: WoofWare.DotnetRuntimeLocator.RuntimeConfigFramework System.Collections.Generic.IReadOnlyList WoofWare.DotnetRuntimeLocator.RuntimeOptions.op_Equality [static method]: (WoofWare.DotnetRuntimeLocator.RuntimeOptions, WoofWare.DotnetRuntimeLocator.RuntimeOptions) -> bool WoofWare.DotnetRuntimeLocator.RuntimeOptions.op_Inequality [static method]: (WoofWare.DotnetRuntimeLocator.RuntimeOptions, WoofWare.DotnetRuntimeLocator.RuntimeOptions) -> bool WoofWare.DotnetRuntimeLocator.RuntimeOptions.RollForward [property]: WoofWare.DotnetRuntimeLocator.RollForward System.Nullable WoofWare.DotnetRuntimeLocator.RuntimeOptions.set_Framework [method]: WoofWare.DotnetRuntimeLocator.RuntimeConfigFramework -> unit WoofWare.DotnetRuntimeLocator.RuntimeOptions.set_Frameworks [method]: WoofWare.DotnetRuntimeLocator.RuntimeConfigFramework System.Collections.Generic.IReadOnlyList -> unit +WoofWare.DotnetRuntimeLocator.RuntimeOptions.set_IncludedFrameworks [method]: WoofWare.DotnetRuntimeLocator.RuntimeConfigFramework System.Collections.Generic.IReadOnlyList -> unit WoofWare.DotnetRuntimeLocator.RuntimeOptions.set_RollForward [method]: WoofWare.DotnetRuntimeLocator.RollForward System.Nullable -> unit WoofWare.DotnetRuntimeLocator.RuntimeOptions.set_Tfm [method]: string -> unit WoofWare.DotnetRuntimeLocator.RuntimeOptions.Tfm [property]: string \ No newline at end of file diff --git a/WoofWare.DotnetRuntimeLocator/Test/Test.fsproj b/WoofWare.DotnetRuntimeLocator/Test/Test.fsproj index 66e25b7..dd07b4d 100644 --- a/WoofWare.DotnetRuntimeLocator/Test/Test.fsproj +++ b/WoofWare.DotnetRuntimeLocator/Test/Test.fsproj @@ -8,6 +8,7 @@ + diff --git a/WoofWare.DotnetRuntimeLocator/Test/TestDotnetRuntime.fs b/WoofWare.DotnetRuntimeLocator/Test/TestDotnetRuntime.fs new file mode 100644 index 0000000..d523d57 --- /dev/null +++ b/WoofWare.DotnetRuntimeLocator/Test/TestDotnetRuntime.fs @@ -0,0 +1,36 @@ +namespace WoofWare.DotnetRuntimeLocator.Test + +open System.IO +open System.Reflection +open NUnit.Framework +open WoofWare.DotnetRuntimeLocator + +[] +module TestDotnetRuntime = + + let inline shouldBeSome (x : 'a option) : unit = + match x with + | None -> failwith "option was None" + | Some _ -> () + + let inline shouldBeNone (x : 'a option) : unit = + match x with + | Some x -> failwith $"expectd None, but option was Some %O{x}" + | None -> () + + [] + let ``Test DotnetRuntime`` () = + let assy = Assembly.GetExecutingAssembly () + let selectedRuntime = DotnetRuntime.SelectForDll assy.Location + + let existsDll (name : string) = + selectedRuntime + |> Seq.tryPick (fun dir -> + let attempt = Path.Combine (dir, name) + if File.Exists attempt then Some attempt else None + ) + + existsDll "System.Private.CoreLib.dll" |> shouldBeSome + existsDll "System.Text.Json.dll" |> shouldBeSome + existsDll "Test.dll" |> shouldBeSome + existsDll "blah-de-blah.dll" |> shouldBeNone diff --git a/WoofWare.DotnetRuntimeLocator/Test/TestSurface.fs b/WoofWare.DotnetRuntimeLocator/Test/TestSurface.fs index 7bd604d..ff093b4 100644 --- a/WoofWare.DotnetRuntimeLocator/Test/TestSurface.fs +++ b/WoofWare.DotnetRuntimeLocator/Test/TestSurface.fs @@ -18,6 +18,7 @@ module TestSurface = let ``Ensure public API is fully documented`` () = DocCoverage.assertFullyDocumented assembly - [] - let ``Ensure version is monotonic`` () = + [] + // https://github.com/nunit/nunit3-vs-adapter/issues/876 + let ``EnsureVersionIsMonotonic`` () = MonotonicVersion.validate assembly "WoofWare.DotnetRuntimeLocator" diff --git a/WoofWare.DotnetRuntimeLocator/WoofWare.DotnetRuntimeLocator.csproj b/WoofWare.DotnetRuntimeLocator/WoofWare.DotnetRuntimeLocator.csproj index 6d5d4dd..dd7a2e2 100644 --- a/WoofWare.DotnetRuntimeLocator/WoofWare.DotnetRuntimeLocator.csproj +++ b/WoofWare.DotnetRuntimeLocator/WoofWare.DotnetRuntimeLocator.csproj @@ -23,6 +23,7 @@ + diff --git a/WoofWare.DotnetRuntimeLocator/version.json b/WoofWare.DotnetRuntimeLocator/version.json index 0def5a6..0158ffa 100644 --- a/WoofWare.DotnetRuntimeLocator/version.json +++ b/WoofWare.DotnetRuntimeLocator/version.json @@ -1,5 +1,5 @@ { - "version": "0.2", + "version": "0.3", "publicReleaseRefSpec": [ "^refs/heads/main$" ], diff --git a/flake.nix b/flake.nix index 1a64bd1..d3f2213 100644 --- a/flake.nix +++ b/flake.nix @@ -48,10 +48,11 @@ fantomas = dotnetTool null "fantomas" (builtins.fromJSON (builtins.readFile ./.config/dotnet-tools.json)).tools.fantomas.version (builtins.head (builtins.filter (elem: elem.pname == "fantomas") deps)).hash; default = pkgs.buildDotnetModule { inherit pname version dotnet-sdk dotnet-runtime; - name = "WoofWare.Myriad.Plugins"; + name = "WoofWare.DotnetRuntimeLocator"; src = ./.; projectFile = "./WoofWare.DotnetRuntimeLocator/WoofWare.DotnetRuntimeLocator.csproj"; testProjectFile = "./WoofWare.DotnetRuntimeLocator/Test/Test.fsproj"; + disabledTests = ["WoofWare.DotnetRuntimeLocator.Test.TestSurface.EnsureVersionIsMonotonic"]; nugetDeps = ./nix/deps.json; # `nix build .#default.fetch-deps && ./result nix/deps.json` doCheck = true; };