Skip to content
This repository was archived by the owner on Nov 1, 2023. It is now read-only.

Commit 572671b

Browse files
tevoineachkeita
andauthored
Sematically validate notification configs (#2850)
* Add new command * Update remaining jinja templates and references to use scriban * Add ado template validation * Validate ado and github templates * Remove unnecessary function * Update src/ApiService/ApiService/OneFuzzTypes/Model.cs Co-authored-by: Cheick Keita <[email protected]> --------- Co-authored-by: Cheick Keita <[email protected]>
1 parent 124f50d commit 572671b

File tree

6 files changed

+126
-22
lines changed

6 files changed

+126
-22
lines changed

src/ApiService/ApiService/FeatureFlags.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
public static class FeatureFlagConstants {
44
public const string EnableScribanOnly = "EnableScribanOnly";
55
public const string EnableNodeDecommissionStrategy = "EnableNodeDecommissionStrategy";
6+
public const string EnableValidateNotificationConfigSemantics = "EnableValidateNotificationConfigSemantics";
67
}

src/ApiService/ApiService/OneFuzzTypes/Model.cs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Reflection;
22
using System.Text.Json;
33
using System.Text.Json.Serialization;
4+
using System.Threading.Tasks;
45
using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
56
using Endpoint = System.String;
67
using GroupId = System.Guid;
@@ -536,6 +537,7 @@ public RegressionReport Truncate(int maxLength) {
536537
#pragma warning disable CA1715
537538
public interface NotificationTemplate {
538539
#pragma warning restore CA1715
540+
Async.Task<OneFuzzResultVoid> Validate();
539541
}
540542

541543

@@ -637,10 +639,19 @@ public record AdoTemplate(
637639
Dictionary<string, string> AdoFields,
638640
ADODuplicateTemplate OnDuplicate,
639641
string? Comment = null
640-
) : NotificationTemplate;
641-
642-
public record TeamsTemplate(SecretData<string> Url) : NotificationTemplate;
642+
) : NotificationTemplate {
643+
public async Task<OneFuzzResultVoid> Validate() {
644+
return await Ado.Validate(this);
645+
}
646+
}
643647

648+
public record TeamsTemplate(SecretData<string> Url) : NotificationTemplate {
649+
public Task<OneFuzzResultVoid> Validate() {
650+
// The only way we can validate in the current state is to send a test webhook
651+
// Maybe there's a teams nuget package we can pull in to help validate
652+
return Async.Task.FromResult(OneFuzzResultVoid.Ok);
653+
}
654+
}
644655

645656
public record GithubAuth(string User, string PersonalAccessToken);
646657

@@ -668,7 +679,11 @@ public record GithubIssuesTemplate(
668679
List<string> Assignees,
669680
List<string> Labels,
670681
GithubIssueDuplicate OnDuplicate
671-
) : NotificationTemplate;
682+
) : NotificationTemplate {
683+
public async Task<OneFuzzResultVoid> Validate() {
684+
return await GithubIssues.Validate(this);
685+
}
686+
}
672687

673688
public record Repro(
674689
[PartitionKey][RowKey] Guid VmId,

src/ApiService/ApiService/onefuzzlib/NotificationOperations.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ public async Async.Task<OneFuzzResult<Notification>> Create(Container container,
9696
return OneFuzzResult<Notification>.Error(ErrorCode.INVALID_REQUEST, "The notification config is not a valid scriban template");
9797
}
9898

99+
if (await _context.FeatureManagerSnapshot.IsEnabledAsync(FeatureFlagConstants.EnableValidateNotificationConfigSemantics)) {
100+
var validConfig = await config.Validate();
101+
if (!validConfig.IsOk) {
102+
return OneFuzzResult<Notification>.Error(validConfig.ErrorV);
103+
}
104+
}
105+
99106
if (replaceExisting) {
100107
var existing = this.SearchByRowKeys(new[] { container.String });
101108
await foreach (var existingEntry in existing) {
@@ -138,7 +145,6 @@ private async Async.Task<NotificationTemplate> HideSecrets(NotificationTemplate
138145

139146
public async Async.Task<Task?> GetRegressionReportTask(RegressionReport report) {
140147
if (report.CrashTestResult.CrashReport != null) {
141-
142148
return await _context.TaskOperations.GetByJobIdAndTaskId(report.CrashTestResult.CrashReport.JobId, report.CrashTestResult.CrashReport.TaskId);
143149
}
144150
if (report.CrashTestResult.NoReproReport != null) {

src/ApiService/ApiService/onefuzzlib/notifications/Ado.cs

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
using Microsoft.TeamFoundation.WorkItemTracking.WebApi;
33
using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
44
using Microsoft.VisualStudio.Services.Common;
5+
using Microsoft.VisualStudio.Services.WebApi;
56
using Microsoft.VisualStudio.Services.WebApi.Patch.Json;
67

78
namespace Microsoft.OneFuzz.Service;
89

910
public interface IAdo {
1011
public Async.Task NotifyAdo(AdoTemplate config, Container container, string filename, IReport reportable, bool isLastRetryAttempt, Guid notificationId);
11-
1212
}
1313

1414
public class Ado : NotificationsBase, IAdo {
@@ -61,6 +61,55 @@ private static bool IsTransient(Exception e) {
6161
return errorCodes.Any(code => errorStr.Contains(code));
6262
}
6363

64+
public static async Async.Task<OneFuzzResultVoid> Validate(AdoTemplate config) {
65+
// Validate PAT is valid for the base url
66+
VssConnection connection;
67+
if (config.AuthToken.Secret is SecretValue<string> token) {
68+
try {
69+
connection = new VssConnection(config.BaseUrl, new VssBasicCredential(string.Empty, token.Value));
70+
await connection.ConnectAsync();
71+
} catch {
72+
return OneFuzzResultVoid.Error(ErrorCode.INVALID_CONFIGURATION, $"Failed to connect to {config.BaseUrl} using the provided token");
73+
}
74+
} else {
75+
return OneFuzzResultVoid.Error(ErrorCode.INVALID_CONFIGURATION, "Auth token is missing or invalid");
76+
}
77+
78+
try {
79+
// Validate unique_fields are part of the project's valid fields
80+
var witClient = await connection.GetClientAsync<WorkItemTrackingHttpClient>();
81+
82+
// The set of valid fields for this project according to ADO
83+
var projectValidFields = await GetValidFields(witClient, config.Project);
84+
85+
var configFields = config.UniqueFields.Select(field => field.ToLowerInvariant()).ToHashSet();
86+
var validConfigFields = configFields.Intersect(projectValidFields.Keys).ToHashSet();
87+
88+
if (!validConfigFields.SetEquals(configFields)) {
89+
var invalidFields = configFields.Except(validConfigFields);
90+
return OneFuzzResultVoid.Error(ErrorCode.INVALID_CONFIGURATION, new[]
91+
{
92+
$"The following unique fields are not valid fields for this project: {string.Join(',', invalidFields)}",
93+
"You can find the valid fields for your project by following these steps: https://learn.microsoft.com/en-us/azure/devops/boards/work-items/work-item-fields?view=azure-devops#review-fields"
94+
}
95+
);
96+
}
97+
} catch {
98+
return OneFuzzResultVoid.Error(ErrorCode.INVALID_CONFIGURATION, "Failed to query and compare the valid fields for this project");
99+
}
100+
101+
return OneFuzzResultVoid.Ok;
102+
}
103+
104+
private static WorkItemTrackingHttpClient GetAdoClient(Uri baseUrl, string token) {
105+
return new WorkItemTrackingHttpClient(baseUrl, new VssBasicCredential("PAT", token));
106+
}
107+
108+
private static async Async.Task<Dictionary<string, WorkItemField>> GetValidFields(WorkItemTrackingHttpClient client, string? project) {
109+
return (await client.GetFieldsAsync(project, expand: GetFieldsExpand.ExtensionFields))
110+
.ToDictionary(field => field.ReferenceName.ToLowerInvariant());
111+
}
112+
64113
sealed class AdoConnector {
65114
private readonly AdoTemplate _config;
66115
private readonly Renderer _renderer;
@@ -75,13 +124,11 @@ public static async Async.Task<AdoConnector> AdoConnectorCreator(IOnefuzzContext
75124

76125
var authToken = await context.SecretsOperations.GetSecretStringValue(config.AuthToken);
77126
var client = GetAdoClient(config.BaseUrl, authToken!);
78-
return new AdoConnector(container, filename, config, report, renderer, project!, client, instanceUrl, logTracer);
127+
return new AdoConnector(config, renderer, project!, client, instanceUrl, logTracer);
79128
}
80129

81-
private static WorkItemTrackingHttpClient GetAdoClient(Uri baseUrl, string token) {
82-
return new WorkItemTrackingHttpClient(baseUrl, new VssBasicCredential("PAT", token));
83-
}
84-
public AdoConnector(Container container, string filename, AdoTemplate config, Report report, Renderer renderer, string project, WorkItemTrackingHttpClient client, Uri instanceUrl, ILogTracer logTracer) {
130+
131+
public AdoConnector(AdoTemplate config, Renderer renderer, string project, WorkItemTrackingHttpClient client, Uri instanceUrl, ILogTracer logTracer) {
85132
_config = config;
86133
_renderer = renderer;
87134
_project = project;
@@ -112,7 +159,7 @@ public async IAsyncEnumerable<WorkItem> ExistingWorkItems() {
112159
}
113160

114161
var project = filters.TryGetValue("system.teamproject", out var value) ? value : null;
115-
var validFields = await GetValidFields(project);
162+
var validFields = await GetValidFields(_client, project);
116163

117164
var postQueryFilter = new Dictionary<string, string>();
118165
/*
@@ -235,11 +282,6 @@ public async Async.Task<bool> UpdateExisting(WorkItem item, (string, string)[] n
235282
return stateUpdated;
236283
}
237284

238-
private async Async.Task<Dictionary<string, WorkItemField>> GetValidFields(string? project) {
239-
return (await _client.GetFieldsAsync(project, expand: GetFieldsExpand.ExtensionFields))
240-
.ToDictionary(field => field.ReferenceName.ToLowerInvariant());
241-
}
242-
243285
private async Async.Task<WorkItem> CreateNew() {
244286
var (taskType, document) = await RenderNew();
245287
var entry = await _client.CreateWorkItemAsync(document, _project, taskType);

src/ApiService/ApiService/onefuzzlib/notifications/GithubIssues.cs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,35 @@ public async Async.Task GithubIssue(GithubIssuesTemplate config, Container conta
3030
}
3131
}
3232

33+
public static async Async.Task<OneFuzzResultVoid> Validate(GithubIssuesTemplate config) {
34+
// Validate PAT is valid
35+
GitHubClient gh;
36+
if (config.Auth.Secret is SecretValue<GithubAuth> auth) {
37+
try {
38+
gh = GetGitHubClient(auth.Value.User, auth.Value.PersonalAccessToken);
39+
var _ = await gh.User.Get(auth.Value.User);
40+
} catch {
41+
return OneFuzzResultVoid.Error(ErrorCode.INVALID_CONFIGURATION, $"Failed to login to github.com with user {auth.Value.User} and the provided Personal Access Token");
42+
}
43+
} else {
44+
return OneFuzzResultVoid.Error(ErrorCode.INVALID_CONFIGURATION, $"GithubAuth is missing or invalid");
45+
}
46+
47+
try {
48+
var _ = await gh.Repository.Get(config.Organization, config.Repository);
49+
} catch {
50+
return OneFuzzResultVoid.Error(ErrorCode.INVALID_CONFIGURATION, $"Failed to access repository: {config.Organization}/{config.Repository}");
51+
}
52+
53+
return OneFuzzResultVoid.Ok;
54+
}
55+
56+
private static GitHubClient GetGitHubClient(string user, string pat) {
57+
return new GitHubClient(new ProductHeaderValue("OneFuzz")) {
58+
Credentials = new Credentials(user, pat)
59+
};
60+
}
61+
3362
private async Async.Task Process(GithubIssuesTemplate config, Container container, string filename, Report report) {
3463
var renderer = await Renderer.ConstructRenderer(_context, container, filename, report, _logTracer);
3564
var handler = await GithubConnnector.GithubConnnectorCreator(config, container, filename, renderer, _context.Creds.GetInstanceUrl(), _context, _logTracer);
@@ -48,14 +77,12 @@ public static async Async.Task<GithubConnnector> GithubConnnectorCreator(GithubI
4877
SecretValue<GithubAuth> sv => sv.Value,
4978
_ => throw new ArgumentException($"Unexpected secret type {config.Auth.Secret.GetType()}")
5079
};
51-
return new GithubConnnector(config, container, filename, renderer, instanceUrl, auth!, logTracer);
80+
return new GithubConnnector(config, renderer, instanceUrl, auth!, logTracer);
5281
}
5382

54-
public GithubConnnector(GithubIssuesTemplate config, Container container, string filename, Renderer renderer, Uri instanceUrl, GithubAuth auth, ILogTracer logTracer) {
83+
public GithubConnnector(GithubIssuesTemplate config, Renderer renderer, Uri instanceUrl, GithubAuth auth, ILogTracer logTracer) {
5584
_config = config;
56-
_gh = new GitHubClient(new ProductHeaderValue("OneFuzz")) {
57-
Credentials = new Credentials(auth.User, auth.PersonalAccessToken)
58-
};
85+
_gh = GetGitHubClient(auth.User, auth.PersonalAccessToken);
5986
_renderer = renderer;
6087
_instanceUrl = instanceUrl;
6188
_logTracer = logTracer;

src/deployment/bicep-templates/feature-flags.bicep

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,17 @@ resource configStoreFeatureflag 'Microsoft.AppConfiguration/configurationStores/
2424
}
2525
}
2626

27+
resource validateNotificationConfigSemantics 'Microsoft.AppConfiguration/configurationStores/keyValues@2021-10-01-preview' = {
28+
parent: featureFlags
29+
name: '.appconfig.featureflag~2FEnableValidateNotificationConfigSemantics'
30+
properties: {
31+
value: string({
32+
id: 'EnableScribanOnly'
33+
description: 'Check notification configs for valid PATs and fields'
34+
enabled: true
35+
})
36+
contentType: 'application/vnd.microsoft.appconfig.ff+json;charset=utf-8'
37+
}
38+
}
39+
2740
output AppConfigEndpoint string = 'https://${appConfigName}.azconfig.io'

0 commit comments

Comments
 (0)