Create dotnet deployment and acquisition size on disk test (#13956)
authorVictor "Nate" Graf <nategraf1@gmail.com>
Fri, 22 Sep 2017 23:47:15 +0000 (16:47 -0700)
committerGitHub <noreply@github.com>
Fri, 22 Sep 2017 23:47:15 +0000 (16:47 -0700)
* [WIP] First version of SoDBench. Contains bugs

* [WIP] Move SoDBench files and increase error checking

* [WIP] Add NugetConfig to enable pulling packages from myget

* [WIP] Remove unhelpful templates and add back oses

* [WIP] Add ability to specify channel

* [WIP] Improve CSV writing

* [WIP] Improve options parsing

* Fix syntax error

* [WIP] Add test leg to perf.groovy

* [WIP] Adjust label to target an existing machine pool

* Change label to run on virtual machine

* Use setMachineAffinity

* Add ASP.NET-Core feed and deafult to master as the channel

* Change channel to master in perf.groovy

* Move NuGet.Config up a directory so it only needs to be written once

* Add CommandLine to external dependencies

* Remove CommandLine as it is now in external.depproj

* Adjust wget command to work more consistantly

* Change calculation of relative paths for clarity

* Set job to run daily instead of on push/PR

* Build sodbench on job run

* Remove MSBuild from job

* Fix quote placement

* Change metadata to be more accurate

* Add rollup totals for each measured category

* Refactor to use a tree rather than a dictionary

* Limit report size

* Publish in release configuration

perf.groovy
tests/src/Common/external/external.depproj
tests/src/performance/performance.csproj
tests/src/sizeondisk/sodbench/SoDBench.cs [new file with mode: 0644]
tests/src/sizeondisk/sodbench/SoDBench.csproj [new file with mode: 0644]

index ca7499b696f58087954da053c887b6b8a3f0262a..3e73edf1ca7be7de6d418222921772215f148baf 100644 (file)
@@ -692,6 +692,88 @@ parallel(
     }
 }
 
+// Setup size-on-disk test
+['Windows_NT'].each { os ->
+    ['x64', 'x86'].each { arch ->
+        def architecture = arch
+        def newJob = job(Utilities.getFullJobName(project, "sizeondisk_${arch}", false)) {
+
+            wrappers {
+                credentialsBinding {
+                    string('BV_UPLOAD_SAS_TOKEN', 'CoreCLR Perf BenchView Sas')
+                }
+            }
+
+            def channel = 'master'
+            def configuration = 'Release'
+            def runType = 'rolling'
+            def benchViewName = 'Dotnet Size on Disk %DATE% %TIME%'
+            def testBin = "%WORKSPACE%\\bin\\tests\\${os}.${architecture}.${configuration}"
+            def coreRoot = "${testBin}\\Tests\\Core_Root"
+            def benchViewTools = "%WORKSPACE%\\Microsoft.BenchView.JSONFormat\\tools"
+
+            steps {
+                // Install nuget and get BenchView tools
+                batchFile("powershell wget https://dist.nuget.org/win-x86-commandline/latest/nuget.exe -OutFile \"%WORKSPACE%\\nuget.exe\"")
+                batchFile("if exist \"%WORKSPACE%\\Microsoft.BenchView.JSONFormat\" rmdir /s /q \"%WORKSPACE%\\Microsoft.BenchView.JSONFormat\"")
+                batchFile("\"%WORKSPACE%\\nuget.exe\" install Microsoft.BenchView.JSONFormat -Source http://benchviewtestfeed.azurewebsites.net/nuget -OutputDirectory \"%WORKSPACE%\" -Prerelease -ExcludeVersion")
+
+                // Generate submission metadata for BenchView
+                // Do this here to remove the origin but at the front of the branch name as this is a problem for BenchView
+                // we have to do it all as one statement because cmd is called each time and we lose the set environment variable
+                batchFile("if \"%GIT_BRANCH:~0,7%\" == \"origin/\" (set \"GIT_BRANCH_WITHOUT_ORIGIN=%GIT_BRANCH:origin/=%\") else (set \"GIT_BRANCH_WITHOUT_ORIGIN=%GIT_BRANCH%\")\n" +
+                "set \"BENCHVIEWNAME=${benchViewName}\"\n" +
+                "set \"BENCHVIEWNAME=%BENCHVIEWNAME:\"=%\"\n" +
+                "py \"${benchViewTools}\\submission-metadata.py\" --name \"%BENCHVIEWNAME%\" --user \"dotnet-bot@microsoft.com\"\n" +
+                "py \"${benchViewTools}\\build.py\" git --branch %GIT_BRANCH_WITHOUT_ORIGIN% --type ${runType}")
+
+                // Generate machine data from BenchView
+                batchFile("py \"${benchViewTools}\\machinedata.py\"")
+
+                // Build CoreCLR and gnerate test layout
+                batchFile("set __TestIntermediateDir=int&&build.cmd ${configuration} ${architecture}")
+                batchFile("tests\\runtest.cmd ${configuration} ${architecture} GenerateLayoutOnly")
+
+                // Run the size on disk benchmark
+                batchFile("\"${coreRoot}\\CoreRun.exe\" \"${testBin}\\sizeondisk\\sodbench\\SoDBench\\SoDBench.exe\" -o \"%WORKSPACE%\\sodbench.csv\" --architecture ${arch} --channel ${channel}")
+
+                // From sodbench.csv, create measurment.json, then submission.json
+                batchFile("py \"${benchViewTools}\\measurement.py\" csv \"%WORKSPACE%\\sodbench.csv\" --metric \"Size on Disk\" --unit \"bytes\" --better \"desc\"")
+                batchFile("py \"${benchViewTools}\\submission.py\" measurement.json --build build.json --machine-data machinedata.json --metadata submission-metadata.json --group \"Dotnet Size on Disk\" --type ${runType} --config-name ${configuration} --architecture ${arch} --machinepool VM --config Channel ${channel}")
+
+                // If this is a PR, upload submission.json
+                batchFile("py \"${benchViewTools}\\upload.py\" submission.json --container coreclr")
+            }
+        }
+
+        Utilities.setMachineAffinity(newJob, "Windows_NT", '20170427-elevated')
+
+        def archiveSettings = new ArchivalSettings()
+        archiveSettings.addFiles('bin/toArchive/**')
+        archiveSettings.addFiles('machinedata.json')
+
+        Utilities.addArchival(newJob, archiveSettings)
+        Utilities.standardJobSetup(newJob, project, false, "*/${branch}")
+
+        // Set the cron job here.  We run nightly on each flavor, regardless of code changes
+        Utilities.addPeriodicTrigger(newJob, "@daily", true /*always run*/)
+
+        newJob.with {
+            logRotator {
+                artifactDaysToKeep(30)
+                daysToKeep(30)
+                artifactNumToKeep(200)
+                numToKeep(200)
+            }
+            wrappers {
+                timeout {
+                    absolute(240)
+                }
+            }
+        }
+    }
+}
+
 // Setup IlLink tests
 [true, false].each { isPR ->
     ['Windows_NT'].each { os ->
index 51b36208b6aee21c325019c2cedca70a20a53d92..7a9725176a095016067867b9bb2015bc159ba23b 100644 (file)
@@ -21,6 +21,9 @@
     <PackageReference Include="Microsoft.CodeAnalysis.Compilers">
       <Version>1.1.1</Version>
     </PackageReference>
+    <PackageReference Include="CommandLineParser">
+      <Version>2.1.1-beta</Version>
+    </PackageReference>
     <PackageReference Include="xunit.performance.api">
       <Version>$(XUnitPerformanceApiVersion)</Version>
     </PackageReference>
@@ -71,6 +74,7 @@
     <PackageToInclude Include="Microsoft.CodeAnalysis.Compilers"/>
     <PackageToInclude Include="Microsoft.CodeAnalysis.CSharp"/>
     <PackageToInclude Include="Microsoft.CodeAnalysis.VisualBasic"/>
+    <PackageToInclude Include="CommandLineParser"/>
     <PackageToInclude Include="$(XUnitRunnerPackageId)" />
   </ItemGroup>
 
index dd016028261d773d42104782554862fb756e03d2..8133535f4514fb56c758aa3411e874ae35f80b2f 100644 (file)
@@ -7,9 +7,6 @@
     <IsTestProject>false</IsTestProject>
   </PropertyGroup>
   <ItemGroup>
-    <PackageReference Include="CommandLineParser">
-      <Version>2.1.1-beta</Version>
-    </PackageReference>
     <PackageReference Include="xunit.performance.api">
       <Version>$(XUnitPerformanceApiVersion)</Version>
     </PackageReference>
diff --git a/tests/src/sizeondisk/sodbench/SoDBench.cs b/tests/src/sizeondisk/sodbench/SoDBench.cs
new file mode 100644 (file)
index 0000000..ac544ab
--- /dev/null
@@ -0,0 +1,702 @@
+using CommandLine;
+using CommandLine.Text;
+using Newtonsoft.Json;
+using Microsoft.Xunit.Performance.Api;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace SoDBench
+{
+    // A simple tree node for tracking file and directory names and sizes
+    // Does not have to accurately represent the true file system; only what we care about
+    class SizeReportingNode
+    {
+        public SizeReportingNode(string name, long? size=null, bool expand=true)
+        {
+            Name = name;
+            _size = size;
+            Expanded = expand;
+        }
+
+        public SizeReportingNode(FileInfo file, bool expand=true)
+        {
+            Name = file.Name;
+            _size = file.Length;
+            Expanded = expand;
+        }
+
+        // Builds out the tree starting from a directory
+        public SizeReportingNode(DirectoryInfo dir, int? reportingDepth=null)
+        {
+            Name = dir.Name;
+
+            foreach (var childDir in dir.EnumerateDirectories())
+            {
+                AddChild(new SizeReportingNode(childDir));
+            }
+
+            foreach (var childFile in dir.EnumerateFiles())
+            {
+                AddChild(new SizeReportingNode(childFile));
+            }
+
+            if (reportingDepth != null)
+            {
+                LimitReportingDepth(reportingDepth ?? 0);
+            }
+        }
+
+
+        // The directory containing this node
+        public SizeReportingNode Parent { get; set; }
+
+        // All the directories and files this node contains
+        public List<SizeReportingNode> Children {get; private set;} = new List<SizeReportingNode>();
+
+        // The file or directory name
+        public string Name { get; set; }
+
+        public bool Expanded { get; set; } = true;
+
+        // A list version of the path up to the root level we care about
+        public List<string> SegmentedPath {
+            get
+            {
+                if (Parent != null)
+                {
+                    var path = Parent.SegmentedPath;
+                    path.Add(Name);
+                    return path;
+                }
+                return new List<string> { Name };
+            }
+        }
+
+        // The size of the file or directory
+        public long Size {
+            get
+            {
+                if (_size == null)
+                {
+                    _size = 0;
+                    foreach (var node in Children)
+                    {
+                        _size += node.Size;
+                    }
+                }
+                return _size ?? 0;
+            }
+
+            private set
+            {
+                _size = value;
+            }
+        }
+
+
+        // Add the adoptee node as a child and set the adoptee's parent
+        public void AddChild(SizeReportingNode adoptee)
+        {
+            Children.Add(adoptee);
+            adoptee.Parent = this;
+            _size = null;
+        }
+
+        public void LimitReportingDepth(int depth)
+        {
+            if (depth <= 0)
+            {
+                Expanded = false;
+            }
+
+            foreach (var childNode in Children)
+            {
+                childNode.LimitReportingDepth(depth-1);
+            }
+        }
+
+        // Return a CSV formatted string representation of the tree
+        public string FormatAsCsv()
+        {
+            return FormatAsCsv(new StringBuilder()).ToString();
+        }
+
+        // Add to the string build a csv formatted representation of the tree
+        public StringBuilder FormatAsCsv(StringBuilder builder)
+        {
+            string path = String.Join(",", SegmentedPath.Select(s => Csv.Escape(s)));
+            builder.AppendLine($"{path},{Size}");
+
+            if (Expanded)
+            {
+                foreach (var childNode in Children)
+                {
+                    childNode.FormatAsCsv(builder);
+                }
+            }
+
+            return builder;
+        }
+
+        private long? _size = null;
+    }
+
+    class Program
+    {
+        public static readonly string NugetConfig =
+        @"<?xml version='1.0' encoding='utf-8'?>
+        <configuration>
+        <packageSources>
+            <add key='nuget.org' value='https://api.nuget.org/v3/index.json' protocolVersion='3' />
+            <add key='myget.org/dotnet-core' value='https://dotnet.myget.org/F/dotnet-core/api/v3/index.json' protocolVersion='3' />
+            <add key='myget.org/aspnet-core' value='https://dotnet.myget.org/F/aspnetcore-ci-dev/api/v3/index.json' protocolVersion='3' />
+        </packageSources>
+        </configuration>";
+
+        public static readonly string[] NewTemplates = new string[] {
+            "console",
+            "classlib",
+            "mstest",
+            "xunit",
+            "web",
+            "mvc",
+            "razor",
+            "webapi",
+            "nugetconfig",
+            "webconfig",
+            "sln",
+            "page",
+            "viewimports",
+            "viewstart"
+        };
+
+        public static readonly string[] OperatingSystems = new string[] {
+            "win10-x64",
+            "win10-x86",
+            "ubuntu.16.10-x64",
+            "rhel.7-x64"
+        };
+
+        static FileInfo s_dotnetExe;
+        static DirectoryInfo s_sandboxDir;
+        static DirectoryInfo s_fallbackDir;
+        static DirectoryInfo s_corelibsDir;
+        static bool s_keepArtifacts;
+        static string s_targetArchitecture;
+        static string s_dotnetChannel;
+
+        static void Main(string[] args)
+        {
+            try
+            {
+                var options = SoDBenchOptions.Parse(args);
+
+                s_targetArchitecture = options.TargetArchitecture;
+                s_dotnetChannel = options.DotnetChannel;
+                s_keepArtifacts = options.KeepArtifacts;
+
+                if (!String.IsNullOrWhiteSpace(options.DotnetExecutable))
+                {
+                    s_dotnetExe = new FileInfo(options.DotnetExecutable);
+                }
+
+                if (s_sandboxDir == null)
+                {
+                    // Truncate the Guid used for anti-collision because a full Guid results in expanded paths over 260 chars (the Windows max)
+                    s_sandboxDir = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"sod{Guid.NewGuid().ToString().Substring(0,13)}"));
+                    s_sandboxDir.Create();
+                    Console.WriteLine($"** Running inside sandbox directory: {s_sandboxDir}");
+                }
+
+                if (s_dotnetExe == null)
+                {
+                    if(!String.IsNullOrEmpty(options.CoreLibariesDirectory))
+                    {
+                        Console.WriteLine($"** Using core libraries found at {options.CoreLibariesDirectory}");
+                        s_corelibsDir = new DirectoryInfo(options.CoreLibariesDirectory);
+                    }
+                    else 
+                    {
+                        var coreroot = Environment.GetEnvironmentVariable("CORE_ROOT");
+                        if (!String.IsNullOrEmpty(coreroot) && Directory.Exists(coreroot))
+                        {
+                            Console.WriteLine($"** Using core libraries from CORE_ROOT at {coreroot}");
+                            s_corelibsDir = new DirectoryInfo(coreroot);
+                        }
+                        else
+                        {
+                            Console.WriteLine("** Using default dotnet-cli core libraries");
+                        }
+                    }
+
+                    PrintHeader("Installing Dotnet CLI");
+                    s_dotnetExe = SetupDotnet();
+                }
+
+                if (s_fallbackDir == null)
+                {
+                    s_fallbackDir = new DirectoryInfo(Path.Combine(s_sandboxDir.FullName, "fallback"));
+                    s_fallbackDir.Create();
+                }
+
+                Console.WriteLine($"** Path to dotnet executable: {s_dotnetExe.FullName}");
+                
+                PrintHeader("Starting acquisition size test");
+                var acquisition = GetAcquisitionSize();
+
+                PrintHeader("Running deployment size test");
+                var deployment = GetDeploymentSize();
+
+                var root = new SizeReportingNode("Dotnet Total");
+                root.AddChild(acquisition);
+                root.AddChild(deployment);
+
+                var formattedStr = root.FormatAsCsv();
+               
+                File.WriteAllText(options.OutputFilename, formattedStr);
+
+                if (options.Verbose)
+                    Console.WriteLine($"** CSV Output:\n{formattedStr}");
+            }
+            finally
+            {
+                if (!s_keepArtifacts && s_sandboxDir != null)
+                {
+                    PrintHeader("Cleaning up sandbox directory");
+                    s_sandboxDir.Delete(true);
+                    s_sandboxDir = null;
+                }
+            }
+        }
+
+        private static void PrintHeader(string message)
+        {
+            Console.WriteLine();
+            Console.WriteLine("**********************************************************************");
+            Console.WriteLine($"** {message}");
+            Console.WriteLine("**********************************************************************");
+        }
+
+        private static SizeReportingNode GetAcquisitionSize()
+        {
+            var result = new SizeReportingNode("Acquisition Size");
+
+            // Arbitrary command to trigger first time setup
+            ProcessStartInfo dotnet = new ProcessStartInfo()
+            {
+                WorkingDirectory = s_sandboxDir.FullName,
+                FileName = s_dotnetExe.FullName,
+                Arguments = "new"
+            };
+
+            // Used to set where the packages will be unpacked to.
+            // There is a no gaurentee that this is a stable method, but is the only way currently to set the fallback folder location
+            dotnet.Environment["DOTNET_CLI_TEST_FALLBACKFOLDER"] = s_fallbackDir.FullName;
+
+            LaunchProcess(dotnet, 180000);
+
+            Console.WriteLine("\n** Measuring total size of acquired files");
+
+            result.AddChild(new SizeReportingNode(s_fallbackDir, 1));
+
+            var dotnetNode = new SizeReportingNode(s_dotnetExe.Directory);
+            var reportingDepths = new Dictionary<string, int>
+            {
+                {"additionalDeps", 1},
+                {"host", 0},
+                {"sdk", 2},
+                {"shared", 2},
+                {"store", 3}
+            };
+            foreach (var childNode in dotnetNode.Children)
+            {
+                int depth = 0;
+                if (reportingDepths.TryGetValue(childNode.Name, out depth))
+                {
+                    childNode.LimitReportingDepth(depth);
+                }
+            }
+            result.AddChild(dotnetNode);
+
+            return result;
+        }
+        
+        private static SizeReportingNode GetDeploymentSize()
+        {
+            // Write the NuGet.Config file
+            var nugetConfFile = new FileInfo(Path.Combine(s_sandboxDir.FullName, "NuGet.Config"));
+            File.WriteAllText(nugetConfFile.FullName, NugetConfig);
+
+            var result = new SizeReportingNode("Deployment Size");
+            foreach (string template in NewTemplates)
+            {
+                var templateNode = new SizeReportingNode(template);
+                result.AddChild(templateNode);
+
+                foreach (var os in OperatingSystems)
+                {
+                    Console.WriteLine($"\n\n** Deploying {template}/{os}");
+
+                    var deploymentSandbox = new DirectoryInfo(Path.Combine(s_sandboxDir.FullName, template, os));
+                    var publishDir = new DirectoryInfo(Path.Combine(deploymentSandbox.FullName, "publish"));
+                    deploymentSandbox.Create();
+
+                    ProcessStartInfo dotnetNew = new ProcessStartInfo()
+                    {
+                        FileName = s_dotnetExe.FullName,
+                        Arguments = $"new {template}",
+                        UseShellExecute = false,
+                        WorkingDirectory = deploymentSandbox.FullName
+                    };
+                    dotnetNew.Environment["DOTNET_CLI_TEST_FALLBACKFOLDER"] = s_fallbackDir.FullName;
+
+                    ProcessStartInfo dotnetRestore = new ProcessStartInfo()
+                    {
+                        FileName = s_dotnetExe.FullName,
+                        Arguments = $"restore --runtime {os}",
+                        UseShellExecute = false,
+                        WorkingDirectory = deploymentSandbox.FullName
+                    };
+                    dotnetRestore.Environment["DOTNET_CLI_TEST_FALLBACKFOLDER"] = s_fallbackDir.FullName;
+
+                    ProcessStartInfo dotnetPublish = new ProcessStartInfo()
+                    {
+                        FileName = s_dotnetExe.FullName,
+                        Arguments = $"publish -c Release --runtime {os} --output {publishDir.FullName}", // "out" is an arbitrary project name
+                        UseShellExecute = false,
+                        WorkingDirectory = deploymentSandbox.FullName
+                    };
+                    dotnetPublish.Environment["DOTNET_CLI_TEST_FALLBACKFOLDER"] = s_fallbackDir.FullName;
+
+                    try
+                    {
+                        LaunchProcess(dotnetNew, 180000);
+                        if (deploymentSandbox.EnumerateFiles().Any(f => f.Name.EndsWith("proj")))
+                        {
+                            LaunchProcess(dotnetRestore, 180000);
+                            LaunchProcess(dotnetPublish, 180000);
+                        }
+                        else
+                        {
+                            Console.WriteLine($"** {template} does not have a project file to restore or publish");
+                        }
+                    }
+                    catch (Exception e)
+                    {
+                        Console.Error.WriteLine(e.Message);
+                        continue;
+                    }
+
+                    // If we published this project, only report published it's size
+                    if (publishDir.Exists)
+                    {
+                        var publishNode = new SizeReportingNode(publishDir, 0);
+                        publishNode.Name = deploymentSandbox.Name;
+                        templateNode.AddChild(publishNode);
+                    }
+                    else
+                    {
+                        templateNode.AddChild(new SizeReportingNode(deploymentSandbox, 0));
+                    }
+                }
+            }
+            return result;
+        }
+
+        private static void DownloadDotnetInstaller()
+        {
+            var psi = new ProcessStartInfo() {
+                WorkingDirectory = s_sandboxDir.FullName,
+                FileName = @"powershell.exe",
+                Arguments = $"wget https://raw.githubusercontent.com/dotnet/cli/master/scripts/obtain/dotnet-install.ps1 -OutFile Dotnet-Install.ps1"
+            };
+            LaunchProcess(psi, 180000);
+        }
+
+        private static void InstallSharedRuntime()
+        {
+            var psi = new ProcessStartInfo() {
+                WorkingDirectory = s_sandboxDir.FullName,
+                FileName = @"powershell.exe",
+                Arguments = $".\\Dotnet-Install.ps1 -SharedRuntime -InstallDir .dotnet -Channel {s_dotnetChannel} -Architecture {s_targetArchitecture}"
+            };
+            LaunchProcess(psi, 180000);
+        }
+
+        private static void InstallDotnet()
+        {
+            var psi = new ProcessStartInfo() {
+                WorkingDirectory = s_sandboxDir.FullName,
+                FileName = @"powershell.exe",
+                Arguments = $".\\Dotnet-Install.ps1 -InstallDir .dotnet -Channel {s_dotnetChannel} -Architecture {s_targetArchitecture}"
+            };
+            LaunchProcess(psi, 180000);
+        }
+
+        private static void ModifySharedFramework()
+        {
+            // Current working directory is the <coreclr repo root>/sandbox directory.
+            Console.WriteLine($"** Modifying the shared framework.");
+
+            var sourcedi = s_corelibsDir;
+
+            // Get the directory containing the newest version of Microsodt.NETCore.App libraries
+            var targetdi = new DirectoryInfo(
+                new DirectoryInfo(Path.Combine(s_sandboxDir.FullName, ".dotnet", "shared", "Microsoft.NETCore.App"))
+                .GetDirectories("*")
+                .OrderBy(s => s.Name)
+                .Last()
+                .FullName);
+
+            Console.WriteLine($"| Source : {sourcedi.FullName}");
+            Console.WriteLine($"| Target : {targetdi.FullName}");
+
+            var compiledBinariesOfInterest = new string[] {
+                "clretwrc.dll",
+                "clrjit.dll",
+                "coreclr.dll",
+                "mscordaccore.dll",
+                "mscordbi.dll",
+                "mscorrc.debug.dll",
+                "mscorrc.dll",
+                "sos.dll",
+                "SOS.NETCore.dll",
+                "System.Private.CoreLib.dll"
+            };
+
+            foreach (var compiledBinaryOfInterest in compiledBinariesOfInterest)
+            {
+                foreach (FileInfo fi in targetdi.GetFiles(compiledBinaryOfInterest))
+                {
+                    var sourceFilePath = Path.Combine(sourcedi.FullName, fi.Name);
+                    var targetFilePath = Path.Combine(targetdi.FullName, fi.Name);
+
+                    if (File.Exists(sourceFilePath))
+                    {
+                        File.Copy(sourceFilePath, targetFilePath, true);
+                        Console.WriteLine($"|   Copied file - '{fi.Name}'");
+                    }
+                }
+            }
+        }
+
+        private static FileInfo SetupDotnet()
+        {
+            DownloadDotnetInstaller();
+            InstallSharedRuntime();
+            InstallDotnet();
+            if (s_corelibsDir != null)
+            {
+                ModifySharedFramework();
+            }
+
+            var dotnetExe = new FileInfo(Path.Combine(s_sandboxDir.FullName, ".dotnet", "dotnet.exe"));
+            Debug.Assert(dotnetExe.Exists);
+
+            return dotnetExe;
+        }
+
+        private static void LaunchProcess(ProcessStartInfo processStartInfo, int timeoutMilliseconds, IDictionary<string, string> environment = null)
+        {
+            Console.WriteLine();
+            Console.WriteLine($"{System.Security.Principal.WindowsIdentity.GetCurrent().Name}@{Environment.MachineName} \"{processStartInfo.WorkingDirectory}\"");
+            Console.WriteLine($"[{DateTime.Now}] $ {processStartInfo.FileName} {processStartInfo.Arguments}");
+
+            if (environment != null)
+            {
+                foreach (KeyValuePair<string, string> pair in environment)
+                {
+                    if (!processStartInfo.Environment.ContainsKey(pair.Key))
+                        processStartInfo.Environment.Add(pair.Key, pair.Value);
+                    else
+                        processStartInfo.Environment[pair.Key] = pair.Value;
+                }
+            }
+
+            using (var p = new Process() { StartInfo = processStartInfo })
+            {
+                p.Start();
+                if (p.WaitForExit(timeoutMilliseconds) == false)
+                {
+                    // FIXME: What about clean/kill child processes?
+                    p.Kill();
+                    throw new TimeoutException($"The process '{processStartInfo.FileName} {processStartInfo.Arguments}' timed out.");
+                }
+
+                if (p.ExitCode != 0)
+                    throw new Exception($"{processStartInfo.FileName} exited with error code {p.ExitCode}");
+            }
+        }
+
+        /// <summary>
+        /// Provides an interface to parse the command line arguments passed to the SoDBench.
+        /// </summary>
+        private sealed class SoDBenchOptions
+        {
+            public SoDBenchOptions() { }
+
+            private static string NormalizePath(string path)
+            {
+                if (String.IsNullOrWhiteSpace(path))
+                    throw new InvalidOperationException($"'{path}' is an invalid path: cannot be null or whitespace");
+
+                if (path.Any(c => Path.GetInvalidPathChars().Contains(c)))
+                    throw new InvalidOperationException($"'{path}' is an invalid path: contains invalid characters");
+
+                return Path.IsPathRooted(path) ? path : Path.GetFullPath(path);
+            }
+
+            [Option('o', Required = false, HelpText = "Specifies the output file name for the csv document")]
+            public string OutputFilename
+            {
+                get { return _outputFilename; }
+
+                set
+                {
+                    _outputFilename = NormalizePath(value);
+                }
+            }
+
+            [Option("dotnet", Required = false, HelpText = "Specifies the location of dotnet cli to use.")]
+            public string DotnetExecutable
+            {
+                get { return _dotnetExe; }
+
+                set
+                {
+                    _dotnetExe = NormalizePath(value);
+                }
+            }
+
+            [Option("corelibs", Required = false, HelpText = "Specifies the location of .NET Core libaries to patch into dotnet. Cannot be used with --dotnet")]
+            public string CoreLibariesDirectory
+            {
+                get { return _corelibsDir; }
+
+                set
+                {
+                    _corelibsDir = NormalizePath(value);
+                }
+            }
+
+            [Option("architecture", Required = false, Default = "x64", HelpText = "JitBench target architecture (It must match the built product that was copied into sandbox).")]
+            public string TargetArchitecture { get; set; }
+
+            [Option("channel", Required = false, Default = "master", HelpText = "Specifies the channel to use when installing the dotnet-cli")]
+            public string DotnetChannel { get; set; }
+
+            [Option('v', Required = false, HelpText = "Sets output to verbose")]
+            public bool Verbose { get; set; }
+
+            [Option("keep-artifacts", Required = false, HelpText = "Specifies that artifacts of this run should be kept")]
+            public bool KeepArtifacts { get; set; }
+
+            public static SoDBenchOptions Parse(string[] args)
+            {
+                using (var parser = new Parser((settings) => {
+                    settings.CaseInsensitiveEnumValues = true;
+                    settings.CaseSensitive = false;
+                    settings.HelpWriter = new StringWriter();
+                    settings.IgnoreUnknownArguments = true;
+                }))
+                {
+                    SoDBenchOptions options = null;
+                    parser.ParseArguments<SoDBenchOptions>(args)
+                        .WithParsed(parsed => options = parsed)
+                        .WithNotParsed(errors => {
+                            foreach (Error error in errors)
+                            {
+                                switch (error.Tag)
+                                {
+                                    case ErrorType.MissingValueOptionError:
+                                        throw new ArgumentException(
+                                                $"Missing value option for command line argument '{(error as MissingValueOptionError).NameInfo.NameText}'");
+                                    case ErrorType.HelpRequestedError:
+                                        Console.WriteLine(Usage());
+                                        Environment.Exit(0);
+                                        break;
+                                    case ErrorType.VersionRequestedError:
+                                        Console.WriteLine(new AssemblyName(typeof(SoDBenchOptions).GetTypeInfo().Assembly.FullName).Version);
+                                        Environment.Exit(0);
+                                        break;
+                                    case ErrorType.BadFormatTokenError:
+                                    case ErrorType.UnknownOptionError:
+                                    case ErrorType.MissingRequiredOptionError:
+                                    case ErrorType.MutuallyExclusiveSetError:
+                                    case ErrorType.BadFormatConversionError:
+                                    case ErrorType.SequenceOutOfRangeError:
+                                    case ErrorType.RepeatedOptionError:
+                                    case ErrorType.NoVerbSelectedError:
+                                    case ErrorType.BadVerbSelectedError:
+                                    case ErrorType.HelpVerbRequestedError:
+                                        break;
+                                }
+                            }
+                        });
+
+                    if (options != null && !String.IsNullOrEmpty(options.DotnetExecutable) && !String.IsNullOrEmpty(options.CoreLibariesDirectory))
+                    {
+                        throw new ArgumentException("--dotnet and --corlibs cannot be used together");
+                    }
+
+                    return options;
+                }
+            }
+
+            public static string Usage()
+            {
+                var parser = new Parser((parserSettings) =>
+                {
+                    parserSettings.CaseInsensitiveEnumValues = true;
+                    parserSettings.CaseSensitive = false;
+                    parserSettings.EnableDashDash = true;
+                    parserSettings.HelpWriter = new StringWriter();
+                    parserSettings.IgnoreUnknownArguments = true;
+                });
+
+                var helpTextString = new HelpText
+                {
+                    AddDashesToOption = true,
+                    AddEnumValuesToHelpText = true,
+                    AdditionalNewLineAfterOption = false,
+                    Heading = "SoDBench",
+                    MaximumDisplayWidth = 80,
+                }.AddOptions(parser.ParseArguments<SoDBenchOptions>(new string[] { "--help" })).ToString();
+                return helpTextString;
+            }
+
+            private string _dotnetExe;
+            private string _corelibsDir;
+            private string _outputFilename = "measurement.csv";
+        }
+    }
+
+    // A simple class for escaping strings for CSV writing
+    // https://stackoverflow.com/a/769713
+    // Used instead of a package because only these < 20 lines of code are needed
+    public static class Csv
+    {
+        public static string Escape( string s )
+        {
+            if ( s.Contains( QUOTE ) )
+                s = s.Replace( QUOTE, ESCAPED_QUOTE );
+
+            if ( s.IndexOfAny( CHARACTERS_THAT_MUST_BE_QUOTED ) > -1 )
+                s = QUOTE + s + QUOTE;
+
+            return s;
+        }
+
+        private const string QUOTE = "\"";
+        private const string ESCAPED_QUOTE = "\"\"";
+        private static char[] CHARACTERS_THAT_MUST_BE_QUOTED = { ',', '"', '\n' };
+    }
+}
diff --git a/tests/src/sizeondisk/sodbench/SoDBench.csproj b/tests/src/sizeondisk/sodbench/SoDBench.csproj
new file mode 100644 (file)
index 0000000..e61c660
--- /dev/null
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), dir.props))\dir.props" />
+  <PropertyGroup>
+    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
+    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
+    <AssemblyName>SoDBench</AssemblyName>
+    <SchemaVersion>2.0</SchemaVersion>
+    <ProjectGuid>{507E3CC2-5D95-414D-9F01-2A106FC177DC}</ProjectGuid>
+    <OutputType>exe</OutputType>
+    <AppDesignerFolder>Properties</AppDesignerFolder>
+    <FileAlignment>512</FileAlignment>
+    <ProjectTypeGuids>{786C830F-07A1-408B-BD7F-6EE04809D6DB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
+    <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\..\</SolutionDir>
+    <NuGetTargetMoniker>.NETStandard,Version=v1.5</NuGetTargetMoniker>
+    <NuGetTargetMonikerShort>netstandard1.5</NuGetTargetMonikerShort>
+  </PropertyGroup>
+  <!-- Default configurations to help VS understand the configurations -->
+  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' " />
+  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' " />
+  <PropertyGroup>
+    <RestoreOutputPath>.\obj</RestoreOutputPath>
+    <DebugType>pdbonly</DebugType>
+    <Optimize>true</Optimize>
+  </PropertyGroup>
+  <ItemGroup>
+    <CodeAnalysisDependentAssemblyPaths Condition=" '$(VS100COMNTOOLS)' != '' " Include="$(VS100COMNTOOLS)..\IDE\PrivateAssemblies">
+      <Visible>False</Visible>
+    </CodeAnalysisDependentAssemblyPaths>
+  </ItemGroup>
+  <ItemGroup>
+    <Compile Include="SoDBench.cs" />
+  </ItemGroup>
+  <ItemGroup>
+    <Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" />
+  </ItemGroup>
+  <Import Project="..\..\performance\performance.targets" />
+
+  <PropertyGroup>
+    <ProjectAssetsFile>..\..\performance\obj\project.assets.json</ProjectAssetsFile>
+  </PropertyGroup>
+</Project>