зеркало из https://github.com/dotnet/sdk.git
migrated tests to Xunit
This commit is contained in:
Родитель
6baba9b9f1
Коммит
7434ad6d90
|
@ -0,0 +1,33 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Build.Framework;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
public class CapturingLogger : ILogger
|
||||
{
|
||||
public LoggerVerbosity Verbosity { get => LoggerVerbosity.Diagnostic; set { } }
|
||||
public string Parameters { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
|
||||
|
||||
private List<BuildMessageEventArgs> _messages = new();
|
||||
public IReadOnlyList<BuildMessageEventArgs> Messages {get { return _messages; } }
|
||||
|
||||
private List<BuildWarningEventArgs> _warnings = new();
|
||||
public IReadOnlyList<BuildWarningEventArgs> Warnings {get { return _warnings; } }
|
||||
|
||||
private List<BuildErrorEventArgs> _errors = new();
|
||||
public IReadOnlyList<BuildErrorEventArgs> Errors {get { return _errors; } }
|
||||
|
||||
public void Initialize(IEventSource eventSource)
|
||||
{
|
||||
eventSource.MessageRaised += (o, e) => _messages.Add(e);
|
||||
eventSource.WarningRaised += (o, e) => _warnings.Add(e);
|
||||
eventSource.ErrorRaised += (o, e) => _errors.Add(e);
|
||||
}
|
||||
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
}
|
||||
}
|
|
@ -0,0 +1,199 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
internal static class ArgumentEscaper
|
||||
{
|
||||
/// <summary>
|
||||
/// Undo the processing which took place to create string[] args in Main,
|
||||
/// so that the next process will receive the same string[] args
|
||||
/// See here for more info:
|
||||
/// http://blogs.msdn.com/b/twistylittlepassagesallalike/archive/2011/04/23/everyone-quotes-arguments-the-wrong-way.aspx .
|
||||
/// </summary>
|
||||
/// <param name="args"></param>
|
||||
/// <returns></returns>
|
||||
internal static string EscapeAndConcatenateArgArrayForProcessStart(IEnumerable<string> args)
|
||||
{
|
||||
IEnumerable<string> escaped = EscapeArgArray(args);
|
||||
return string.Join(" ", escaped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Undo the processing which took place to create string[] args in Main,
|
||||
/// so that the next process will receive the same string[] args
|
||||
/// See here for more info:
|
||||
/// http://blogs.msdn.com/b/twistylittlepassagesallalike/archive/2011/04/23/everyone-quotes-arguments-the-wrong-way.aspx .
|
||||
/// </summary>
|
||||
/// <param name="args"></param>
|
||||
/// <returns></returns>
|
||||
internal static string EscapeAndConcatenateArgArrayForCmdProcessStart(IEnumerable<string> args)
|
||||
{
|
||||
IEnumerable<string> escaped = EscapeArgArrayForCmd(args);
|
||||
return string.Join(" ", escaped);
|
||||
}
|
||||
|
||||
internal static string EscapeSingleArg(string arg)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
var length = arg.Length;
|
||||
var needsQuotes = length == 0 || ShouldSurroundWithQuotes(arg);
|
||||
var isQuoted = needsQuotes || IsSurroundedWithQuotes(arg);
|
||||
|
||||
if (needsQuotes)
|
||||
{
|
||||
sb.Append('"');
|
||||
}
|
||||
|
||||
for (int i = 0; i < length; ++i)
|
||||
{
|
||||
var backslashCount = 0;
|
||||
|
||||
// Consume All Backslashes
|
||||
while (i < arg.Length && arg[i] == '\\')
|
||||
{
|
||||
backslashCount++;
|
||||
i++;
|
||||
}
|
||||
|
||||
// Escape any backslashes at the end of the arg
|
||||
// when the argument is also quoted.
|
||||
// This ensures the outside quote is interpreted as
|
||||
// an argument delimiter
|
||||
if (i == arg.Length && isQuoted)
|
||||
{
|
||||
sb.Append('\\', 2 * backslashCount);
|
||||
}
|
||||
|
||||
// At then end of the arg, which isn't quoted,
|
||||
// just add the backslashes, no need to escape
|
||||
else if (i == arg.Length)
|
||||
{
|
||||
sb.Append('\\', backslashCount);
|
||||
}
|
||||
|
||||
// Escape any preceding backslashes and the quote
|
||||
else if (arg[i] == '"')
|
||||
{
|
||||
sb.Append('\\', (2 * backslashCount) + 1);
|
||||
sb.Append('"');
|
||||
}
|
||||
|
||||
// Output any consumed backslashes and the character
|
||||
else
|
||||
{
|
||||
sb.Append('\\', backslashCount);
|
||||
sb.Append(arg[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (needsQuotes)
|
||||
{
|
||||
sb.Append('"');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
internal static bool ShouldSurroundWithQuotes(string argument)
|
||||
{
|
||||
// Only quote if whitespace exists in the string
|
||||
return ArgumentContainsWhitespace(argument);
|
||||
}
|
||||
|
||||
internal static bool IsSurroundedWithQuotes(string argument)
|
||||
{
|
||||
return argument.StartsWith("\"", StringComparison.Ordinal) &&
|
||||
argument.EndsWith("\"", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
internal static bool ArgumentContainsWhitespace(string argument)
|
||||
{
|
||||
return argument.Contains(' ') || argument.Contains('\t') || argument.Contains('\n');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepare as single argument to
|
||||
/// roundtrip properly through cmd.
|
||||
/// This prefixes every character with the '^' character to force cmd to
|
||||
/// interpret the argument string literally. An alternative option would
|
||||
/// be to do this only for cmd metacharacters.
|
||||
/// See here for more info:
|
||||
/// http://blogs.msdn.com/b/twistylittlepassagesallalike/archive/2011/04/23/everyone-quotes-arguments-the-wrong-way.aspx .
|
||||
/// </summary>
|
||||
/// <param name="argument"></param>
|
||||
/// <returns></returns>
|
||||
private static string EscapeArgForCmd(string argument)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
var quoted = ShouldSurroundWithQuotes(argument);
|
||||
|
||||
if (quoted)
|
||||
{
|
||||
sb.Append("^\"");
|
||||
}
|
||||
|
||||
// Prepend every character with ^
|
||||
// This is harmless when passing through cmd
|
||||
// and ensures cmd metacharacters are not interpreted
|
||||
// as such
|
||||
foreach (var character in argument)
|
||||
{
|
||||
sb.Append('^');
|
||||
sb.Append(character);
|
||||
}
|
||||
|
||||
if (quoted)
|
||||
{
|
||||
sb.Append("^\"");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Undo the processing which took place to create string[] args in Main,
|
||||
/// so that the next process will receive the same string[] args
|
||||
/// See here for more info:
|
||||
/// http://blogs.msdn.com/b/twistylittlepassagesallalike/archive/2011/04/23/everyone-quotes-arguments-the-wrong-way.aspx .
|
||||
/// </summary>
|
||||
/// <param name="args"></param>
|
||||
/// <returns></returns>
|
||||
private static List<string> EscapeArgArray(IEnumerable<string> args)
|
||||
{
|
||||
var escapedArgs = new List<string>();
|
||||
|
||||
foreach (var arg in args)
|
||||
{
|
||||
escapedArgs.Add(EscapeSingleArg(arg));
|
||||
}
|
||||
|
||||
return escapedArgs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This prefixes every character with the '^' character to force cmd to
|
||||
/// interpret the argument string literally. An alternative option would
|
||||
/// be to do this only for cmd metacharacters.
|
||||
/// See here for more info:
|
||||
/// http://blogs.msdn.com/b/twistylittlepassagesallalike/archive/2011/04/23/everyone-quotes-arguments-the-wrong-way.aspx.
|
||||
/// </summary>
|
||||
/// <param name="arguments"></param>
|
||||
/// <returns></returns>
|
||||
private static List<string> EscapeArgArrayForCmd(IEnumerable<string> arguments)
|
||||
{
|
||||
var escapedArgs = new List<string>();
|
||||
|
||||
foreach (var arg in arguments)
|
||||
{
|
||||
escapedArgs.Add(EscapeArgForCmd(arg));
|
||||
}
|
||||
|
||||
return escapedArgs;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
internal sealed class BasicCommand : TestCommand
|
||||
{
|
||||
private readonly string _processName;
|
||||
|
||||
internal BasicCommand(ITestOutputHelper? log, string processName, params string[] args) : base(log)
|
||||
{
|
||||
_processName = processName;
|
||||
Arguments.AddRange(args.Where(a => !string.IsNullOrWhiteSpace(a)));
|
||||
}
|
||||
|
||||
private protected override SdkCommandSpec CreateCommand(IEnumerable<string> args)
|
||||
{
|
||||
var sdkCommandSpec = new SdkCommandSpec()
|
||||
{
|
||||
FileName = _processName,
|
||||
Arguments = args.ToList(),
|
||||
WorkingDirectory = WorkingDirectory
|
||||
};
|
||||
return sdkCommandSpec;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,202 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
internal sealed class Command
|
||||
{
|
||||
private readonly Process _process;
|
||||
|
||||
private readonly bool _trimTrailingNewlines;
|
||||
|
||||
private StreamForwarder? _stdOut;
|
||||
private StreamForwarder? _stdErr;
|
||||
private bool _running;
|
||||
|
||||
public Command(Process process, bool trimtrailingNewlines = false)
|
||||
{
|
||||
_trimTrailingNewlines = trimtrailingNewlines;
|
||||
_process = process ?? throw new ArgumentNullException(nameof(process));
|
||||
}
|
||||
|
||||
public string CommandName => _process.StartInfo.FileName;
|
||||
|
||||
public string CommandArgs => _process.StartInfo.Arguments;
|
||||
|
||||
public CommandResult Execute()
|
||||
{
|
||||
return Execute(_ => { });
|
||||
}
|
||||
|
||||
public CommandResult Execute(Action<Process>? processStarted)
|
||||
{
|
||||
Console.WriteLine($"Running {_process.StartInfo.FileName} {_process.StartInfo.Arguments}");
|
||||
ThrowIfRunning();
|
||||
|
||||
_running = true;
|
||||
|
||||
_process.EnableRaisingEvents = true;
|
||||
|
||||
#if DEBUG
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
Console.WriteLine($"> {FormatProcessInfo(_process.StartInfo)}");
|
||||
#endif
|
||||
using (var reaper = new ProcessReaper(_process))
|
||||
{
|
||||
_process.Start();
|
||||
processStarted?.Invoke(_process);
|
||||
reaper.NotifyProcessStarted();
|
||||
|
||||
Console.WriteLine($"Process ID: {_process.Id}");
|
||||
|
||||
var taskOut = _stdOut?.BeginRead(_process.StandardOutput);
|
||||
var taskErr = _stdErr?.BeginRead(_process.StandardError);
|
||||
_process.WaitForExit();
|
||||
|
||||
taskOut?.Wait();
|
||||
taskErr?.Wait();
|
||||
}
|
||||
|
||||
var exitCode = _process.ExitCode;
|
||||
|
||||
#if DEBUG
|
||||
var message = string.Format($"< {FormatProcessInfo(_process.StartInfo)} exited with {exitCode} in {sw.ElapsedMilliseconds} ms");
|
||||
if (exitCode == 0)
|
||||
{
|
||||
Console.WriteLine(message);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(message);
|
||||
}
|
||||
#endif
|
||||
|
||||
return new CommandResult(
|
||||
_process.StartInfo,
|
||||
exitCode,
|
||||
_stdOut?.CapturedOutput,
|
||||
_stdErr?.CapturedOutput);
|
||||
}
|
||||
|
||||
public Command WorkingDirectory(string projectDirectory)
|
||||
{
|
||||
_process.StartInfo.WorkingDirectory = projectDirectory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Command EnvironmentVariable(string name, string value)
|
||||
{
|
||||
_process.StartInfo.Environment[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Command CaptureStdOut()
|
||||
{
|
||||
ThrowIfRunning();
|
||||
EnsureStdOut();
|
||||
_stdOut!.Capture(_trimTrailingNewlines);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Command CaptureStdErr()
|
||||
{
|
||||
ThrowIfRunning();
|
||||
EnsureStdErr();
|
||||
_stdErr!.Capture(_trimTrailingNewlines);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Command ForwardStdOut(TextWriter? to = null)
|
||||
{
|
||||
ThrowIfRunning();
|
||||
EnsureStdOut();
|
||||
|
||||
if (to == null)
|
||||
{
|
||||
_stdOut!.ForwardTo(writeLine: Console.Out.WriteLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
_stdOut!.ForwardTo(writeLine: to.WriteLine);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public Command ForwardStdErr(TextWriter? to = null)
|
||||
{
|
||||
ThrowIfRunning();
|
||||
|
||||
EnsureStdErr();
|
||||
|
||||
if (to == null)
|
||||
{
|
||||
_stdErr!.ForwardTo(writeLine: Console.Error.WriteLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
_stdErr!.ForwardTo(writeLine: to.WriteLine);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public Command OnOutputLine(Action<string> handler)
|
||||
{
|
||||
ThrowIfRunning();
|
||||
EnsureStdOut();
|
||||
|
||||
_stdOut!.ForwardTo(writeLine: handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Command OnErrorLine(Action<string> handler)
|
||||
{
|
||||
ThrowIfRunning();
|
||||
EnsureStdErr();
|
||||
|
||||
_stdErr!.ForwardTo(writeLine: handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Command SetCommandArgs(string commandArgs)
|
||||
{
|
||||
_process.StartInfo.Arguments = commandArgs;
|
||||
return this;
|
||||
}
|
||||
|
||||
private static string FormatProcessInfo(ProcessStartInfo info)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(info.Arguments))
|
||||
{
|
||||
return info.FileName;
|
||||
}
|
||||
|
||||
return info.FileName + " " + info.Arguments;
|
||||
}
|
||||
|
||||
private void EnsureStdOut()
|
||||
{
|
||||
_stdOut ??= new StreamForwarder();
|
||||
_process.StartInfo.RedirectStandardOutput = true;
|
||||
}
|
||||
|
||||
private void EnsureStdErr()
|
||||
{
|
||||
_stdErr ??= new StreamForwarder();
|
||||
_process.StartInfo.RedirectStandardError = true;
|
||||
}
|
||||
|
||||
private void ThrowIfRunning([CallerMemberName] string? memberName = null)
|
||||
{
|
||||
if (_running)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to invoke {memberName} after the command has been run.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
internal readonly struct CommandResult
|
||||
{
|
||||
internal CommandResult(ProcessStartInfo startInfo, int exitCode, string? stdOut, string? stdErr)
|
||||
{
|
||||
StartInfo = startInfo;
|
||||
ExitCode = exitCode;
|
||||
StdOut = stdOut;
|
||||
StdErr = stdErr;
|
||||
}
|
||||
|
||||
internal ProcessStartInfo StartInfo { get; }
|
||||
|
||||
internal int ExitCode { get; }
|
||||
|
||||
internal string? StdOut { get; }
|
||||
|
||||
internal string? StdErr { get; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentAssertions;
|
||||
using FluentAssertions.Execution;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
internal sealed class CommandResultAssertions
|
||||
{
|
||||
private readonly CommandResult _commandResult;
|
||||
|
||||
internal CommandResultAssertions(CommandResult commandResult)
|
||||
{
|
||||
_commandResult = commandResult;
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> ExitWith(int expectedExitCode)
|
||||
{
|
||||
Execute.Assertion.ForCondition(_commandResult.ExitCode == expectedExitCode)
|
||||
.FailWith(AppendDiagnosticsTo($"Expected command to exit with {expectedExitCode} but it did not."));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> Pass()
|
||||
{
|
||||
Execute.Assertion.ForCondition(_commandResult.ExitCode == 0)
|
||||
.FailWith(AppendDiagnosticsTo($"Expected command to pass but it did not."));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> Fail()
|
||||
{
|
||||
Execute.Assertion.ForCondition(_commandResult.ExitCode != 0)
|
||||
.FailWith(AppendDiagnosticsTo($"Expected command to fail but it did not."));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveStdOut()
|
||||
{
|
||||
Execute.Assertion.ForCondition(!string.IsNullOrEmpty(_commandResult.StdOut))
|
||||
.FailWith(AppendDiagnosticsTo("Command did not output anything to stdout"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveStdOut(string expectedOutput)
|
||||
{
|
||||
if (_commandResult.StdOut is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdOut for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(_commandResult.StdOut.Equals(expectedOutput, StringComparison.Ordinal))
|
||||
.FailWith(AppendDiagnosticsTo($"Command did not output with Expected Output. Expected: {expectedOutput}"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveStdOutContaining(string pattern)
|
||||
{
|
||||
if (_commandResult.StdOut is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdOut for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(_commandResult.StdOut.Contains(pattern))
|
||||
.FailWith(AppendDiagnosticsTo($"The command output did not contain expected result: {pattern}{Environment.NewLine}"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveStdOutContaining(Func<string, bool> predicate, string description = "")
|
||||
{
|
||||
if (_commandResult.StdOut is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdOut for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(predicate(_commandResult.StdOut))
|
||||
.FailWith(AppendDiagnosticsTo($"The command output did not contain expected result: {description} {Environment.NewLine}"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> NotHaveStdOutContaining(string pattern)
|
||||
{
|
||||
if (_commandResult.StdOut is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdOut for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(!_commandResult.StdOut.Contains(pattern))
|
||||
.FailWith(AppendDiagnosticsTo($"The command output contained a result it should not have contained: {pattern}{Environment.NewLine}"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveStdOutContainingIgnoreSpaces(string pattern)
|
||||
{
|
||||
if (_commandResult.StdOut is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdOut for the command was not captured");
|
||||
}
|
||||
string commandResultNoSpaces = _commandResult.StdOut.Replace(" ", string.Empty);
|
||||
|
||||
Execute.Assertion
|
||||
.ForCondition(commandResultNoSpaces.Contains(pattern))
|
||||
.FailWith(AppendDiagnosticsTo($"The command output did not contain expected result: {pattern}{Environment.NewLine}"));
|
||||
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveStdOutContainingIgnoreCase(string pattern)
|
||||
{
|
||||
if (_commandResult.StdOut is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdOut for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(_commandResult.StdOut.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
.FailWith(AppendDiagnosticsTo($"The command output did not contain expected result (ignoring case): {pattern}{Environment.NewLine}"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveStdOutMatching(string pattern, RegexOptions options = RegexOptions.None)
|
||||
{
|
||||
if (_commandResult.StdOut is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdOut for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(Regex.Match(_commandResult.StdOut, pattern, options).Success)
|
||||
.FailWith(AppendDiagnosticsTo($"Matching the command output failed. Pattern: {pattern}{Environment.NewLine}"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> NotHaveStdOutMatching(string pattern, RegexOptions options = RegexOptions.None)
|
||||
{
|
||||
if (_commandResult.StdOut is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdOut for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(!Regex.Match(_commandResult.StdOut, pattern, options).Success)
|
||||
.FailWith(AppendDiagnosticsTo($"The command output matched a pattern it should not have. Pattern: {pattern}{Environment.NewLine}"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveStdErr()
|
||||
{
|
||||
if (_commandResult.StdErr is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdOut for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(!string.IsNullOrEmpty(_commandResult.StdErr))
|
||||
.FailWith(AppendDiagnosticsTo("Command did not output anything to StdErr."));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveStdErr(string expectedOutput)
|
||||
{
|
||||
if (_commandResult.StdErr is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdErr for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(_commandResult.StdErr.Equals(expectedOutput, StringComparison.Ordinal))
|
||||
.FailWith(AppendDiagnosticsTo($"Command did not output the expected output to StdErr.{Environment.NewLine}Expected: {expectedOutput}{Environment.NewLine}Actual: {_commandResult.StdErr}"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveStdErrContaining(string pattern)
|
||||
{
|
||||
if (_commandResult.StdErr is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdErr for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(_commandResult.StdErr.Contains(pattern))
|
||||
.FailWith(AppendDiagnosticsTo($"The command error output did not contain expected result: {pattern}{Environment.NewLine}"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> NotHaveStdErrContaining(string pattern)
|
||||
{
|
||||
if (_commandResult.StdErr is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdErr for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(!_commandResult.StdErr.Contains(pattern))
|
||||
.FailWith(AppendDiagnosticsTo($"The command error output contained a result it should not have contained: {pattern}{Environment.NewLine}"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveStdErrMatching(string pattern, RegexOptions options = RegexOptions.None)
|
||||
{
|
||||
if (_commandResult.StdErr is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdErr for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(Regex.Match(_commandResult.StdErr, pattern, options).Success)
|
||||
.FailWith(AppendDiagnosticsTo($"Matching the command error output failed. Pattern: {pattern}{Environment.NewLine}"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> NotHaveStdOut()
|
||||
{
|
||||
if (_commandResult.StdOut is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdOut for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(string.IsNullOrEmpty(_commandResult.StdOut))
|
||||
.FailWith(AppendDiagnosticsTo($"Expected command to not output to stdout but it was not:"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> NotHaveStdErr()
|
||||
{
|
||||
if (_commandResult.StdErr is null)
|
||||
{
|
||||
throw new InvalidOperationException("StdErr for the command was not captured");
|
||||
}
|
||||
Execute.Assertion.ForCondition(string.IsNullOrEmpty(_commandResult.StdErr))
|
||||
.FailWith(AppendDiagnosticsTo("Expected command to not output to stderr but it was not:"));
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveSkippedProjectCompilation(string skippedProject, string frameworkFullName)
|
||||
{
|
||||
_commandResult.StdOut.Should().Contain($"Project {skippedProject} ({frameworkFullName}) was previously compiled. Skipping compilation.");
|
||||
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
internal AndConstraint<CommandResultAssertions> HaveCompiledProject(string compiledProject, string frameworkFullName)
|
||||
{
|
||||
_commandResult.StdOut.Should().Contain($"Project {compiledProject} ({frameworkFullName}) will be compiled");
|
||||
|
||||
return new AndConstraint<CommandResultAssertions>(this);
|
||||
}
|
||||
|
||||
private string AppendDiagnosticsTo(string s)
|
||||
{
|
||||
return (s + $"{Environment.NewLine}" +
|
||||
$"File Name: {_commandResult.StartInfo.FileName}{Environment.NewLine}" +
|
||||
$"Arguments: {_commandResult.StartInfo.Arguments}{Environment.NewLine}" +
|
||||
$"Exit Code: {_commandResult.ExitCode}{Environment.NewLine}" +
|
||||
$"StdOut:{Environment.NewLine}{_commandResult.StdOut}{Environment.NewLine}" +
|
||||
$"StdErr:{Environment.NewLine}{_commandResult.StdErr}{Environment.NewLine}")
|
||||
//escape curly braces for String.Format
|
||||
.Replace("{", "{{").Replace("}", "}}");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
internal static class CommandResultExtensions
|
||||
{
|
||||
internal static CommandResultAssertions Should(this CommandResult commandResult)
|
||||
{
|
||||
return new CommandResultAssertions(commandResult);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
internal sealed class DotnetCommand : TestCommand
|
||||
{
|
||||
private string _executableFilePath = "dotnet";
|
||||
|
||||
internal DotnetCommand(ITestOutputHelper log, string subcommand, params string[] args) : base(log)
|
||||
{
|
||||
Arguments.Add(subcommand);
|
||||
Arguments.AddRange(args);
|
||||
}
|
||||
|
||||
internal DotnetCommand WithoutTelemetry()
|
||||
{
|
||||
WithEnvironmentVariable("DOTNET_CLI_TELEMETRY_OPTOUT", "true");
|
||||
return this;
|
||||
}
|
||||
|
||||
internal DotnetCommand WithCustomExecutablePath(string? executableFilePath)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(executableFilePath))
|
||||
{
|
||||
_executableFilePath = executableFilePath;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private protected override SdkCommandSpec CreateCommand(IEnumerable<string> args)
|
||||
{
|
||||
var sdkCommandSpec = new SdkCommandSpec()
|
||||
{
|
||||
FileName = _executableFilePath,
|
||||
Arguments = args.ToList(),
|
||||
WorkingDirectory = WorkingDirectory
|
||||
};
|
||||
return sdkCommandSpec;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
internal static class NativeMethods
|
||||
{
|
||||
internal static class Windows
|
||||
{
|
||||
internal const int ProcessBasicInformation = 0;
|
||||
|
||||
internal enum JobObjectInfoClass : uint
|
||||
{
|
||||
JobObjectExtendedLimitInformation = 9,
|
||||
}
|
||||
|
||||
[Flags]
|
||||
internal enum JobObjectLimitFlags : uint
|
||||
{
|
||||
JobObjectLimitKillOnJobClose = 0x2000,
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
internal static extern SafeWaitHandle CreateJobObjectW(IntPtr lpJobAttributes, string? lpName);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
internal static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInfoClass jobObjectInformationClass, IntPtr lpJobObjectInformation, uint cbJobObjectInformationLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
internal static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
internal static extern IntPtr GetCommandLine();
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct JobObjectBasicLimitInformation
|
||||
{
|
||||
public long PerProcessUserTimeLimit;
|
||||
public long PerJobUserTimeLimit;
|
||||
public JobObjectLimitFlags LimitFlags;
|
||||
public UIntPtr MinimumWorkingSetSize;
|
||||
public UIntPtr MaximumWorkingSetSize;
|
||||
public uint ActiveProcessLimit;
|
||||
public UIntPtr Affinity;
|
||||
public uint PriorityClass;
|
||||
public uint SchedulingClass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct IoCounters
|
||||
{
|
||||
public ulong ReadOperationCount;
|
||||
public ulong WriteOperationCount;
|
||||
public ulong OtherOperationCount;
|
||||
public ulong ReadTransferCount;
|
||||
public ulong WriteTransferCount;
|
||||
public ulong OtherTransferCount;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct JobObjectExtendedLimitInformation
|
||||
{
|
||||
public JobObjectBasicLimitInformation BasicLimitInformation;
|
||||
public IoCounters IoInfo;
|
||||
public UIntPtr ProcessMemoryLimit;
|
||||
public UIntPtr JobMemoryLimit;
|
||||
public UIntPtr PeakProcessMemoryUsed;
|
||||
public UIntPtr PeakJobMemoryUsed;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct PROCESS_BASIC_INFORMATION
|
||||
{
|
||||
public uint ExitStatus;
|
||||
public IntPtr PebBaseAddress;
|
||||
public UIntPtr AffinityMask;
|
||||
public int BasePriority;
|
||||
public UIntPtr UniqueProcessId;
|
||||
public UIntPtr InheritedFromUniqueProcessId;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Posix
|
||||
{
|
||||
internal const int SIGINT = 2;
|
||||
internal const int SIGTERM = 15;
|
||||
|
||||
[DllImport("libc", SetLastError = true)]
|
||||
internal static extern int kill(int pid, int sig);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Responsible for reaping a target process if the current process terminates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On Windows, a job object will be used to ensure the termination of the target
|
||||
/// process (and its tree) even if the current process is rudely terminated.
|
||||
///
|
||||
/// On POSIX systems, the reaper will handle SIGTERM and attempt to forward the
|
||||
/// signal to the target process only.
|
||||
///
|
||||
/// The reaper also suppresses SIGINT in the current process to allow the target
|
||||
/// process to handle the signal.
|
||||
/// </remarks>
|
||||
internal sealed class ProcessReaper : IDisposable
|
||||
{
|
||||
private readonly Process _process;
|
||||
private SafeWaitHandle? _job;
|
||||
private Mutex? _shutdownMutex;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new process reaper.
|
||||
/// </summary>
|
||||
/// <param name="process">The target process to reap if the current process terminates. The process should not yet be started.</param>
|
||||
public ProcessReaper(Process process)
|
||||
{
|
||||
_process = process;
|
||||
|
||||
// The tests need the event handlers registered prior to spawning the child to prevent a race
|
||||
// where the child writes output the test expects before the intermediate dotnet process
|
||||
// has registered the event handlers to handle the signals the tests will generate.
|
||||
Console.CancelKeyPress += HandleCancelKeyPress;
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
_shutdownMutex = new Mutex();
|
||||
AppDomain.CurrentDomain.ProcessExit += HandleProcessExit;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call to notify the reaper that the process has started.
|
||||
/// </summary>
|
||||
public void NotifyProcessStarted()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// Limit the use of job objects to versions of Windows that support nested jobs (i.e. Windows 8/2012 or later).
|
||||
// Ideally, we would check for some new API export or OS feature instead of the OS version,
|
||||
// but nested jobs are transparently implemented with respect to the Job Objects API.
|
||||
// Note: Windows 8.1 and later may report as Windows 8 (see https://docs.microsoft.com/en-us/windows/desktop/sysinfo/operating-system-version).
|
||||
// However, for the purpose of this check that is still sufficient.
|
||||
if (Environment.OSVersion.Version.Major > 6 ||
|
||||
(Environment.OSVersion.Version.Major == 6 && Environment.OSVersion.Version.Minor >= 2))
|
||||
{
|
||||
_job = AssignProcessToJobObject(_process.Handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
if (_job != null)
|
||||
{
|
||||
// Clear the kill on close flag because the child process terminated successfully
|
||||
// If this fails, then we have no choice but to terminate any remaining processes in the job
|
||||
SetKillOnJobClose(_job.DangerousGetHandle(), false);
|
||||
|
||||
_job.Dispose();
|
||||
_job = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AppDomain.CurrentDomain.ProcessExit -= HandleProcessExit;
|
||||
|
||||
// If there's been a shutdown via the process exit handler,
|
||||
// this will block the current thread so we don't race with the CLR shutdown
|
||||
// from the signal handler.
|
||||
if (_shutdownMutex != null)
|
||||
{
|
||||
_shutdownMutex.WaitOne();
|
||||
_shutdownMutex.ReleaseMutex();
|
||||
_shutdownMutex.Dispose();
|
||||
_shutdownMutex = null;
|
||||
}
|
||||
}
|
||||
|
||||
Console.CancelKeyPress -= HandleCancelKeyPress;
|
||||
}
|
||||
|
||||
private static void HandleCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
|
||||
{
|
||||
// Ignore SIGINT/SIGQUIT so that the process can handle the signal
|
||||
e.Cancel = true;
|
||||
}
|
||||
|
||||
private static SafeWaitHandle? AssignProcessToJobObject(IntPtr process)
|
||||
{
|
||||
var job = NativeMethods.Windows.CreateJobObjectW(IntPtr.Zero, null);
|
||||
if (job == null || job.IsInvalid)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!SetKillOnJobClose(job.DangerousGetHandle(), true))
|
||||
{
|
||||
job.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!NativeMethods.Windows.AssignProcessToJobObject(job.DangerousGetHandle(), process))
|
||||
{
|
||||
job.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
private static bool SetKillOnJobClose(IntPtr job, bool value)
|
||||
{
|
||||
var information = new NativeMethods.Windows.JobObjectExtendedLimitInformation
|
||||
{
|
||||
BasicLimitInformation = new NativeMethods.Windows.JobObjectBasicLimitInformation
|
||||
{
|
||||
LimitFlags = value ? NativeMethods.Windows.JobObjectLimitFlags.JobObjectLimitKillOnJobClose : 0
|
||||
}
|
||||
};
|
||||
|
||||
var length = Marshal.SizeOf(typeof(NativeMethods.Windows.JobObjectExtendedLimitInformation));
|
||||
var informationPtr = Marshal.AllocHGlobal(length);
|
||||
|
||||
try
|
||||
{
|
||||
Marshal.StructureToPtr(information, informationPtr, false);
|
||||
|
||||
if (!NativeMethods.Windows.SetInformationJobObject(
|
||||
job,
|
||||
NativeMethods.Windows.JobObjectInfoClass.JobObjectExtendedLimitInformation,
|
||||
informationPtr,
|
||||
(uint)length))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(informationPtr);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleProcessExit(object? sender, EventArgs args)
|
||||
{
|
||||
int processId;
|
||||
try
|
||||
{
|
||||
processId = _process.Id;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// The process hasn't started yet; nothing to signal
|
||||
return;
|
||||
}
|
||||
|
||||
// Take ownership of the shutdown mutex; this will ensure that the other
|
||||
// thread also waiting on the process to exit won't complete CLR shutdown before
|
||||
// this one does.
|
||||
_shutdownMutex?.WaitOne();
|
||||
|
||||
if (!_process.WaitForExit(0) && NativeMethods.Posix.kill(processId, NativeMethods.Posix.SIGTERM) != 0)
|
||||
{
|
||||
// Couldn't send the signal, don't wait
|
||||
return;
|
||||
}
|
||||
|
||||
// If SIGTERM was ignored by the target, then we'll still wait
|
||||
_process.WaitForExit();
|
||||
|
||||
Environment.ExitCode = _process.ExitCode;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
internal sealed class SdkCommandSpec
|
||||
{
|
||||
public string? FileName { get; set; }
|
||||
|
||||
public List<string> Arguments { get; set; } = new List<string>();
|
||||
|
||||
public Dictionary<string, string> Environment { get; set; } = new Dictionary<string, string>();
|
||||
|
||||
public List<string> EnvironmentToRemove { get; } = new List<string>();
|
||||
|
||||
public string? WorkingDirectory { get; set; }
|
||||
|
||||
public Command ToCommand()
|
||||
{
|
||||
var process = new Process()
|
||||
{
|
||||
StartInfo = ToProcessStartInfo()
|
||||
};
|
||||
var ret = new Command(process, trimtrailingNewlines: true);
|
||||
return ret;
|
||||
}
|
||||
|
||||
public ProcessStartInfo ToProcessStartInfo()
|
||||
{
|
||||
var ret = new ProcessStartInfo
|
||||
{
|
||||
FileName = FileName,
|
||||
Arguments = EscapeArgs(),
|
||||
UseShellExecute = false
|
||||
};
|
||||
foreach (var kvp in Environment)
|
||||
{
|
||||
ret.Environment[kvp.Key] = kvp.Value;
|
||||
}
|
||||
foreach (var envToRemove in EnvironmentToRemove)
|
||||
{
|
||||
ret.Environment.Remove(envToRemove);
|
||||
}
|
||||
|
||||
if (WorkingDirectory != null)
|
||||
{
|
||||
ret.WorkingDirectory = WorkingDirectory;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
private string EscapeArgs()
|
||||
{
|
||||
// Note: this doesn't handle invoking .cmd files via "cmd /c" on Windows, which probably won't be necessary here
|
||||
// If it is, refer to the code in WindowsExePreferredCommandSpecFactory in Microsoft.DotNet.Cli.Utils
|
||||
return ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(Arguments);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Xunit.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils;
|
||||
|
||||
/// <summary>
|
||||
/// This is an abstraction so we can pass ITestOutputHelper to TestCommand constructor
|
||||
/// when calling from class fixture.
|
||||
/// </summary>
|
||||
public class SharedTestOutputHelper : ITestOutputHelper
|
||||
{
|
||||
private readonly IMessageSink _sink;
|
||||
|
||||
public SharedTestOutputHelper(IMessageSink sink)
|
||||
{
|
||||
this._sink = sink;
|
||||
}
|
||||
|
||||
public void WriteLine(string message)
|
||||
{
|
||||
_sink.OnMessage(new DiagnosticMessage(message));
|
||||
}
|
||||
|
||||
public void WriteLine(string format, params object[] args)
|
||||
{
|
||||
_sink.OnMessage(new DiagnosticMessage(format, args));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
internal sealed class StreamForwarder
|
||||
{
|
||||
private const char FlushBuilderCharacter = '\n';
|
||||
private static readonly char[] IgnoreCharacters = new char[] { '\r' };
|
||||
|
||||
private StringBuilder? _builder;
|
||||
private StringWriter? _capture;
|
||||
private Action<string>? _writeLine;
|
||||
private bool _trimTrailingCapturedNewline;
|
||||
|
||||
public string? CapturedOutput
|
||||
{
|
||||
get
|
||||
{
|
||||
string? capture = _capture?.GetStringBuilder()?.ToString();
|
||||
if (_trimTrailingCapturedNewline)
|
||||
{
|
||||
capture = capture?.TrimEnd('\r', '\n');
|
||||
}
|
||||
return capture;
|
||||
}
|
||||
}
|
||||
|
||||
public StreamForwarder Capture(bool trimTrailingNewline = false)
|
||||
{
|
||||
ThrowIfCaptureSet();
|
||||
|
||||
_capture = new StringWriter();
|
||||
_trimTrailingCapturedNewline = trimTrailingNewline;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamForwarder ForwardTo(Action<string> writeLine)
|
||||
{
|
||||
ThrowIfNull(writeLine);
|
||||
|
||||
ThrowIfForwarderSet();
|
||||
|
||||
_writeLine = writeLine;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public Task BeginRead(TextReader reader) => Task.Run(() => Read(reader));
|
||||
|
||||
public void Read(TextReader reader)
|
||||
{
|
||||
int bufferSize = 1;
|
||||
char currentCharacter;
|
||||
|
||||
char[] buffer = new char[bufferSize];
|
||||
_builder = new StringBuilder();
|
||||
|
||||
// Using Read with buffer size 1 to prevent looping endlessly
|
||||
// like we would when using Read() with no buffer
|
||||
while ((_ = reader.Read(buffer, 0, bufferSize)) > 0)
|
||||
{
|
||||
currentCharacter = buffer[0];
|
||||
|
||||
if (currentCharacter == FlushBuilderCharacter)
|
||||
{
|
||||
WriteBuilder();
|
||||
}
|
||||
else if (!IgnoreCharacters.Contains(currentCharacter))
|
||||
{
|
||||
_ = _builder.Append(currentCharacter);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush anything else when the stream is closed
|
||||
// Which should only happen if someone used console.Write
|
||||
if (_builder.Length > 0)
|
||||
{
|
||||
WriteBuilder();
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteBuilder()
|
||||
{
|
||||
WriteLine(_builder?.ToString());
|
||||
_ = (_builder?.Clear());
|
||||
}
|
||||
|
||||
private void WriteLine(string? str)
|
||||
{
|
||||
_capture?.WriteLine(str);
|
||||
|
||||
if (_writeLine != null && str != null)
|
||||
{
|
||||
_writeLine(str);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfNull(object obj)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(obj);
|
||||
}
|
||||
|
||||
private void ThrowIfForwarderSet()
|
||||
{
|
||||
if (_writeLine != null)
|
||||
{
|
||||
throw new InvalidOperationException("WriteLine forwarder set previously");
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfCaptureSet()
|
||||
{
|
||||
if (_capture != null)
|
||||
{
|
||||
throw new InvalidOperationException("Already capturing stream!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.DotNet.CommandUtils
|
||||
{
|
||||
internal abstract class TestCommand
|
||||
{
|
||||
private readonly ITestOutputHelper? _log;
|
||||
|
||||
protected TestCommand(ITestOutputHelper? log)
|
||||
{
|
||||
_log = log;
|
||||
}
|
||||
|
||||
internal string? WorkingDirectory { get; set; }
|
||||
|
||||
internal List<string> Arguments { get; set; } = new List<string>();
|
||||
|
||||
internal List<string> EnvironmentToRemove { get; } = new List<string>();
|
||||
|
||||
// These only work via Execute(), not when using GetProcessStartInfo()
|
||||
internal Action<string>? CommandOutputHandler { get; set; }
|
||||
|
||||
internal Action<Process>? ProcessStartedHandler { get; set; }
|
||||
|
||||
protected Dictionary<string, string> Environment { get; set; } = new Dictionary<string, string>();
|
||||
|
||||
internal TestCommand WithEnvironmentVariable(string name, string value)
|
||||
{
|
||||
Environment[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
internal TestCommand WithEnvironmentVariables(IReadOnlyDictionary<string, string>? variables)
|
||||
{
|
||||
if (variables != null)
|
||||
{
|
||||
foreach (KeyValuePair<string, string> pair in variables)
|
||||
{
|
||||
Environment[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
internal TestCommand WithWorkingDirectory(string workingDirectory)
|
||||
{
|
||||
WorkingDirectory = workingDirectory;
|
||||
return this;
|
||||
}
|
||||
|
||||
internal TestCommand WithNoUpdateCheck()
|
||||
{
|
||||
Arguments.Add("--no-update-check");
|
||||
return this;
|
||||
}
|
||||
|
||||
internal ProcessStartInfo GetProcessStartInfo(params string[] args)
|
||||
{
|
||||
SdkCommandSpec commandSpec = CreateCommandSpec(args);
|
||||
|
||||
var psi = commandSpec.ToProcessStartInfo();
|
||||
|
||||
return psi;
|
||||
}
|
||||
|
||||
internal CommandResult Execute(params string[] args)
|
||||
{
|
||||
IEnumerable<string> enumerableArgs = args;
|
||||
return Execute(enumerableArgs);
|
||||
}
|
||||
|
||||
internal virtual CommandResult Execute(IEnumerable<string> args)
|
||||
{
|
||||
Command command = CreateCommandSpec(args)
|
||||
.ToCommand()
|
||||
.CaptureStdOut()
|
||||
.CaptureStdErr();
|
||||
|
||||
if (CommandOutputHandler != null)
|
||||
{
|
||||
command.OnOutputLine(CommandOutputHandler);
|
||||
}
|
||||
|
||||
var result = command.Execute(ProcessStartedHandler);
|
||||
|
||||
_log?.WriteLine($"> {result.StartInfo.FileName} {result.StartInfo.Arguments}");
|
||||
_log?.WriteLine(result.StdOut);
|
||||
|
||||
if (!string.IsNullOrEmpty(result.StdErr))
|
||||
{
|
||||
_log?.WriteLine(string.Empty);
|
||||
_log?.WriteLine("StdErr:");
|
||||
_log?.WriteLine(result.StdErr);
|
||||
}
|
||||
|
||||
if (result.ExitCode != 0)
|
||||
{
|
||||
_log?.WriteLine($"Exit Code: {result.ExitCode}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private protected abstract SdkCommandSpec CreateCommand(IEnumerable<string> args);
|
||||
|
||||
private SdkCommandSpec CreateCommandSpec(IEnumerable<string> args)
|
||||
{
|
||||
var commandSpec = CreateCommand(args);
|
||||
foreach (var kvp in Environment)
|
||||
{
|
||||
commandSpec.Environment[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
foreach (var envToRemove in EnvironmentToRemove)
|
||||
{
|
||||
commandSpec.EnvironmentToRemove.Add(envToRemove);
|
||||
}
|
||||
|
||||
if (WorkingDirectory != null)
|
||||
{
|
||||
commandSpec.WorkingDirectory = WorkingDirectory;
|
||||
}
|
||||
|
||||
if (Arguments.Any())
|
||||
{
|
||||
commandSpec.Arguments = Arguments.Concat(commandSpec.Arguments).ToList();
|
||||
}
|
||||
|
||||
return commandSpec;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Build.Utilities;
|
||||
using Microsoft.DotNet.CommandUtils;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.Tasks.IntegrationTests;
|
||||
|
||||
[Collection("Docker tests")]
|
||||
public class CreateNewImageTests
|
||||
{
|
||||
private ITestOutputHelper _testOutput;
|
||||
|
||||
public CreateNewImageTests(ITestOutputHelper testOutput)
|
||||
{
|
||||
_testOutput = testOutput;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateNewImage_Baseline()
|
||||
{
|
||||
DirectoryInfo newProjectDir = new DirectoryInfo(Path.Combine(TestSettings.TestArtifactsDirectory, nameof(CreateNewImage_Baseline)));
|
||||
|
||||
if (newProjectDir.Exists)
|
||||
{
|
||||
newProjectDir.Delete(recursive: true);
|
||||
}
|
||||
|
||||
newProjectDir.Create();
|
||||
|
||||
new DotnetCommand(_testOutput, "new", "console", "-f", "net7.0")
|
||||
.WithWorkingDirectory(newProjectDir.FullName)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
new DotnetCommand(_testOutput, "publish", "-c", "Release", "-r", "linux-arm64", "--no-self-contained")
|
||||
.WithWorkingDirectory(newProjectDir.FullName)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
CreateNewImage task = new CreateNewImage();
|
||||
task.BaseRegistry = "mcr.microsoft.com";
|
||||
task.BaseImageName = "dotnet/runtime";
|
||||
task.BaseImageTag = "7.0";
|
||||
|
||||
task.OutputRegistry = "localhost:5010";
|
||||
task.PublishDirectory = Path.Combine(newProjectDir.FullName, "bin", "Release", "net7.0", "linux-arm64", "publish");
|
||||
task.ImageName = "dotnet/testimage";
|
||||
task.ImageTags = new[] { "latest" };
|
||||
task.WorkingDirectory = "app/";
|
||||
task.ContainerRuntimeIdentifier = "linux-arm64";
|
||||
task.Entrypoint = new TaskItem[] { new("dotnet"), new("build") };
|
||||
task.RuntimeIdentifierGraphPath = ToolsetUtils.GetRuntimeGraphFilePath();
|
||||
|
||||
Assert.True(task.Execute());
|
||||
newProjectDir.Delete(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseContainerProperties_EndToEnd()
|
||||
{
|
||||
DirectoryInfo newProjectDir = new DirectoryInfo(Path.Combine(TestSettings.TestArtifactsDirectory, nameof(ParseContainerProperties_EndToEnd)));
|
||||
|
||||
if (newProjectDir.Exists)
|
||||
{
|
||||
newProjectDir.Delete(recursive: true);
|
||||
}
|
||||
|
||||
newProjectDir.Create();
|
||||
|
||||
new DotnetCommand(_testOutput, "new", "console", "-f", "net7.0")
|
||||
.WithWorkingDirectory(newProjectDir.FullName)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
new DotnetCommand(_testOutput, "build", "--configuration", "release")
|
||||
.WithWorkingDirectory(newProjectDir.FullName)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
ParseContainerProperties pcp = new ParseContainerProperties();
|
||||
pcp.FullyQualifiedBaseImageName = "mcr.microsoft.com/dotnet/runtime:7.0";
|
||||
pcp.ContainerRegistry = "localhost:5010";
|
||||
pcp.ContainerImageName = "dotnet/testimage";
|
||||
pcp.ContainerImageTags = new[] { "5.0", "latest" };
|
||||
|
||||
Assert.True(pcp.Execute());
|
||||
Assert.Equal("mcr.microsoft.com", pcp.ParsedContainerRegistry);
|
||||
Assert.Equal("dotnet/runtime", pcp.ParsedContainerImage);
|
||||
Assert.Equal("7.0", pcp.ParsedContainerTag);
|
||||
|
||||
Assert.Equal("dotnet/testimage", pcp.NewContainerImageName);
|
||||
pcp.NewContainerTags.Should().BeEquivalentTo(new[] { "5.0", "latest" });
|
||||
|
||||
CreateNewImage cni = new CreateNewImage();
|
||||
cni.BaseRegistry = pcp.ParsedContainerRegistry;
|
||||
cni.BaseImageName = pcp.ParsedContainerImage;
|
||||
cni.BaseImageTag = pcp.ParsedContainerTag;
|
||||
cni.ImageName = pcp.NewContainerImageName;
|
||||
cni.OutputRegistry = "localhost:5010";
|
||||
cni.PublishDirectory = Path.Combine(newProjectDir.FullName, "bin", "release", "net7.0");
|
||||
cni.WorkingDirectory = "app/";
|
||||
cni.Entrypoint = new TaskItem[] { new("ParseContainerProperties_EndToEnd") };
|
||||
cni.ImageTags = pcp.NewContainerTags;
|
||||
cni.ContainerRuntimeIdentifier = "linux-x64";
|
||||
cni.RuntimeIdentifierGraphPath = ToolsetUtils.GetRuntimeGraphFilePath();
|
||||
|
||||
Assert.True(cni.Execute());
|
||||
newProjectDir.Delete(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a console app that outputs the environment variable added to the image.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Tasks_EndToEnd_With_EnvironmentVariable_Validation()
|
||||
{
|
||||
DirectoryInfo newProjectDir = new DirectoryInfo(Path.Combine(TestSettings.TestArtifactsDirectory, nameof(Tasks_EndToEnd_With_EnvironmentVariable_Validation)));
|
||||
|
||||
if (newProjectDir.Exists)
|
||||
{
|
||||
newProjectDir.Delete(recursive: true);
|
||||
}
|
||||
|
||||
newProjectDir.Create();
|
||||
|
||||
new DotnetCommand(_testOutput, "new", "console", "-f", "net7.0")
|
||||
.WithWorkingDirectory(newProjectDir.FullName)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
File.WriteAllText(Path.Combine(newProjectDir.FullName, "Program.cs"), $"Console.Write(Environment.GetEnvironmentVariable(\"GoodEnvVar\"));");
|
||||
|
||||
new DotnetCommand(_testOutput, "build", "--configuration", "release", "/p:runtimeidentifier=linux-x64")
|
||||
.WithWorkingDirectory(newProjectDir.FullName)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
ParseContainerProperties pcp = new ParseContainerProperties();
|
||||
pcp.FullyQualifiedBaseImageName = "mcr.microsoft.com/dotnet/runtime:6.0";
|
||||
pcp.ContainerRegistry = "";
|
||||
pcp.ContainerImageName = "dotnet/envvarvalidation";
|
||||
pcp.ContainerImageTag = "latest";
|
||||
|
||||
Dictionary<string, string> dict = new Dictionary<string, string>();
|
||||
dict.Add("Value", "Foo");
|
||||
|
||||
pcp.ContainerEnvironmentVariables = new[] { new TaskItem("B@dEnv.Var", dict), new TaskItem("GoodEnvVar", dict) };
|
||||
|
||||
Assert.True(pcp.Execute());
|
||||
Assert.Equal("mcr.microsoft.com", pcp.ParsedContainerRegistry);
|
||||
Assert.Equal("dotnet/runtime", pcp.ParsedContainerImage);
|
||||
Assert.Equal("6.0", pcp.ParsedContainerTag);
|
||||
Assert.Single(pcp.NewContainerEnvironmentVariables);
|
||||
Assert.Equal("Foo", pcp.NewContainerEnvironmentVariables[0].GetMetadata("Value"));
|
||||
|
||||
Assert.Equal("dotnet/envvarvalidation", pcp.NewContainerImageName);
|
||||
Assert.Equal("latest", pcp.NewContainerTags[0]);
|
||||
|
||||
CreateNewImage cni = new CreateNewImage();
|
||||
cni.BaseRegistry = pcp.ParsedContainerRegistry;
|
||||
cni.BaseImageName = pcp.ParsedContainerImage;
|
||||
cni.BaseImageTag = pcp.ParsedContainerTag;
|
||||
cni.ImageName = pcp.NewContainerImageName;
|
||||
cni.OutputRegistry = pcp.NewContainerRegistry;
|
||||
cni.PublishDirectory = Path.Combine(newProjectDir.FullName, "bin", "release", "net7.0", "linux-x64");
|
||||
cni.WorkingDirectory = "/app";
|
||||
cni.Entrypoint = new TaskItem[] { new("/app/Tasks_EndToEnd_With_EnvironmentVariable_Validation") };
|
||||
cni.ImageTags = pcp.NewContainerTags;
|
||||
cni.ContainerEnvironmentVariables = pcp.NewContainerEnvironmentVariables;
|
||||
cni.ContainerRuntimeIdentifier = "linux-x64";
|
||||
cni.RuntimeIdentifierGraphPath = ToolsetUtils.GetRuntimeGraphFilePath();
|
||||
cni.LocalContainerDaemon = global::Microsoft.NET.Build.Containers.KnownDaemonTypes.Docker;
|
||||
|
||||
Assert.True(cni.Execute());
|
||||
|
||||
new BasicCommand(_testOutput, "docker", "run", "--rm", $"{pcp.NewContainerImageName}:latest")
|
||||
.Execute()
|
||||
.Should().Pass()
|
||||
.And.HaveStdOut("Foo");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
public static class CurrentFile
|
||||
{
|
||||
public static string Path([CallerFilePath] string file = "") => file;
|
||||
|
||||
public static string Relative(string relative, [CallerFilePath] string file = "") {
|
||||
return global::System.IO.Path.Combine(global::System.IO.Path.GetDirectoryName(file)!, relative); // file known to be not-null due to the mechanics of CallerFilePath
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.DotNet.CommandUtils;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
public class DockerRegistryManager
|
||||
{
|
||||
public const string BaseImage = "dotnet/runtime";
|
||||
public const string BaseImageSource = "mcr.microsoft.com/";
|
||||
public const string Net6ImageTag = "6.0";
|
||||
public const string Net7ImageTag = "7.0";
|
||||
public const string LocalRegistry = "localhost:5010";
|
||||
public const string FullyQualifiedBaseImageDefault = $"{BaseImageSource}{BaseImage}:{Net6ImageTag}";
|
||||
private static string? s_registryContainerId;
|
||||
|
||||
public static void StartAndPopulateDockerRegistry(ITestOutputHelper testOutput)
|
||||
{
|
||||
testOutput.WriteLine("Spawning local registry");
|
||||
CommandResult processResult = new BasicCommand(testOutput, "docker", "run", "--rm", "--publish", "5010:5000", "--detach", "registry:2").Execute();
|
||||
processResult.Should().Pass().And.HaveStdOut();
|
||||
using var reader = new StringReader(processResult.StdOut!);
|
||||
s_registryContainerId = reader.ReadLine();
|
||||
|
||||
foreach (var tag in new[] { Net6ImageTag, Net7ImageTag })
|
||||
{
|
||||
new BasicCommand(testOutput, "docker", "pull", $"{BaseImageSource}{BaseImage}:{tag}")
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
new BasicCommand(testOutput, "docker", "tag", $"{BaseImageSource}{BaseImage}:{tag}", $"{LocalRegistry}/{BaseImage}:{tag}")
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
new BasicCommand(testOutput, "docker", "push", $"{LocalRegistry}/{BaseImage}:{tag}")
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
}
|
||||
}
|
||||
|
||||
public static void ShutdownDockerRegistry(ITestOutputHelper testOutput)
|
||||
{
|
||||
Assert.NotNull(s_registryContainerId);
|
||||
|
||||
new BasicCommand(testOutput, "docker", "stop", s_registryContainerId)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
[CollectionDefinition("Docker tests")]
|
||||
#pragma warning disable CA1711 // Identifiers should not have incorrect suffix
|
||||
public class DockerTestsCollection : ICollectionFixture<DockerTestsFixture>
|
||||
#pragma warning restore CA1711 // Identifiers should not have incorrect suffix
|
||||
{
|
||||
// This class has no code, and is never created. Its purpose is simply
|
||||
// to be the place to apply [CollectionDefinition] and all the
|
||||
// ICollectionFixture<> interfaces.
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.DotNet.CommandUtils;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
public sealed class DockerTestsFixture : IDisposable
|
||||
{
|
||||
private ITestOutputHelper _diagnosticOutput;
|
||||
|
||||
public DockerTestsFixture(IMessageSink messageSink)
|
||||
{
|
||||
_diagnosticOutput = new SharedTestOutputHelper(messageSink);
|
||||
DockerRegistryManager.StartAndPopulateDockerRegistry(_diagnosticOutput);
|
||||
ProjectInitializer.LocateMSBuild();
|
||||
Directory.CreateDirectory(TestSettings.TestArtifactsDirectory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DockerRegistryManager.ShutdownDockerRegistry(_diagnosticOutput);
|
||||
ProjectInitializer.Cleanup();
|
||||
//clean up tests artifacts
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(TestSettings.TestArtifactsDirectory))
|
||||
{
|
||||
Directory.Delete(TestSettings.TestArtifactsDirectory, true);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,335 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.DotNet.CommandUtils;
|
||||
using Microsoft.NET.Build.Containers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
[Collection("Docker tests")]
|
||||
public class EndToEndTests
|
||||
{
|
||||
private ITestOutputHelper _testOutput;
|
||||
|
||||
public EndToEndTests(ITestOutputHelper testOutput)
|
||||
{
|
||||
_testOutput = testOutput;
|
||||
}
|
||||
|
||||
public static string NewImageName([CallerMemberName] string callerMemberName = "")
|
||||
{
|
||||
bool normalized = ContainerHelpers.NormalizeImageName(callerMemberName, out string? normalizedName);
|
||||
if (!normalized)
|
||||
{
|
||||
return normalizedName!;
|
||||
}
|
||||
|
||||
return callerMemberName;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApiEndToEndWithRegistryPushAndPull()
|
||||
{
|
||||
string publishDirectory = BuildLocalApp();
|
||||
|
||||
// Build the image
|
||||
|
||||
Registry registry = new Registry(ContainerHelpers.TryExpandRegistryToUri(DockerRegistryManager.LocalRegistry));
|
||||
|
||||
Image? x = await registry.GetImageManifest(
|
||||
DockerRegistryManager.BaseImage,
|
||||
DockerRegistryManager.Net6ImageTag,
|
||||
"linux-x64",
|
||||
ToolsetUtils.GetRuntimeGraphFilePath()).ConfigureAwait(false);
|
||||
|
||||
Assert.NotNull(x);
|
||||
|
||||
Layer l = Layer.FromDirectory(publishDirectory, "/app");
|
||||
|
||||
x.AddLayer(l);
|
||||
|
||||
x.SetEntrypoint(new[] { "/app/MinimalTestApp" });
|
||||
|
||||
// Push the image back to the local registry
|
||||
var sourceReference = new ImageReference(registry, DockerRegistryManager.BaseImage, DockerRegistryManager.Net6ImageTag);
|
||||
var destinationReference = new ImageReference(registry, NewImageName(), "latest");
|
||||
|
||||
await registry.Push(x, sourceReference, destinationReference, Console.WriteLine).ConfigureAwait(false);
|
||||
|
||||
// pull it back locally
|
||||
new BasicCommand(_testOutput, "docker", "pull", $"{DockerRegistryManager.LocalRegistry}/{NewImageName()}:latest")
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
// Run the image
|
||||
new BasicCommand(_testOutput, "docker", "run", "--rm", "--tty", $"{DockerRegistryManager.LocalRegistry}/{NewImageName()}:latest")
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApiEndToEndWithLocalLoad()
|
||||
{
|
||||
string publishDirectory = BuildLocalApp();
|
||||
|
||||
// Build the image
|
||||
|
||||
Registry registry = new Registry(ContainerHelpers.TryExpandRegistryToUri(DockerRegistryManager.LocalRegistry));
|
||||
|
||||
Image? x = await registry.GetImageManifest(
|
||||
DockerRegistryManager.BaseImage,
|
||||
DockerRegistryManager.Net6ImageTag,
|
||||
"linux-x64",
|
||||
ToolsetUtils.GetRuntimeGraphFilePath()).ConfigureAwait(false);
|
||||
Assert.NotNull(x);
|
||||
|
||||
Layer l = Layer.FromDirectory(publishDirectory, "/app");
|
||||
|
||||
x.AddLayer(l);
|
||||
|
||||
x.SetEntrypoint(new[] { "/app/MinimalTestApp" });
|
||||
|
||||
// Load the image into the local Docker daemon
|
||||
var sourceReference = new ImageReference(registry, DockerRegistryManager.BaseImage, DockerRegistryManager.Net6ImageTag);
|
||||
var destinationReference = new ImageReference(registry, NewImageName(), "latest");
|
||||
|
||||
await new LocalDocker(Console.WriteLine).Load(x, sourceReference, destinationReference).ConfigureAwait(false);
|
||||
|
||||
// Run the image
|
||||
new BasicCommand(_testOutput, "docker", "run", "--rm", "--tty", $"{NewImageName()}:latest")
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
}
|
||||
|
||||
private string BuildLocalApp([CallerMemberName] string testName = "TestName", string tfm = "net6.0", string rid = "linux-x64")
|
||||
{
|
||||
string workingDirectory = Path.Combine(TestSettings.TestArtifactsDirectory, testName);
|
||||
|
||||
DirectoryInfo d = new DirectoryInfo(Path.Combine(workingDirectory, "MinimalTestApp"));
|
||||
if (d.Exists)
|
||||
{
|
||||
d.Delete(recursive: true);
|
||||
}
|
||||
Directory.CreateDirectory(workingDirectory);
|
||||
|
||||
new DotnetCommand(_testOutput, "new", "console", "-f", tfm, "-o", "MinimalTestApp")
|
||||
.WithWorkingDirectory(workingDirectory)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
new DotnetCommand(_testOutput, "publish", "-bl", "MinimalTestApp", "-r", rid, "-f", tfm)
|
||||
.WithWorkingDirectory(workingDirectory)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
string publishDirectory = Path.Join(workingDirectory, "MinimalTestApp", "bin", "Debug", tfm, rid, "publish");
|
||||
return publishDirectory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndToEnd_NoAPI()
|
||||
{
|
||||
DirectoryInfo newProjectDir = new DirectoryInfo(Path.Combine(TestSettings.TestArtifactsDirectory, "CreateNewImageTest"));
|
||||
DirectoryInfo privateNuGetAssets = new DirectoryInfo(Path.Combine(TestSettings.TestArtifactsDirectory, "ContainerNuGet"));
|
||||
|
||||
if (newProjectDir.Exists)
|
||||
{
|
||||
newProjectDir.Delete(recursive: true);
|
||||
}
|
||||
|
||||
if (privateNuGetAssets.Exists)
|
||||
{
|
||||
privateNuGetAssets.Delete(recursive: true);
|
||||
}
|
||||
|
||||
newProjectDir.Create();
|
||||
privateNuGetAssets.Create();
|
||||
var repoGlobalJson = Path.Combine("..", "..", "..", "..", "global.json");
|
||||
File.Copy(repoGlobalJson, Path.Combine(newProjectDir.FullName, "global.json"));
|
||||
|
||||
var packagedir = new DirectoryInfo(CurrentFile.Relative("./package"));
|
||||
|
||||
// do not pollute the primary/global NuGet package store with the private package(s)
|
||||
var env = new (string, string)[] { new("NUGET_PACKAGES", privateNuGetAssets.FullName) };
|
||||
// 🤢
|
||||
FileInfo[] nupkgs = packagedir.GetFiles("*.nupkg");
|
||||
if (nupkgs == null || nupkgs.Length == 0)
|
||||
{
|
||||
// Build Microsoft.NET.Build.Containers.csproj & wait.
|
||||
// for now, fail.
|
||||
Assert.Fail("No nupkg found in expected package folder. You may need to rerun the build");
|
||||
}
|
||||
|
||||
|
||||
new DotnetCommand(_testOutput, "new", "webapi", "-f", "net7.0")
|
||||
.WithWorkingDirectory(newProjectDir.FullName)
|
||||
// do not pollute the primary/global NuGet package store with the private package(s)
|
||||
.WithEnvironmentVariable("NUGET_PACKAGES", privateNuGetAssets.FullName)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
new DotnetCommand(_testOutput, "new", "nugetconfig")
|
||||
.WithWorkingDirectory(newProjectDir.FullName)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
new DotnetCommand(_testOutput, "nuget", "add", "source", packagedir.FullName, "--name", "local-temp")
|
||||
.WithEnvironmentVariable("NUGET_PACKAGES", privateNuGetAssets.FullName)
|
||||
.WithWorkingDirectory(newProjectDir.FullName)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
// Add package to the project
|
||||
new DotnetCommand(_testOutput, "add", "package", "Microsoft.NET.Build.Containers", "--prerelease", "-f", "net7.0")
|
||||
.WithEnvironmentVariable("NUGET_PACKAGES", privateNuGetAssets.FullName)
|
||||
.WithWorkingDirectory(newProjectDir.FullName)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
string imageName = NewImageName();
|
||||
string imageTag = "1.0";
|
||||
|
||||
// Build & publish the project
|
||||
new DotnetCommand(
|
||||
_testOutput,
|
||||
"publish",
|
||||
"/p:publishprofile=DefaultContainer",
|
||||
"/p:runtimeidentifier=linux-x64",
|
||||
"/bl",
|
||||
$"/p:ContainerBaseImage={DockerRegistryManager.FullyQualifiedBaseImageDefault}",
|
||||
$"/p:ContainerRegistry={DockerRegistryManager.LocalRegistry}",
|
||||
$"/p:ContainerImageName={imageName}",
|
||||
$"/p:Version={imageTag}")
|
||||
.WithEnvironmentVariable("NUGET_PACKAGES", privateNuGetAssets.FullName)
|
||||
.WithWorkingDirectory(newProjectDir.FullName)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
new BasicCommand(_testOutput, "docker", "pull", $"{DockerRegistryManager.LocalRegistry}/{imageName}:{imageTag}")
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
var containerName = "test-container-1";
|
||||
CommandResult processResult = new BasicCommand(
|
||||
_testOutput,
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
containerName,
|
||||
"--publish",
|
||||
"5017:80",
|
||||
"--detach",
|
||||
$"{DockerRegistryManager.LocalRegistry}/{imageName}:{imageTag}")
|
||||
.Execute();
|
||||
processResult.Should().Pass();
|
||||
Assert.NotNull(processResult.StdOut);
|
||||
|
||||
string appContainerId = processResult.StdOut.Trim();
|
||||
|
||||
bool everSucceeded = false;
|
||||
|
||||
HttpClient client = new();
|
||||
|
||||
// Give the server a moment to catch up, but no more than necessary.
|
||||
for (int retry = 0; retry < 10; retry++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync("http://localhost:5017/weatherforecast").ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
everSucceeded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
new BasicCommand(_testOutput, "docker", "logs", appContainerId)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
Assert.True(everSucceeded, "http://localhost:5017/weatherforecast never responded.");
|
||||
|
||||
new BasicCommand(_testOutput, "docker", "stop", appContainerId)
|
||||
.Execute()
|
||||
.Should().Pass();
|
||||
|
||||
newProjectDir.Delete(true);
|
||||
privateNuGetAssets.Delete(true);
|
||||
}
|
||||
|
||||
// These two are commented because the Github Actions runners don't let us easily configure the Docker Buildx config -
|
||||
// we need to configure it to allow emulation of other platforms on amd64 hosts before these two will run.
|
||||
// They do run locally, however.
|
||||
|
||||
//[InlineData("linux-arm", false, "/app", "linux/arm/v7")] // packaging framework-dependent because emulating arm on x64 Docker host doesn't work
|
||||
//[InlineData("linux-arm64", false, "/app", "linux/arm64/v8")] // packaging framework-dependent because emulating arm64 on x64 Docker host doesn't work
|
||||
|
||||
// this one should be skipped in all cases because we don't ship linux-x86 runtime packs, so we can't execute the 'apphost' version of the app
|
||||
//[InlineData("linux-x86", false, "/app", "linux/386")] // packaging framework-dependent because missing runtime packs for x86 linux.
|
||||
|
||||
// This one should be skipped because containers can't be configured to run on Linux hosts :(
|
||||
//[InlineData("win-x64", true, "C:\\app", "windows/amd64")]
|
||||
|
||||
// As a result, we only have one actual data-driven test
|
||||
[InlineData("linux-x64", true, "/app", "linux/amd64")]
|
||||
[Theory]
|
||||
public async Task CanPackageForAllSupportedContainerRIDs(string rid, bool isRIDSpecific, string workingDir, string dockerPlatform)
|
||||
{
|
||||
string publishDirectory = isRIDSpecific ? BuildLocalApp(tfm: "net7.0", rid: rid) : BuildLocalApp(tfm: "net7.0");
|
||||
|
||||
// Build the image
|
||||
Registry registry = new Registry(ContainerHelpers.TryExpandRegistryToUri(DockerRegistryManager.BaseImageSource));
|
||||
|
||||
Image? x = await registry.GetImageManifest(DockerRegistryManager.BaseImage, DockerRegistryManager.Net7ImageTag, rid, ToolsetUtils.GetRuntimeGraphFilePath()).ConfigureAwait(false);
|
||||
Assert.NotNull(x);
|
||||
|
||||
Layer l = Layer.FromDirectory(publishDirectory, "/app");
|
||||
|
||||
x.AddLayer(l);
|
||||
x.WorkingDirectory = workingDir;
|
||||
|
||||
var entryPoint = DecideEntrypoint(rid, isRIDSpecific, "MinimalTestApp", workingDir);
|
||||
x.SetEntrypoint(entryPoint);
|
||||
|
||||
// Load the image into the local Docker daemon
|
||||
var sourceReference = new ImageReference(registry, DockerRegistryManager.BaseImage, DockerRegistryManager.Net7ImageTag);
|
||||
var destinationReference = new ImageReference(registry, NewImageName(), rid);
|
||||
await new LocalDocker(Console.WriteLine).Load(x, sourceReference, destinationReference).ConfigureAwait(false);
|
||||
|
||||
// Run the image
|
||||
new BasicCommand(
|
||||
_testOutput,
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--tty",
|
||||
"--platform",
|
||||
dockerPlatform,
|
||||
$"{NewImageName()}:{rid}")
|
||||
.Execute()
|
||||
.Should()
|
||||
.Pass();
|
||||
|
||||
string[] DecideEntrypoint(string rid, bool isRIDSpecific, string appName, string workingDir)
|
||||
{
|
||||
var binary = rid.StartsWith("win", StringComparison.Ordinal) ? $"{appName}.exe" : appName;
|
||||
if (isRIDSpecific)
|
||||
{
|
||||
return new[] { $"{workingDir}/{binary}" };
|
||||
}
|
||||
else
|
||||
{
|
||||
return new[] { "dotnet", $"{workingDir}/{binary}.dll" };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Formats.Tar;
|
||||
using Microsoft.NET.Build.Containers;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Globalization;
|
||||
using Xunit.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
public sealed class LayerEndToEndTests : IDisposable
|
||||
{
|
||||
private ITestOutputHelper _testOutput;
|
||||
|
||||
public LayerEndToEndTests(ITestOutputHelper testOutput)
|
||||
{
|
||||
_testOutput = testOutput;
|
||||
testSpecificArtifactRoot = new();
|
||||
priorArtifactRoot = ContentStore.ArtifactRoot;
|
||||
ContentStore.ArtifactRoot = testSpecificArtifactRoot.Path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleFileInFolder()
|
||||
{
|
||||
using TransientTestFolder folder = new();
|
||||
|
||||
string testFilePath = Path.Join(folder.Path, "TestFile.txt");
|
||||
string testString = $"Test content for {nameof(SingleFileInFolder)}";
|
||||
|
||||
File.WriteAllText(testFilePath, testString);
|
||||
|
||||
Layer l = Layer.FromDirectory(directory: folder.Path, containerPath: "/app");
|
||||
|
||||
Console.WriteLine(l.Descriptor);
|
||||
|
||||
//Assert.AreEqual("application/vnd.oci.image.layer.v1.tar", l.Descriptor.MediaType); // TODO: configurability
|
||||
Assert.True(l.Descriptor.Size is >= 135 and <= 500, $"'l.Descriptor.Size' should be between 135 and 500, but is {l.Descriptor.Size}"); // TODO: determinism!
|
||||
//Assert.AreEqual("sha256:26140bc75f2fcb3bf5da7d3b531d995c93d192837e37df0eb5ca46e2db953124", l.Descriptor.Digest); // TODO: determinism!
|
||||
|
||||
VerifyDescriptorInfo(l);
|
||||
|
||||
var allEntries = LoadAllTarEntries(l.BackingFile);
|
||||
Assert.True(allEntries.TryGetValue("app/", out var appEntryType) && appEntryType == TarEntryType.Directory, "Missing app directory entry");
|
||||
Assert.True(allEntries.TryGetValue("app/TestFile.txt", out var fileEntryType) && fileEntryType == TarEntryType.RegularFile, "Missing TestFile.txt file entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoFilesInTwoFolders()
|
||||
{
|
||||
using TransientTestFolder folder = new();
|
||||
|
||||
string testFilePath = Path.Join(folder.Path, "TestFile.txt");
|
||||
string testString = $"Test content for {nameof(TwoFilesInTwoFolders)}";
|
||||
File.WriteAllText(testFilePath, testString);
|
||||
|
||||
using TransientTestFolder folder2 = new();
|
||||
string testFilePath2 = Path.Join(folder2.Path, "TestFile.txt");
|
||||
string testString2 = $"Test content 2 for {nameof(TwoFilesInTwoFolders)}";
|
||||
File.WriteAllText(testFilePath2, testString2);
|
||||
|
||||
Layer l = Layer.FromFiles(new[]
|
||||
{
|
||||
(testFilePath, "/app/TestFile.txt"),
|
||||
(testFilePath2, "/app/subfolder/TestFile.txt"),
|
||||
});
|
||||
|
||||
Console.WriteLine(l.Descriptor);
|
||||
|
||||
//Assert.AreEqual("application/vnd.oci.image.layer.v1.tar", l.Descriptor.MediaType); // TODO: configurability
|
||||
Assert.True(l.Descriptor.Size is >= 150 and <= 500, $"'l.Descriptor.Size' should be between 150 and 500, but is {l.Descriptor.Size}"); // TODO: determinism!
|
||||
//Assert.AreEqual("sha256:26140bc75f2fcb3bf5da7d3b531d995c93d192837e37df0eb5ca46e2db953124", l.Descriptor.Digest); // TODO: determinism!
|
||||
|
||||
VerifyDescriptorInfo(l);
|
||||
|
||||
var allEntries = LoadAllTarEntries(l.BackingFile);
|
||||
Assert.True(allEntries.TryGetValue("app/", out var appEntryType) && appEntryType == TarEntryType.Directory, "Missing app directory entry");
|
||||
Assert.True(allEntries.TryGetValue("app/TestFile.txt", out var fileEntryType) && fileEntryType == TarEntryType.RegularFile, "Missing TestFile.txt file entry");
|
||||
Assert.True(allEntries.TryGetValue("app/subfolder/", out var subfolderType) && subfolderType == TarEntryType.Directory, "Missing subfolder directory entry");
|
||||
Assert.True(allEntries.TryGetValue("app/subfolder/TestFile.txt", out var subfolderFileEntryType) && subfolderFileEntryType == TarEntryType.RegularFile, "Missing subfolder/TestFile.txt file entry");
|
||||
}
|
||||
|
||||
private static void VerifyDescriptorInfo(Layer l)
|
||||
{
|
||||
Assert.Equal(l.Descriptor.Size, new FileInfo(l.BackingFile).Length);
|
||||
|
||||
byte[] hashBytes;
|
||||
byte[] uncompressedHashBytes;
|
||||
|
||||
using (FileStream fs = File.OpenRead(l.BackingFile))
|
||||
{
|
||||
hashBytes = SHA256.HashData(fs);
|
||||
|
||||
fs.Position = 0;
|
||||
|
||||
using (GZipStream decompressionStream = new GZipStream(fs, CompressionMode.Decompress))
|
||||
{
|
||||
uncompressedHashBytes = SHA256.HashData(decompressionStream);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(Convert.ToHexString(hashBytes), l.Descriptor.Digest.Substring("sha256:".Length), ignoreCase: true);
|
||||
Assert.Equal(Convert.ToHexString(uncompressedHashBytes), l.Descriptor.UncompressedDigest?.Substring("sha256:".Length), ignoreCase: true);
|
||||
}
|
||||
|
||||
TransientTestFolder? testSpecificArtifactRoot;
|
||||
string? priorArtifactRoot;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
testSpecificArtifactRoot?.Dispose();
|
||||
if (priorArtifactRoot is not null)
|
||||
{
|
||||
ContentStore.ArtifactRoot = priorArtifactRoot;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, TarEntryType> LoadAllTarEntries(string file)
|
||||
{
|
||||
using var gzip = new GZipStream(File.OpenRead(file), CompressionMode.Decompress);
|
||||
using var tar = new TarReader(gzip);
|
||||
|
||||
var entries = new Dictionary<string, TarEntryType>();
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) != null)
|
||||
{
|
||||
entries[entry.Name] = entry.EntryType;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Nullable>enable</Nullable>
|
||||
<DisableMSBuildAssemblyCopyCheck>true</DisableMSBuildAssemblyCopyCheck>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Build.Utilities.Core" />
|
||||
<PackageReference Include="Microsoft.Build" />
|
||||
<PackageReference Include="Microsoft.Build.Framework"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.NET.Build.Containers\Microsoft.NET.Build.Containers.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
|
@ -0,0 +1,101 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
using Xunit;
|
||||
using static Microsoft.NET.Build.Containers.KnownStrings;
|
||||
using static Microsoft.NET.Build.Containers.KnownStrings.Properties;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.Tasks.IntegrationTests;
|
||||
|
||||
[Collection("Docker tests")]
|
||||
public class ParseContainerPropertiesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Baseline()
|
||||
{
|
||||
var (project, _) = ProjectInitializer.InitProject(new () {
|
||||
[ContainerBaseImage] = "mcr.microsoft.com/dotnet/runtime:7.0",
|
||||
[ContainerRegistry] = "localhost:5010",
|
||||
[ContainerImageName] = "dotnet/testimage",
|
||||
[ContainerImageTags] = "7.0;latest"
|
||||
});
|
||||
var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None);
|
||||
Assert.True(instance.Build(new[]{ComputeContainerConfig}, null, null, out var outputs));
|
||||
|
||||
Assert.Equal("mcr.microsoft.com", instance.GetPropertyValue(ContainerBaseRegistry));
|
||||
Assert.Equal("dotnet/runtime", instance.GetPropertyValue(ContainerBaseName));
|
||||
Assert.Equal("7.0", instance.GetPropertyValue(ContainerBaseTag));
|
||||
|
||||
Assert.Equal("dotnet/testimage", instance.GetPropertyValue(ContainerImageName));
|
||||
instance.GetItems(ContainerImageTags).Select(i => i.EvaluatedInclude).ToArray().Should().BeEquivalentTo(new[] { "7.0", "latest" });
|
||||
instance.GetItems("ProjectCapability").Select(i => i.EvaluatedInclude).ToArray().Should().BeEquivalentTo(new[] { "NetSdkOCIImageBuild" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpacesGetReplacedWithDashes()
|
||||
{
|
||||
var (project, _) = ProjectInitializer.InitProject(new () {
|
||||
[ContainerBaseImage] = "mcr microsoft com/dotnet runtime:7.0",
|
||||
[ContainerRegistry] = "localhost:5010"
|
||||
});
|
||||
|
||||
var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None);
|
||||
Assert.True(instance.Build(new[]{ComputeContainerConfig}, null, null, out var outputs));
|
||||
|
||||
Assert.Equal("mcr-microsoft-com",instance.GetPropertyValue(ContainerBaseRegistry));
|
||||
Assert.Equal("dotnet-runtime", instance.GetPropertyValue(ContainerBaseName));
|
||||
Assert.Equal("7.0", instance.GetPropertyValue(ContainerBaseTag));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegexCatchesInvalidContainerNames()
|
||||
{
|
||||
var (project, logs) = ProjectInitializer.InitProject(new () {
|
||||
[ContainerBaseImage] = "mcr.microsoft.com/dotnet/runtime:7.0",
|
||||
[ContainerRegistry] = "localhost:5010",
|
||||
[ContainerImageName] = "dotnet testimage",
|
||||
[ContainerImageTag] = "5.0"
|
||||
});
|
||||
|
||||
var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None);
|
||||
Assert.True(instance.Build(new[]{ComputeContainerConfig}, new [] { logs }, null, out var outputs));
|
||||
Assert.Contains(logs.Messages, m => m.Code == ErrorCodes.CONTAINER001 && m.Importance == global::Microsoft.Build.Framework.MessageImportance.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegexCatchesInvalidContainerTags()
|
||||
{
|
||||
var (project, logs) = ProjectInitializer.InitProject(new () {
|
||||
[ContainerBaseImage] = "mcr.microsoft.com/dotnet/runtime:7.0",
|
||||
[ContainerRegistry] = "localhost:5010",
|
||||
[ContainerImageName] = "dotnet/testimage",
|
||||
[ContainerImageTag] = "5 0"
|
||||
});
|
||||
|
||||
var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None);
|
||||
Assert.False(instance.Build(new[]{ComputeContainerConfig}, new [] { logs }, null, out var outputs));
|
||||
|
||||
Assert.True(logs.Errors.Count > 0);
|
||||
Assert.Equal(logs.Errors[0].Code, ErrorCodes.CONTAINER004);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOnlySupplyOneOfTagAndTags()
|
||||
{
|
||||
var (project, logs) = ProjectInitializer.InitProject(new () {
|
||||
[ContainerBaseImage] = "mcr.microsoft.com/dotnet/runtime:7.0",
|
||||
[ContainerRegistry] = "localhost:5010",
|
||||
[ContainerImageName] = "dotnet/testimage",
|
||||
[ContainerImageTag] = "5.0",
|
||||
[ContainerImageTags] = "latest;oldest"
|
||||
});
|
||||
|
||||
var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None);
|
||||
Assert.False(instance.Build(new[]{ComputeContainerConfig}, new [] { logs }, null, out var outputs));
|
||||
|
||||
Assert.True(logs.Errors.Count > 0);
|
||||
Assert.Equal(logs.Errors[0].Code, ErrorCodes.CONTAINER005);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Build.Evaluation;
|
||||
using Microsoft.Build.Framework;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
public static class ProjectInitializer {
|
||||
private static string? CombinedTargetsLocation;
|
||||
|
||||
private static string CombineFiles(string propsFile, string targetsFile)
|
||||
{
|
||||
var propsContent = File.ReadAllLines(propsFile);
|
||||
var targetsContent = File.ReadAllLines(targetsFile);
|
||||
var combinedContent = new List<string>();
|
||||
combinedContent.AddRange(propsContent[..^1]);
|
||||
combinedContent.AddRange(targetsContent[1..]);
|
||||
var tempTargetLocation = Path.Combine(Path.GetTempPath(), "Containers", "Microsoft.NET.Build.Containers.targets");
|
||||
string? directoryName = Path.GetDirectoryName(tempTargetLocation);
|
||||
Assert.NotNull(directoryName);
|
||||
Directory.CreateDirectory(directoryName);
|
||||
File.WriteAllLines(tempTargetLocation, combinedContent);
|
||||
return tempTargetLocation;
|
||||
}
|
||||
|
||||
public static void LocateMSBuild()
|
||||
{
|
||||
var relativePath = Path.Combine("..", "packaging", "build", "Microsoft.NET.Build.Containers.targets");
|
||||
var targetsFile = CurrentFile.Relative(relativePath);
|
||||
var propsFile = Path.ChangeExtension(targetsFile, ".props");
|
||||
CombinedTargetsLocation = CombineFiles(propsFile, targetsFile);
|
||||
}
|
||||
|
||||
public static void Cleanup()
|
||||
{
|
||||
if (CombinedTargetsLocation != null) File.Delete(CombinedTargetsLocation);
|
||||
}
|
||||
|
||||
public static (Project, CapturingLogger) InitProject(Dictionary<string, string> bonusProps, [CallerMemberName]string projectName = "")
|
||||
{
|
||||
var props = new Dictionary<string, string>();
|
||||
// required parameters
|
||||
props["TargetFileName"] = "foo.dll";
|
||||
props["AssemblyName"] = "foo";
|
||||
props["_TargetFrameworkVersionWithoutV"] = "7.0";
|
||||
props["_NativeExecutableExtension"] = ".exe"; //TODO: windows/unix split here
|
||||
props["Version"] = "1.0.0"; // TODO: need to test non-compliant version strings here
|
||||
props["NetCoreSdkVersion"] = "7.0.100"; // TODO: float this to current SDK?
|
||||
// test setup parameters so that we can load the props/targets/tasks
|
||||
props["ContainerCustomTasksAssembly"] = Path.GetFullPath(Path.Combine(".", "Microsoft.NET.Build.Containers.dll"));
|
||||
props["_IsTest"] = "true";
|
||||
|
||||
var safeBinlogFileName = projectName.Replace(" ", "_").Replace(":", "_").Replace("/", "_").Replace("\\", "_").Replace("*", "_");
|
||||
var loggers = new List<ILogger>
|
||||
{
|
||||
new global::Microsoft.Build.Logging.BinaryLogger() {CollectProjectImports = global::Microsoft.Build.Logging.BinaryLogger.ProjectImportsCollectionMode.Embed, Verbosity = LoggerVerbosity.Diagnostic, Parameters = $"LogFile={safeBinlogFileName}.binlog" },
|
||||
new global::Microsoft.Build.Logging.ConsoleLogger(LoggerVerbosity.Detailed)
|
||||
};
|
||||
CapturingLogger logs = new CapturingLogger();
|
||||
loggers.Add(logs);
|
||||
|
||||
var collection = new ProjectCollection(null, loggers, ToolsetDefinitionLocations.Default);
|
||||
foreach (var kvp in bonusProps)
|
||||
{
|
||||
props[kvp.Key] = kvp.Value;
|
||||
}
|
||||
return (collection.LoadProject(CombinedTargetsLocation, props, null), logs);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
[Collection("Docker tests")]
|
||||
public class RegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetFromRegistry()
|
||||
{
|
||||
Registry registry = new Registry(ContainerHelpers.TryExpandRegistryToUri(DockerRegistryManager.LocalRegistry));
|
||||
var ridgraphfile = ToolsetUtils.GetRuntimeGraphFilePath();
|
||||
|
||||
// Don't need rid graph for local registry image pulls - since we're only pushing single image manifests (not manifest lists)
|
||||
// as part of our setup, we could put literally anything in here. The file at the passed-in path would only get read when parsing manifests lists.
|
||||
Image? downloadedImage = await registry.GetImageManifest(DockerRegistryManager.BaseImage, DockerRegistryManager.Net6ImageTag, "linux-x64", ridgraphfile).ConfigureAwait(false);
|
||||
|
||||
Assert.NotNull(downloadedImage);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using static Microsoft.NET.Build.Containers.KnownStrings.Properties;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Build.Execution;
|
||||
using Xunit;
|
||||
using Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.Targets.IntegrationTests;
|
||||
|
||||
[Collection("Docker tests")]
|
||||
public class TargetsTests
|
||||
{
|
||||
[InlineData(true, "/app/foo.exe")]
|
||||
[InlineData(false, "dotnet", "/app/foo.dll")]
|
||||
[Theory]
|
||||
public void CanSetEntrypointArgsToUseAppHost(bool useAppHost, params string[] entrypointArgs)
|
||||
{
|
||||
var (project, _) = ProjectInitializer.InitProject(new()
|
||||
{
|
||||
[UseAppHost] = useAppHost.ToString()
|
||||
}, projectName: $"{nameof(CanSetEntrypointArgsToUseAppHost)}_{useAppHost}_{String.Join("_", entrypointArgs)}");
|
||||
Assert.True(project.Build(ComputeContainerConfig));
|
||||
var computedEntrypointArgs = project.GetItems(ContainerEntrypoint).Select(i => i.EvaluatedInclude).ToArray();
|
||||
foreach (var (First, Second) in entrypointArgs.Zip(computedEntrypointArgs))
|
||||
{
|
||||
Assert.Equal(First, Second);
|
||||
}
|
||||
}
|
||||
|
||||
[InlineData("WebApplication44", "webapplication44", true)]
|
||||
[InlineData("friendly-suspicious-alligator", "friendly-suspicious-alligator", true)]
|
||||
[InlineData("*friendly-suspicious-alligator", "", false)]
|
||||
[InlineData("web/app2+7", "web/app2-7", true)]
|
||||
[InlineData("Microsoft.Apps.Demo.ContosoWeb", "microsoft-apps-demo-contosoweb", true)]
|
||||
[Theory]
|
||||
public void CanNormalizeInputContainerNames(string projectName, string expectedContainerImageName, bool shouldPass)
|
||||
{
|
||||
var (project, _) = ProjectInitializer.InitProject(new()
|
||||
{
|
||||
[AssemblyName] = projectName
|
||||
}, projectName: $"{nameof(CanNormalizeInputContainerNames)}_{projectName}_{expectedContainerImageName}_{shouldPass}");
|
||||
var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None);
|
||||
instance.Build(new[] { ComputeContainerConfig }, null, null, out var outputs).Should().Be(shouldPass, "Build should have succeeded");
|
||||
Assert.Equal(expectedContainerImageName, instance.GetPropertyValue(ContainerImageName));
|
||||
}
|
||||
|
||||
[InlineData("7.0.100", true)]
|
||||
[InlineData("8.0.100", true)]
|
||||
[InlineData("7.0.100-preview.7", true)]
|
||||
[InlineData("7.0.100-rc.1", true)]
|
||||
[InlineData("6.0.100", false)]
|
||||
[InlineData("7.0.100-preview.1", false)]
|
||||
[Theory]
|
||||
public void CanWarnOnInvalidSDKVersions(string sdkVersion, bool isAllowed)
|
||||
{
|
||||
var (project, _) = ProjectInitializer.InitProject(new()
|
||||
{
|
||||
["NETCoreSdkVersion"] = sdkVersion,
|
||||
["PublishProfile"] = "DefaultContainer"
|
||||
}, projectName: $"{nameof(CanWarnOnInvalidSDKVersions)}_{sdkVersion}_{isAllowed}");
|
||||
var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None);
|
||||
var derivedIsAllowed = Boolean.Parse(project.GetProperty("_IsSDKContainerAllowedVersion").EvaluatedValue);
|
||||
// var buildResult = instance.Build(new[]{"_ContainerVerifySDKVersion"}, null, null, out var outputs);
|
||||
derivedIsAllowed.Should().Be(isAllowed, $"SDK version {(isAllowed ? "should" : "should not")} have been allowed ");
|
||||
}
|
||||
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
[Theory]
|
||||
public void GetsConventionalLabelsByDefault(bool shouldEvaluateLabels)
|
||||
{
|
||||
var (project, _) = ProjectInitializer.InitProject(new()
|
||||
{
|
||||
[ContainerGenerateLabels] = shouldEvaluateLabels.ToString()
|
||||
}, projectName: $"{nameof(GetsConventionalLabelsByDefault)}_{shouldEvaluateLabels}");
|
||||
var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None);
|
||||
instance.Build(new[] { ComputeContainerConfig }, null, null, out var outputs).Should().BeTrue("Build should have succeeded");
|
||||
if (shouldEvaluateLabels)
|
||||
{
|
||||
instance.GetItems(ContainerLabel).Should().NotBeEmpty("Should have evaluated some labels by default");
|
||||
}
|
||||
else
|
||||
{
|
||||
instance.GetItems(ContainerLabel).Should().BeEmpty("Should not have evaluated any labels by default");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LabelMatch(string label, string value, ProjectItemInstance item) => item.EvaluatedInclude == label && item.GetMetadata("Value") is { } v && v.EvaluatedValue == value;
|
||||
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
[Theory]
|
||||
public void ShouldNotIncludeSourceControlLabelsUnlessUserOptsIn(bool includeSourceControl)
|
||||
{
|
||||
var commitHash = "abcdef";
|
||||
var repoUrl = "https://git.cosmere.com/shard/whimsy.git";
|
||||
|
||||
var (project, _) = ProjectInitializer.InitProject(new()
|
||||
{
|
||||
["PublishRepositoryUrl"] = includeSourceControl.ToString(),
|
||||
["PrivateRepositoryUrl"] = repoUrl,
|
||||
["SourceRevisionId"] = commitHash
|
||||
}, projectName: $"{nameof(ShouldNotIncludeSourceControlLabelsUnlessUserOptsIn)}_{includeSourceControl}");
|
||||
var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None);
|
||||
instance.Build(new[] { ComputeContainerConfig }, null, null, out var outputs).Should().BeTrue("Build should have succeeded");
|
||||
var labels = instance.GetItems(ContainerLabel);
|
||||
if (includeSourceControl)
|
||||
{
|
||||
labels.Should().NotBeEmpty("Should have evaluated some labels by default")
|
||||
.And.ContainSingle(label => LabelMatch("org.opencontainers.image.source", repoUrl, label))
|
||||
.And.ContainSingle(label => LabelMatch("org.opencontainers.image.revision", commitHash, label)); ;
|
||||
}
|
||||
else
|
||||
{
|
||||
labels.Should().NotBeEmpty("Should have evaluated some labels by default")
|
||||
.And.NotContain(label => LabelMatch("org.opencontainers.image.source", repoUrl, label))
|
||||
.And.NotContain(label => LabelMatch("org.opencontainers.image.revision", commitHash, label)); ;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
internal static class TestSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets temporary location for test artifacts.
|
||||
/// </summary>
|
||||
internal static string TestArtifactsDirectory { get; } = Path.Combine(Path.GetTempPath(), "ContainersTests", DateTime.Now.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture));
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
internal static class ToolsetUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the path with installed dotnet.
|
||||
/// Order of processing:
|
||||
/// - DOTNET_ROOT environment variable
|
||||
/// - DOTNET_INSTALL_DIR environment variable
|
||||
/// - resolving dotnet executable path.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">on unexpected environment result, i.e. common environment variables are not set.</exception>
|
||||
internal static string GetDotNetPath()
|
||||
{
|
||||
string? dotnetRootFromEnvironment = Environment.GetEnvironmentVariable("DOTNET_ROOT");
|
||||
string? dotnetInstallDirFromEnvironment = Environment.GetEnvironmentVariable("DOTNET_INSTALL_DIR");
|
||||
|
||||
if (!string.IsNullOrEmpty(dotnetRootFromEnvironment) && Directory.Exists(dotnetRootFromEnvironment))
|
||||
{
|
||||
return dotnetRootFromEnvironment;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(dotnetInstallDirFromEnvironment) && Directory.Exists(dotnetInstallDirFromEnvironment))
|
||||
{
|
||||
return dotnetInstallDirFromEnvironment;
|
||||
}
|
||||
string dotnetExePath = ResolveCommand("dotnet");
|
||||
string dotnetRoot = Path.GetDirectoryName(dotnetExePath) ?? throw new InvalidOperationException("dotnet executable is in the root?");
|
||||
if (Directory.Exists(dotnetInstallDirFromEnvironment))
|
||||
{
|
||||
Assert.False(true, "'dotnet' was not found.");
|
||||
}
|
||||
return dotnetRoot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets path to RuntimeIdentifierGraph.json file.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
internal static string GetRuntimeGraphFilePath()
|
||||
{
|
||||
string dotnetRoot = GetDotNetPath();
|
||||
DirectoryInfo sdksDir = new(Path.Combine(dotnetRoot, "sdk"));
|
||||
|
||||
var lastWrittenSdk = sdksDir.EnumerateDirectories().OrderByDescending(di => di.LastWriteTime).First();
|
||||
|
||||
return lastWrittenSdk.GetFiles("RuntimeIdentifierGraph.json").Single().FullName;
|
||||
}
|
||||
|
||||
private static string ResolveCommand(string command)
|
||||
{
|
||||
char pathSplitChar;
|
||||
string[] extensions = new string[] { string.Empty };
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
string? pathExt = Environment.GetEnvironmentVariable("PATHEXT") ?? throw new InvalidOperationException("PATHEXT is not set.");
|
||||
pathSplitChar = ';';
|
||||
extensions = extensions
|
||||
.Concat(pathExt.Split(pathSplitChar))
|
||||
.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
pathSplitChar = ':';
|
||||
}
|
||||
|
||||
string? path = Environment.GetEnvironmentVariable("PATH") ?? throw new InvalidOperationException("PATH is not set.");
|
||||
|
||||
var paths = path.Split(pathSplitChar);
|
||||
string? result = extensions.SelectMany(ext => paths.Select(p => Path.Combine(p, command + ext)))
|
||||
.FirstOrDefault(File.Exists);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Could not resolve path to " + command);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using static System.IO.Path;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class to clean up after tests that touch the filesystem.
|
||||
/// </summary>
|
||||
internal sealed class TransientTestFolder : IDisposable
|
||||
{
|
||||
public readonly string Path = Combine(TestSettings.TestArtifactsDirectory, GetRandomFileName());
|
||||
public readonly DirectoryInfo DirectoryInfo;
|
||||
|
||||
public TransientTestFolder()
|
||||
{
|
||||
DirectoryInfo = Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.UnitTests;
|
||||
|
||||
public class ContainerHelpersTests
|
||||
{
|
||||
[Theory]
|
||||
// Valid Tests
|
||||
[InlineData("mcr.microsoft.com", true)]
|
||||
[InlineData("mcr.microsoft.com:5001", true)] // Registries can have ports
|
||||
[InlineData("docker.io", true)] // default docker registry is considered valid
|
||||
|
||||
// // Invalid tests
|
||||
[InlineData("mcr.mi-=crosoft.com", false)] // invalid url
|
||||
[InlineData("mcr.microsoft.com/", false)] // invalid url
|
||||
public void IsValidRegistry(string registry, bool expectedReturn)
|
||||
{
|
||||
Console.WriteLine($"Domain pattern is '{ReferenceParser.AnchoredDomainRegexp.ToString()}'");
|
||||
Assert.Equal(expectedReturn, ContainerHelpers.IsValidRegistry(registry));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("mcr.microsoft.com/dotnet/runtime:6.0", true, "mcr.microsoft.com", "dotnet/runtime", "6.0")]
|
||||
[InlineData("mcr.microsoft.com/dotnet/runtime", true, "mcr.microsoft.com", "dotnet/runtime", null)]
|
||||
[InlineData("mcr.microsoft.com/", false, null, null, null)] // no image = nothing resolves
|
||||
// Ports tag along
|
||||
[InlineData("mcr.microsoft.com:54/dotnet/runtime", true, "mcr.microsoft.com:54", "dotnet/runtime", null)]
|
||||
// Even if nonsensical
|
||||
[InlineData("mcr.microsoft.com:0/dotnet/runtime", true, "mcr.microsoft.com:0", "dotnet/runtime", null)]
|
||||
// We don't allow hosts with missing ports when a port is anticipated
|
||||
[InlineData("mcr.microsoft.com:/dotnet/runtime", false, null, null, null)]
|
||||
[InlineData("ubuntu:jammy", true, ContainerHelpers.DefaultRegistry, "ubuntu", "jammy")]
|
||||
public void TryParseFullyQualifiedContainerName(string fullyQualifiedName, bool expectedReturn, string expectedRegistry, string expectedImage, string expectedTag)
|
||||
{
|
||||
Assert.Equal(expectedReturn, ContainerHelpers.TryParseFullyQualifiedContainerName(fullyQualifiedName, out string? containerReg, out string? containerName, out string? containerTag, out string? containerDigest));
|
||||
Assert.Equal(expectedRegistry, containerReg);
|
||||
Assert.Equal(expectedImage, containerName);
|
||||
Assert.Equal(expectedTag, containerTag);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("dotnet/runtime", true)]
|
||||
[InlineData("foo/bar", true)]
|
||||
[InlineData("registry", true)]
|
||||
[InlineData("-foo/bar", false)]
|
||||
[InlineData(".foo/bar", false)]
|
||||
[InlineData("_foo/bar", false)]
|
||||
[InlineData("foo/bar-", false)]
|
||||
[InlineData("foo/bar.", false)]
|
||||
[InlineData("foo/bar_", false)]
|
||||
public void IsValidImageName(string imageName, bool expectedReturn)
|
||||
{
|
||||
Assert.Equal(expectedReturn, ContainerHelpers.IsValidImageName(imageName));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("6.0", true)] // baseline
|
||||
[InlineData("5.2-asd123", true)] // with commit hash
|
||||
[InlineData(".6.0", false)] // starts with .
|
||||
[InlineData("-6.0", false)] // starts with -
|
||||
[InlineData("---", false)] // malformed
|
||||
public void IsValidImageTag(string imageTag, bool expectedReturn)
|
||||
{
|
||||
Assert.Equal(expectedReturn, ContainerHelpers.IsValidImageTag(imageTag));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidImageTag_InvalidLength()
|
||||
{
|
||||
Assert.False(ContainerHelpers.IsValidImageTag(new string('a', 129)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("80/tcp", true, 80, PortType.tcp, null)]
|
||||
[InlineData("80", true, 80, PortType.tcp, null)]
|
||||
[InlineData("125/dup", false, 125, PortType.tcp, ContainerHelpers.ParsePortError.InvalidPortType)]
|
||||
[InlineData("invalidNumber", false, null, null, ContainerHelpers.ParsePortError.InvalidPortNumber)]
|
||||
[InlineData("welp/unknowntype", false, null, null, (ContainerHelpers.ParsePortError)3)]
|
||||
[InlineData("a/b/c", false, null, null, ContainerHelpers.ParsePortError.UnknownPortFormat)]
|
||||
[InlineData("/tcp", false, null, null, ContainerHelpers.ParsePortError.MissingPortNumber)]
|
||||
public void CanParsePort(string input, bool shouldParse, int? expectedPortNumber, PortType? expectedType, ContainerHelpers.ParsePortError? expectedError) {
|
||||
var parseSuccess = ContainerHelpers.TryParsePort(input, out var port, out var errors);
|
||||
Assert.Equal(shouldParse, parseSuccess);
|
||||
|
||||
if (shouldParse) {
|
||||
Assert.NotNull(port);
|
||||
Assert.Equal(port.number, expectedPortNumber);
|
||||
Assert.Equal(port.type, expectedType);
|
||||
} else {
|
||||
Assert.Null(port);
|
||||
Assert.NotNull(errors);
|
||||
Assert.Equal(expectedError, errors);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("FOO", true)]
|
||||
[InlineData("foo_bar", true)]
|
||||
[InlineData("foo-bar", false)]
|
||||
[InlineData("foo.bar", false)]
|
||||
[InlineData("foo bar", false)]
|
||||
[InlineData("1_NAME", false)]
|
||||
[InlineData("ASPNETCORE_URLS", true)]
|
||||
[InlineData("ASPNETCORE_URLS2", true)]
|
||||
public void CanRecognizeEnvironmentVariableNames(string envVarName, bool isValid) {
|
||||
var success = ContainerHelpers.IsValidEnvironmentVariable(envVarName);
|
||||
Assert.Equal(isValid, success);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.UnitTests;
|
||||
|
||||
public class DescriptorTests
|
||||
{
|
||||
[Fact]
|
||||
public void BasicConstructor()
|
||||
{
|
||||
Descriptor d = new(
|
||||
mediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
digest: "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
|
||||
size: 7682);
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(d, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
Assert.Equal("application/vnd.oci.image.manifest.v1+json", d.MediaType);
|
||||
Assert.Equal("sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270", d.Digest);
|
||||
Assert.Equal(7_682, d.Size);
|
||||
|
||||
Assert.Null(d.Annotations);
|
||||
Assert.Null(d.Data);
|
||||
Assert.Null(d.Urls);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.UnitTests;
|
||||
|
||||
public class DockerDaemonTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Can_detect_when_no_daemon_is_running() {
|
||||
// mimic no daemon running by setting the DOCKER_HOST to a nonexistent socket
|
||||
try {
|
||||
System.Environment.SetEnvironmentVariable("DOCKER_HOST", "tcp://123.123.123.123:12345");
|
||||
var available = await new LocalDocker(Console.WriteLine).IsAvailable().ConfigureAwait(false);
|
||||
Assert.False(available, "No daemon should be listening at that port");
|
||||
} finally {
|
||||
System.Environment.SetEnvironmentVariable("DOCKER_HOST", null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_detect_when_daemon_is_running() {
|
||||
var available = await new LocalDocker(Console.WriteLine).IsAvailable().ConfigureAwait(false);
|
||||
Assert.True(available, "Should have found a working daemon");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.NET.Build.Containers\Microsoft.NET.Build.Containers.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
|
@ -0,0 +1,25 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.NET.Build.Containers.UnitTests
|
||||
{
|
||||
public class RegistryTests
|
||||
{
|
||||
[InlineData("public.ecr.aws", true)]
|
||||
[InlineData("123412341234.dkr.ecr.us-west-2.amazonaws.com", true)]
|
||||
[InlineData("123412341234.dkr.ecr-fips.us-west-2.amazonaws.com", true)]
|
||||
[InlineData("notvalid.dkr.ecr.us-west-2.amazonaws.com", false)]
|
||||
[InlineData("1111.dkr.ecr.us-west-2.amazonaws.com", false)]
|
||||
[InlineData("mcr.microsoft.com", false)]
|
||||
[InlineData("localhost", false)]
|
||||
[InlineData("hub", false)]
|
||||
[Theory]
|
||||
public void CheckIfAmazonECR(string registryName, bool isECR)
|
||||
{
|
||||
Registry registry = new Registry(ContainerHelpers.TryExpandRegistryToUri(registryName));
|
||||
Assert.Equal(isECR, registry.IsAmazonECRRegistry);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -70,6 +70,6 @@
|
|||
<!-- Hacky workaround for the fact that we don't publish the package yet. -->
|
||||
<Target Name="CopyNupkgToCustomFolder" AfterTargets="Pack">
|
||||
<Copy SourceFiles="$(OutDir)../Microsoft.NET.Build.Containers.$(Version).nupkg"
|
||||
DestinationFiles="../Test.Microsoft.NET.Build.Containers.Filesystem/package/Microsoft.NET.Build.Containers.$(Version).nupkg" />
|
||||
DestinationFiles="../Microsoft.NET.Build.Containers.IntegrationTests/package/Microsoft.NET.Build.Containers.$(Version).nupkg" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
|
Загрузка…
Ссылка в новой задаче