Skip to content

Commit

Permalink
Provide errors for missing and unexpected parameters in azure pipelin…
Browse files Browse the repository at this point in the history
…es (#238)

* azp: detect unrecognized parameters and missing required parameters

* add test framework for azpipelines

* Revert Test.csproj (Due to build failure)

* removed try/catch, fix for null reference types

* fix tests on linux and run them in ci

---------

Co-authored-by: ChristopherHX <[email protected]>
  • Loading branch information
bryanbcook and ChristopherHX authored Oct 15, 2023
1 parent c7ee3e7 commit efdebf7
Show file tree
Hide file tree
Showing 26 changed files with 708 additions and 16 deletions.
7 changes: 6 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,16 @@ jobs:
path: |
_package
- name: Unit Tests
- name: Unit Tests (actions/runner)
if: matrix.runtime != 'linux-arm64' && matrix.runtime != 'linux-arm' && matrix.runtime != 'osx-arm64'
run: |
${{ matrix.devScript }} test
working-directory: src

- name: Unit Tests (Sdk.Tests)
if: matrix.runtime != 'linux-arm64' && matrix.runtime != 'linux-arm' && matrix.runtime != 'osx-arm64'
run: |
dotnet test src/Sdk.Tests/Sdk.Tests.csproj
- name: Setup
if: 'false' # Disabled due to 403 of statuses api with this token and rate limit
Expand Down
18 changes: 10 additions & 8 deletions src/ActionsRunner.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@


Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29411.138
Expand All @@ -13,7 +13,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Runner.Plugins", "Runner.Pl
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Runner.Sdk", "Runner.Sdk\Runner.Sdk.csproj", "{D0484633-DA97-4C34-8E47-1DADE212A57A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RunnerService", "Runner.Service\Windows\RunnerService.csproj", "{D12EBD71-0464-46D0-8394-40BCFBA0A6F2}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RunnerService", "Runner.Service\Windows\RunnerService.csproj", "{D12EBD71-0464-46D0-8394-40BCFBA0A6F2}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Runner.Worker", "Runner.Worker\Runner.Worker.csproj", "{C2F5B9FA-2621-411F-8EB2-273ED276F503}"
EndProject
Expand All @@ -26,9 +26,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.editorconfig = .editorconfig
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Runner.Server", "Runner.Server\Runner.Server.csproj", "{43BFD5CB-4939-46F1-B94C-5CCB8493FC00}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Runner.Server", "Runner.Server\Runner.Server.csproj", "{43BFD5CB-4939-46F1-B94C-5CCB8493FC00}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Runner.Client", "Runner.Client\Runner.Client.csproj", "{20089019-022B-48F6-85A4-A524D7E8161D}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Runner.Client", "Runner.Client\Runner.Client.csproj", "{20089019-022B-48F6-85A4-A524D7E8161D}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sdk.Tests", "Sdk.Tests\Sdk.Tests.csproj", "{9E191751-BE28-498F-8A21-6EDA151197CB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand Down Expand Up @@ -80,10 +82,10 @@ Global
{20089019-022B-48F6-85A4-A524D7E8161D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{20089019-022B-48F6-85A4-A524D7E8161D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{20089019-022B-48F6-85A4-A524D7E8161D}.Release|Any CPU.Build.0 = Release|Any CPU
{911D4E90-F306-4B56-87DD-A2642EB0BA95}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{911D4E90-F306-4B56-87DD-A2642EB0BA95}.Debug|Any CPU.Build.0 = Debug|Any CPU
{911D4E90-F306-4B56-87DD-A2642EB0BA95}.Release|Any CPU.ActiveCfg = Release|Any CPU
{911D4E90-F306-4B56-87DD-A2642EB0BA95}.Release|Any CPU.Build.0 = Release|Any CPU
{9E191751-BE28-498F-8A21-6EDA151197CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9E191751-BE28-498F-8A21-6EDA151197CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9E191751-BE28-498F-8A21-6EDA151197CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9E191751-BE28-498F-8A21-6EDA151197CB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
52 changes: 52 additions & 0 deletions src/Sdk.Tests/AzurePipelines/AzurePipelinesYamlValidationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using Xunit.Abstractions;

namespace Runner.Server.Azure.Devops
{
public class AzurePipelinesYamlValidationTests
{
private TestContext Context;

public AzurePipelinesYamlValidationTests(ITestOutputHelper output)
{
Context = TestContext.Create(Directory.GetCurrentDirectory()).AddOutputToTest(output);
}

[Theory]
[ClassData(typeof(AzPipelineTestWorkflows))]
public void ValidateYamlFormat(TestWorkflow workflow)
{
// arrange
Context.SetWorkingDirectory(TestUtil.GetAzPipelineFolder(workflow.WorkingDirectory));
foreach(var localRepo in workflow.LocalRepository)
{
var repositoryAndRef = localRepo.Split("=");
Context.AddRepo(repositoryAndRef[0], TestUtil.GetAzPipelineFolder(repositoryAndRef[1]));
}

// act
var act = new Action(() => Context.Evaluate(workflow.File).ToYaml());

// assert
if (workflow.ExpectedException != null)
{
var message = Should.Throw(act, workflow.ExpectedException).Message;

if (workflow.ExpectedErrorMessage != null)
{
message.ShouldContain(workflow.ExpectedErrorMessage);
}

}
else
{
act.Invoke();
}
}

[Fact]
public void TestWorkflowResolve()
{
var results = TestGenerator.ResolveWorkflows(TestUtil.GetAzPipelineFolder()).ToArray();
}
}
}
20 changes: 20 additions & 0 deletions src/Sdk.Tests/AzurePipelines/TestData/AzPipelineTestWorkflows.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Collections;

namespace Runner.Server.Azure.Devops
{
public class AzPipelineTestWorkflows : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
foreach(var result in TestGenerator.ResolveWorkflows(TestUtil.GetAzPipelineFolder()))
{
yield return new object[] { result };
}
}

IEnumerator IEnumerable.GetEnumerator()
{
return this.GetEnumerator();
}
}
}
131 changes: 131 additions & 0 deletions src/Sdk.Tests/AzurePipelines/TestData/TestGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using GitHub.DistributedTask.ObjectTemplating;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;

namespace Runner.Server.Azure.Devops
{
public class TestGenerator
{
public static IEnumerable<TestWorkflow> ResolveWorkflows(string basePath, string folder = "")
{
var currentFolder = Path.Combine(basePath, folder);
var files = Directory.GetFiles(currentFolder, "*.yml");

bool found = false;

if (files.Any())
{
bool asPipeline = false;

// prioritize filtering pipeline*.yml files
var pipelines = files.Where(i => i.Contains($"{Path.DirectorySeparatorChar}pipeline"));
if (pipelines.Any())
{
asPipeline = true;
files = pipelines.ToArray();
}

foreach(var file in files)
{
if (TryReadPipeline(basePath, file, asPipeline, out var result))
{
found = true; // this folder contains pipelines, stop recursion
yield return result;
}
}
}

if (!found)
{
// loop through subfolder
foreach(var directory in Directory.GetDirectories(currentFolder))
{
var folderName = Path.GetRelativePath(basePath, directory);
foreach(var item in ResolveWorkflows(basePath, folderName))
{
yield return item;
}
}
}
}

private static bool TryReadPipeline(string basePath, string file, bool isPipeline, [MaybeNullWhen(returnValue:false)] out TestWorkflow result)
{
result = null;

// read contents of files
using(var reader = new StreamReader(file))
{
var content = reader.ReadToEnd();

var parser = new TestWorkflowParser(content);

if (isPipeline || parser.HasMeta())
{
var relativePath = Path.GetRelativePath(basePath, file);
var workingDir = Path.GetDirectoryName(relativePath) ?? basePath;
var fileName = Path.GetFileName(relativePath);

result = new TestWorkflow(workingDir, fileName)
{
Name = parser.Name,
LocalRepository = parser.LocalRepository,
ExpectedException = parser.ExpectedException,
ExpectedErrorMessage = parser.ExpectedError
};

return true;
}
}

return false;
}

/// <summary>
/// Obtains meta-data stored in the yaml file comments
/// </summary>
class TestWorkflowParser
{
public string? Name { get; private set; }
public Type? ExpectedException { get; private set; }
public string? ExpectedError { get; private set; }
public string[] LocalRepository { get; private set; }

public bool HasMeta()
{
return Name != null || ExpectedException != null | ExpectedError != null | LocalRepository?.Length > 0;
}

public TestWorkflowParser(string content)
{
Name = GetMeta(content, "Name")[0];
ExpectedError = GetMeta(content, "ExpectedErrorMessage")[0];
ExpectedException = LoadType(GetMeta(content, "ExpectedException")[0]);
LocalRepository = GetMeta(content, "LocalRepository").Where(i => i != null).Cast<string>().ToArray();
}

private static string?[] GetMeta(string content, string name)
{
var matches = Regex.Matches(content, $"^# {name}: (.*?)\r?$", RegexOptions.Multiline | RegexOptions.IgnoreCase);
return matches.Where(i => i.Success).Select(i => i.Groups[1].Value).DefaultIfEmpty().ToArray();
}

public static Type? LoadType(string? typeName)
{
if (typeName == null)
{
return null;
}

var referenceTypes = new Type[]
{
typeof(TemplateValidationError),
typeof(Exception)
};

// locate and resolve type
return referenceTypes.Select(i => Type.GetType($"{i.Namespace}.{typeName}, {i.Assembly.GetName().Name}", false)).FirstOrDefault(i => i != null);
}
}
}
}
106 changes: 106 additions & 0 deletions src/Sdk.Tests/AzurePipelines/TestData/TestWorkflow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using Xunit.Abstractions;

namespace Runner.Server.Azure.Devops
{
public class TestWorkflow : IXunitSerializable
{
/// <summary>
/// Serialization Constructor
/// </summary>
#pragma warning disable CS8618
public TestWorkflow() { }
#pragma warning restore CS8618

public TestWorkflow(string workingDirectory, string file)
{
WorkingDirectory = workingDirectory;
File = file.Replace(@"\", "/");
LocalRepository = Array.Empty<string>();
}

/// <summary>
/// Base Working Directory for Test
/// </summary>
public string WorkingDirectory { get; private set; }

/// <summary>
/// Path to Pipeline
/// </summary>
public string File { get; private set; }

#region meta-data
/// <summary>
/// Display Name for Test
/// </summary>
public string? Name { get; set; }

/// <summary>
/// Additional repository information needed for workflow
/// </summary>
public string[] LocalRepository { get; set; }

/// <summary>
/// Expected Exception for YAML Parsing scenario
/// </summary>
public Type? ExpectedException { get; set; }

/// <summary>
/// Expected Exception Message for YAML Parsing scenario
/// </summary>
public string? ExpectedErrorMessage { get; set; }
#endregion

#region IXUnitSerializable

/// <summary>
/// Hydrate Test data from xUnit Test discovery
/// </summary>
public void Deserialize(IXunitSerializationInfo info)
{
Name = info.GetValue<string>(nameof(Name));
WorkingDirectory = info.GetValue<string>(nameof(WorkingDirectory));
File = info.GetValue<string>(nameof(File));
LocalRepository = info.GetValue<string?>(nameof(LocalRepository))?.Split(";") ?? Array.Empty<string>();
ExpectedErrorMessage = info.GetValue<string?>(nameof(ExpectedErrorMessage));
string? exceptionType = info.GetValue<string?>(nameof(ExpectedException));
if (exceptionType != null)
{
ExpectedException = Type.GetType(exceptionType, false);
}
}

/// <summary>
/// Persist test data from xUnit Test discovery
/// </summary>
public void Serialize(IXunitSerializationInfo info)
{
info.AddValue(nameof(Name), Name);
info.AddValue(nameof(WorkingDirectory), WorkingDirectory);
info.AddValue(nameof(File), File);
info.AddValue(nameof(LocalRepository), LocalRepository?.Length > 0 ? string.Join(";", LocalRepository) : null);
info.AddValue(nameof(ExpectedException), $"{ExpectedException?.FullName},{ExpectedException?.Assembly.GetName().Name}");
info.AddValue(nameof(ExpectedErrorMessage), ExpectedErrorMessage);
}
#endregion

/// <summary>
/// Provide a display value for the testdata
/// </summary>
/// <returns></returns>
public override string ToString()
{
if (Name != null)
{
return Name;
}

// working_directory_pipeline_name
return string.Format(
"{0}{1}",
WorkingDirectory,
File == "pipeline.yml" ? "" : $"_{File.Replace(".yml","")}"
);
}

}
}
Loading

0 comments on commit efdebf7

Please sign in to comment.