mirror of
https://github.com/microsoft/onefuzz.git
synced 2025-06-19 04:58:09 +00:00
Continue port of QueueNodeHearbeat (#1761)
[WIP ] continue port of QueueNodeHearbeat
This commit is contained in:
@ -91,30 +91,49 @@ class Console : ILog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LogTracer {
|
public interface ILogTracer
|
||||||
|
{
|
||||||
|
IDictionary<string, string> Tags { get; }
|
||||||
|
|
||||||
|
void Critical(string message);
|
||||||
|
void Error(string message);
|
||||||
|
void Event(string evt, IDictionary<string, double>? metrics);
|
||||||
|
void Exception(Exception ex, IDictionary<string, double>? metrics);
|
||||||
|
void ForceFlush();
|
||||||
|
void Info(string message);
|
||||||
|
void Warning(string message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LogTracer : ILogTracer
|
||||||
|
{
|
||||||
|
|
||||||
private List<ILog> loggers;
|
private List<ILog> loggers;
|
||||||
|
|
||||||
private IDictionary<string, string> tags = new Dictionary<string, string>();
|
private IDictionary<string, string> tags = new Dictionary<string, string>();
|
||||||
private Guid correlationId;
|
private Guid correlationId;
|
||||||
|
|
||||||
public LogTracer(Guid correlationId, List<ILog> loggers) {
|
public LogTracer(Guid correlationId, List<ILog> loggers)
|
||||||
|
{
|
||||||
this.correlationId = correlationId;
|
this.correlationId = correlationId;
|
||||||
this.loggers = loggers;
|
this.loggers = loggers;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IDictionary<string, string> Tags => tags;
|
public IDictionary<string, string> Tags => tags;
|
||||||
|
|
||||||
public void Info(string message) {
|
public void Info(string message)
|
||||||
|
{
|
||||||
var caller = new StackTrace()?.GetFrame(1)?.GetMethod()?.Name;
|
var caller = new StackTrace()?.GetFrame(1)?.GetMethod()?.Name;
|
||||||
foreach (var logger in loggers) {
|
foreach (var logger in loggers)
|
||||||
|
{
|
||||||
logger.Log(correlationId, message, SeverityLevel.Information, Tags, caller);
|
logger.Log(correlationId, message, SeverityLevel.Information, Tags, caller);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Warning(string message) {
|
public void Warning(string message)
|
||||||
|
{
|
||||||
var caller = new StackTrace()?.GetFrame(1)?.GetMethod()?.Name;
|
var caller = new StackTrace()?.GetFrame(1)?.GetMethod()?.Name;
|
||||||
foreach (var logger in loggers) {
|
foreach (var logger in loggers)
|
||||||
|
{
|
||||||
logger.Log(correlationId, message, SeverityLevel.Warning, Tags, caller);
|
logger.Log(correlationId, message, SeverityLevel.Warning, Tags, caller);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -137,36 +156,50 @@ public class LogTracer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Event(string evt, IDictionary<string, double>? metrics) {
|
public void Event(string evt, IDictionary<string, double>? metrics)
|
||||||
|
{
|
||||||
var caller = new StackTrace()?.GetFrame(1)?.GetMethod()?.Name;
|
var caller = new StackTrace()?.GetFrame(1)?.GetMethod()?.Name;
|
||||||
foreach (var logger in loggers) {
|
foreach (var logger in loggers)
|
||||||
|
{
|
||||||
logger.LogEvent(correlationId, evt, Tags, metrics, caller);
|
logger.LogEvent(correlationId, evt, Tags, metrics, caller);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Exception(Exception ex, IDictionary<string, double>? metrics) {
|
public void Exception(Exception ex, IDictionary<string, double>? metrics)
|
||||||
|
{
|
||||||
var caller = new StackTrace()?.GetFrame(1)?.GetMethod()?.Name;
|
var caller = new StackTrace()?.GetFrame(1)?.GetMethod()?.Name;
|
||||||
foreach (var logger in loggers) {
|
foreach (var logger in loggers)
|
||||||
|
{
|
||||||
logger.LogException(correlationId, ex, Tags, metrics, caller);
|
logger.LogException(correlationId, ex, Tags, metrics, caller);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ForceFlush() {
|
public void ForceFlush()
|
||||||
foreach (var logger in loggers) {
|
{
|
||||||
|
foreach (var logger in loggers)
|
||||||
|
{
|
||||||
logger.Flush();
|
logger.Flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LogTracerFactory {
|
public interface ILogTracerFactory
|
||||||
|
{
|
||||||
|
LogTracer MakeLogTracer(Guid correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LogTracerFactory : ILogTracerFactory
|
||||||
|
{
|
||||||
|
|
||||||
private List<ILog> loggers;
|
private List<ILog> loggers;
|
||||||
|
|
||||||
public LogTracerFactory(List<ILog> loggers) {
|
public LogTracerFactory(List<ILog> loggers)
|
||||||
|
{
|
||||||
this.loggers = loggers;
|
this.loggers = loggers;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LogTracer MakeLogTracer(Guid correlationId) {
|
public LogTracer MakeLogTracer(Guid correlationId)
|
||||||
|
{
|
||||||
return new LogTracer(correlationId, this.loggers);
|
return new LogTracer(correlationId, this.loggers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,3 +26,11 @@ public enum ErrorCode {
|
|||||||
PROXY_FAILED = 472,
|
PROXY_FAILED = 472,
|
||||||
INVALID_CONFIGURATION = 473,
|
INVALID_CONFIGURATION = 473,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public enum WebhookMessageState {
|
||||||
|
Queued,
|
||||||
|
Retrying,
|
||||||
|
Succeeded,
|
||||||
|
Failed
|
||||||
|
}
|
251
src/ApiService/ApiService/OneFuzzTypes/Events.cs
Normal file
251
src/ApiService/ApiService/OneFuzzTypes/Events.cs
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using PoolName = System.String;
|
||||||
|
|
||||||
|
namespace Microsoft.OneFuzz.Service
|
||||||
|
{
|
||||||
|
public enum EventType
|
||||||
|
{
|
||||||
|
JobCreated,
|
||||||
|
JobStopped,
|
||||||
|
NodeCreated,
|
||||||
|
NodeDeleted,
|
||||||
|
NodeStateUpdated,
|
||||||
|
Ping,
|
||||||
|
PoolCreated,
|
||||||
|
PoolDeleted,
|
||||||
|
ProxyCreated,
|
||||||
|
ProxyDeleted,
|
||||||
|
ProxyFailed,
|
||||||
|
ProxyStateUpdated,
|
||||||
|
ScalesetCreated,
|
||||||
|
ScalesetDeleted,
|
||||||
|
ScalesetFailed,
|
||||||
|
ScalesetStateUpdated,
|
||||||
|
ScalesetResizeScheduled,
|
||||||
|
TaskCreated,
|
||||||
|
TaskFailed,
|
||||||
|
TaskStateUpdated,
|
||||||
|
TaskStopped,
|
||||||
|
CrashReported,
|
||||||
|
RegressionReported,
|
||||||
|
FileAdded,
|
||||||
|
TaskHeartbeat,
|
||||||
|
NodeHeartbeat,
|
||||||
|
InstanceConfigUpdated
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract record BaseEvent() {
|
||||||
|
|
||||||
|
public EventType GetEventType() {
|
||||||
|
return
|
||||||
|
this switch {
|
||||||
|
EventNodeHeartbeat _ => EventType.NodeHeartbeat,
|
||||||
|
_ => throw new NotImplementedException(),
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//public record EventTaskStopped(
|
||||||
|
// Guid JobId,
|
||||||
|
// Guid TaskId,
|
||||||
|
// UserInfo? UserInfo,
|
||||||
|
// TaskConfig Config
|
||||||
|
//) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventTaskFailed(
|
||||||
|
// Guid JobId,
|
||||||
|
// Guid TaskId,
|
||||||
|
// Error Error,
|
||||||
|
// UserInfo? UserInfo,
|
||||||
|
// TaskConfig Config
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventJobCreated(
|
||||||
|
// Guid JobId,
|
||||||
|
// JobConfig Config,
|
||||||
|
// UserInfo? UserInfo
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record JobTaskStopped(
|
||||||
|
// Guid TaskId,
|
||||||
|
// TaskType TaskType,
|
||||||
|
// Error? Error
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
//record EventJobStopped(
|
||||||
|
// Guid JobId: UUId,
|
||||||
|
// JobConfig Config,
|
||||||
|
// UserInfo? UserInfo,
|
||||||
|
// List<JobTaskStopped> TaskInfo
|
||||||
|
//): BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventTaskCreated(
|
||||||
|
// Guid JobId,
|
||||||
|
// Guid TaskId,
|
||||||
|
// TaskConfig Config,
|
||||||
|
// UserInfo? UserInfo
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventTaskStateUpdated(
|
||||||
|
// Guid JobId,
|
||||||
|
// Guid TaskId,
|
||||||
|
// TaskState State,
|
||||||
|
// DateTimeOffset? EndTime,
|
||||||
|
// TaskConfig Config
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventTaskHeartbeat(
|
||||||
|
// JobId: Guid,
|
||||||
|
// TaskId: Guid,
|
||||||
|
// Config: TaskConfig
|
||||||
|
//): BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventPing(
|
||||||
|
// PingId: Guid
|
||||||
|
//): BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventScalesetCreated(
|
||||||
|
// Guid ScalesetId,
|
||||||
|
// PoolName PoolName,
|
||||||
|
// string VmSku,
|
||||||
|
// string Image,
|
||||||
|
// Region Region,
|
||||||
|
// int Size) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventScalesetFailed(
|
||||||
|
// Guid ScalesetId,
|
||||||
|
// PoolName: PoolName,
|
||||||
|
// Error: Error
|
||||||
|
//): BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventScalesetDeleted(
|
||||||
|
// Guid ScalesetId,
|
||||||
|
// PoolName PoolName,
|
||||||
|
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventScalesetResizeScheduled(
|
||||||
|
// Guid ScalesetId,
|
||||||
|
// PoolName PoolName,
|
||||||
|
// int size
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventPoolDeleted(
|
||||||
|
// PoolName PoolName
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventPoolCreated(
|
||||||
|
// PoolName PoolName,
|
||||||
|
// Os Os,
|
||||||
|
// Architecture Arch,
|
||||||
|
// bool Managed,
|
||||||
|
// AutoScaleConfig? Autoscale
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventProxyCreated(
|
||||||
|
// Region Region,
|
||||||
|
// Guid? ProxyId,
|
||||||
|
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventProxyDeleted(
|
||||||
|
// Region Region,
|
||||||
|
// Guid? ProxyId
|
||||||
|
//) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventProxyFailed(
|
||||||
|
// Region Region,
|
||||||
|
// Guid? ProxyId,
|
||||||
|
// Error Error
|
||||||
|
//) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventProxyStateUpdated(
|
||||||
|
// Region Region,
|
||||||
|
// Guid ProxyId,
|
||||||
|
// VmState State
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
//record EventNodeCreated(
|
||||||
|
// Guid MachineId,
|
||||||
|
// Guid? ScalesetId,
|
||||||
|
// PoolName PoolName
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public record EventNodeHeartbeat(
|
||||||
|
Guid MachineId,
|
||||||
|
Guid? ScalesetId,
|
||||||
|
PoolName PoolName
|
||||||
|
) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
// record EventNodeDeleted(
|
||||||
|
// Guid MachineId,
|
||||||
|
// Guid ScalesetId,
|
||||||
|
// PoolName PoolName
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
// record EventScalesetStateUpdated(
|
||||||
|
// Guid ScalesetId,
|
||||||
|
// PoolName PoolName,
|
||||||
|
// ScalesetState State
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
// record EventNodeStateUpdated(
|
||||||
|
// Guid MachineId,
|
||||||
|
// Guid? ScalesetId,
|
||||||
|
// PoolName PoolName,
|
||||||
|
// NodeState state
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
// record EventCrashReported(
|
||||||
|
// Report Report,
|
||||||
|
// Container Container,
|
||||||
|
// [property: JsonPropertyName("filename")] String FileName,
|
||||||
|
// TaskConfig? TaskConfig
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
// record EventRegressionReported(
|
||||||
|
// RegressionReport RegressionReport,
|
||||||
|
// Container Container,
|
||||||
|
// [property: JsonPropertyName("filename")] String FileName,
|
||||||
|
// TaskConfig? TaskConfig
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
// record EventFileAdded(
|
||||||
|
// Container Container,
|
||||||
|
// [property: JsonPropertyName("filename")] String FileName
|
||||||
|
// ) : BaseEvent();
|
||||||
|
|
||||||
|
|
||||||
|
// record EventInstanceConfigUpdated(
|
||||||
|
// InstanceConfig Config
|
||||||
|
// ) : BaseEvent();
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
|
using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using PoolName = System.String;
|
||||||
|
|
||||||
namespace Microsoft.OneFuzz.Service;
|
namespace Microsoft.OneFuzz.Service;
|
||||||
|
|
||||||
@ -74,7 +75,7 @@ public enum NodeState
|
|||||||
public partial record Node
|
public partial record Node
|
||||||
(
|
(
|
||||||
DateTimeOffset? InitializedAt,
|
DateTimeOffset? InitializedAt,
|
||||||
[PartitionKey] string PoolName,
|
[PartitionKey] PoolName PoolName,
|
||||||
Guid? PoolId,
|
Guid? PoolId,
|
||||||
[RowKey] Guid MachineId,
|
[RowKey] Guid MachineId,
|
||||||
NodeState State,
|
NodeState State,
|
||||||
@ -90,3 +91,34 @@ public partial record Node
|
|||||||
public record Error (ErrorCode Code, string[]? Errors = null);
|
public record Error (ErrorCode Code, string[]? Errors = null);
|
||||||
|
|
||||||
public record UserInfo (Guid? ApplicationId, Guid? ObjectId, String? Upn);
|
public record UserInfo (Guid? ApplicationId, Guid? ObjectId, String? Upn);
|
||||||
|
|
||||||
|
|
||||||
|
public record EventMessage(
|
||||||
|
Guid EventId,
|
||||||
|
EventType EventType,
|
||||||
|
BaseEvent Event,
|
||||||
|
Guid InstanceId,
|
||||||
|
String InstanceName
|
||||||
|
): EntityBase();
|
||||||
|
|
||||||
|
|
||||||
|
//record AnyHttpUrl(AnyUrl):
|
||||||
|
// allowed_schemes = {'http', 'https
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//public record TaskConfig(
|
||||||
|
// Guid jobId,
|
||||||
|
// List<Guid> PrereqTasks,
|
||||||
|
// TaskDetails Task,
|
||||||
|
// TaskVm? vm,
|
||||||
|
// TaskPool pool: Optional[]
|
||||||
|
// containers: List[TaskContainers]
|
||||||
|
// tags: Dict[str, str]
|
||||||
|
// debug: Optional[List[TaskDebugFlag]]
|
||||||
|
// colocate: Optional[bool]
|
||||||
|
// ): EntityBase();
|
||||||
|
|
||||||
|
59
src/ApiService/ApiService/OneFuzzTypes/Webhooks.cs
Normal file
59
src/ApiService/ApiService/OneFuzzTypes/Webhooks.cs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Microsoft.OneFuzz.Service;
|
||||||
|
|
||||||
|
|
||||||
|
public enum WebhookMessageFormat
|
||||||
|
{
|
||||||
|
Onefuzz,
|
||||||
|
EventGrid
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WebhookMessage(Guid EventId,
|
||||||
|
EventType EventType,
|
||||||
|
BaseEvent Event,
|
||||||
|
Guid InstanceId,
|
||||||
|
String InstanceName,
|
||||||
|
Guid WebhookId): EventMessage(EventId, EventType, Event, InstanceId, InstanceName);
|
||||||
|
|
||||||
|
|
||||||
|
public record WebhookMessageEventGrid(
|
||||||
|
[property: JsonPropertyName("dataVersion")] string DataVersion,
|
||||||
|
string Subject,
|
||||||
|
[property: JsonPropertyName("EventType")] EventType EventType,
|
||||||
|
[property: JsonPropertyName("eventTime")] DateTimeOffset eventTime,
|
||||||
|
Guid Id,
|
||||||
|
BaseEvent data);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public record WebhookMessageLog(
|
||||||
|
[RowKey] Guid EventId,
|
||||||
|
EventType EventType,
|
||||||
|
BaseEvent Event,
|
||||||
|
Guid InstanceId,
|
||||||
|
String InstanceName,
|
||||||
|
[PartitionKey] Guid WebhookId,
|
||||||
|
WebhookMessageState State = WebhookMessageState.Queued,
|
||||||
|
int TryCount = 0
|
||||||
|
) : WebhookMessage(EventId,
|
||||||
|
EventType,
|
||||||
|
Event,
|
||||||
|
InstanceId,
|
||||||
|
InstanceName,
|
||||||
|
WebhookId);
|
||||||
|
|
||||||
|
public record Webhook(
|
||||||
|
[PartitionKey] Guid WebhookId,
|
||||||
|
[RowKey] string Name,
|
||||||
|
Uri? url,
|
||||||
|
List<EventType> EventTypes,
|
||||||
|
string SecretToken, // SecretString??
|
||||||
|
WebhookMessageFormat? MessageFormat
|
||||||
|
) : EntityBase();
|
@ -34,7 +34,9 @@ public class Program
|
|||||||
var host = new HostBuilder()
|
var host = new HostBuilder()
|
||||||
.ConfigureFunctionsWorkerDefaults()
|
.ConfigureFunctionsWorkerDefaults()
|
||||||
.ConfigureServices((context, services) =>
|
.ConfigureServices((context, services) =>
|
||||||
services.AddSingleton(_ => new LogTracerFactory(GetLoggers()))
|
services
|
||||||
|
.AddSingleton<ILogTracerFactory>(_ => new LogTracerFactory(GetLoggers()))
|
||||||
|
.AddScoped<ILogTracer>(s => s.GetService<LogTracerFactory>()?.MakeLogTracer(Guid.NewGuid()) ?? throw new InvalidOperationException("Unable to create a logger") )
|
||||||
.AddSingleton<IStorageProvider>(_ => new StorageProvider(EnvironmentVariables.OneFuzz.FuncStorage ?? throw new InvalidOperationException("Missing account id") ))
|
.AddSingleton<IStorageProvider>(_ => new StorageProvider(EnvironmentVariables.OneFuzz.FuncStorage ?? throw new InvalidOperationException("Missing account id") ))
|
||||||
.AddSingleton<ICreds>(_ => new Creds())
|
.AddSingleton<ICreds>(_ => new Creds())
|
||||||
.AddSingleton<IStorage, Storage>()
|
.AddSingleton<IStorage, Storage>()
|
||||||
|
@ -14,20 +14,25 @@ namespace Microsoft.OneFuzz.Service;
|
|||||||
public class QueueNodeHearbeat
|
public class QueueNodeHearbeat
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly IStorageProvider _storageProvider;
|
|
||||||
|
|
||||||
public QueueNodeHearbeat(ILoggerFactory loggerFactory, IStorageProvider storageProvider)
|
private readonly IEvents _events;
|
||||||
|
private readonly INodeOperations _nodes;
|
||||||
|
|
||||||
|
public QueueNodeHearbeat(ILoggerFactory loggerFactory, INodeOperations nodes, IEvents events)
|
||||||
{
|
{
|
||||||
_logger = loggerFactory.CreateLogger<QueueNodeHearbeat>();
|
_logger = loggerFactory.CreateLogger<QueueNodeHearbeat>();
|
||||||
_storageProvider = storageProvider;
|
_nodes = nodes;
|
||||||
|
_events = events;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Function("QueueNodeHearbeat")]
|
[Function("QueueNodeHearbeat")]
|
||||||
public async Task Run([QueueTrigger("myqueue-items", Connection = "AzureWebJobsStorage")] string msg)
|
public async Task Run([QueueTrigger("myqueue-items", Connection = "AzureWebJobsStorage")] string msg)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation($"heartbeat: {msg}");
|
||||||
|
|
||||||
var hb = JsonSerializer.Deserialize<NodeHeartbeatEntry>(msg, EntityConverter.GetJsonSerializerOptions()).EnsureNotNull($"wrong data {msg}");
|
var hb = JsonSerializer.Deserialize<NodeHeartbeatEntry>(msg, EntityConverter.GetJsonSerializerOptions()).EnsureNotNull($"wrong data {msg}");
|
||||||
|
|
||||||
var node = await Node.GetByMachineId(_storageProvider, hb.NodeId);
|
var node = await _nodes.GetByMachineId(hb.NodeId);
|
||||||
|
|
||||||
if (node == null) {
|
if (node == null) {
|
||||||
_logger.LogWarning($"invalid node id: {hb.NodeId}");
|
_logger.LogWarning($"invalid node id: {hb.NodeId}");
|
||||||
@ -36,16 +41,8 @@ public class QueueNodeHearbeat
|
|||||||
|
|
||||||
var newNode = node with { Heartbeat = DateTimeOffset.UtcNow };
|
var newNode = node with { Heartbeat = DateTimeOffset.UtcNow };
|
||||||
|
|
||||||
await _storageProvider.Replace(newNode);
|
await _nodes.Replace(newNode);
|
||||||
|
|
||||||
//send_event(
|
await _events.SendEvent(new EventNodeHeartbeat(node.MachineId, node.ScalesetId, node.PoolName));
|
||||||
// EventNodeHeartbeat(
|
|
||||||
// machine_id = node.machine_id,
|
|
||||||
// scaleset_id = node.scaleset_id,
|
|
||||||
// pool_name = node.pool_name,
|
|
||||||
// )
|
|
||||||
//)
|
|
||||||
|
|
||||||
_logger.LogInformation($"heartbeat: {msg}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
73
src/ApiService/ApiService/onefuzzlib/Events.cs
Normal file
73
src/ApiService/ApiService/onefuzzlib/Events.cs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
using ApiService.OneFuzzLib;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Microsoft.OneFuzz.Service
|
||||||
|
{
|
||||||
|
|
||||||
|
public record SignalREvent
|
||||||
|
(
|
||||||
|
string Target,
|
||||||
|
List<EventMessage> arguments
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public interface IEvents {
|
||||||
|
public Task SendEvent(BaseEvent anEvent);
|
||||||
|
|
||||||
|
public Task QueueSignalrEvent(EventMessage message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Events : IEvents
|
||||||
|
{
|
||||||
|
private readonly IQueue _queue;
|
||||||
|
private readonly ILogTracer _logger;
|
||||||
|
private readonly IWebhookOperations _webhook;
|
||||||
|
|
||||||
|
public Events(IQueue queue, ILogTracer logger, IWebhookOperations webhook)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
_logger = logger;
|
||||||
|
_webhook = webhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task QueueSignalrEvent(EventMessage eventMessage)
|
||||||
|
{
|
||||||
|
var message = new SignalREvent("events", new List<EventMessage>() { eventMessage });
|
||||||
|
var encodedMessage = Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(message)) ;
|
||||||
|
await _queue.SendMessage("signalr-events", encodedMessage, StorageType.Config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendEvent(BaseEvent anEvent) {
|
||||||
|
var eventType = anEvent.GetEventType();
|
||||||
|
|
||||||
|
var eventMessage = new EventMessage(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
eventType,
|
||||||
|
anEvent,
|
||||||
|
Guid.NewGuid(), // todo
|
||||||
|
"test" //todo
|
||||||
|
);
|
||||||
|
await QueueSignalrEvent(eventMessage);
|
||||||
|
await _webhook.SendEvent(eventMessage);
|
||||||
|
LogEvent(anEvent, eventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LogEvent(BaseEvent anEvent, EventType eventType)
|
||||||
|
{
|
||||||
|
//todo
|
||||||
|
//var scrubedEvent = FilterEvent(anEvent);
|
||||||
|
//throw new NotImplementedException();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private object FilterEvent(BaseEvent anEvent)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
src/ApiService/ApiService/onefuzzlib/NodeOperations.cs
Normal file
32
src/ApiService/ApiService/onefuzzlib/NodeOperations.cs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
using ApiService.OneFuzzLib.Orm;
|
||||||
|
using Azure.Data.Tables;
|
||||||
|
using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Microsoft.OneFuzz.Service;
|
||||||
|
|
||||||
|
public interface INodeOperations : IOrm<Node>
|
||||||
|
{
|
||||||
|
Task<Node?> GetByMachineId(Guid machineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NodeOperations : Orm<Node>, INodeOperations
|
||||||
|
{
|
||||||
|
|
||||||
|
public NodeOperations(IStorage storage)
|
||||||
|
:base(storage)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Node?> GetByMachineId(Guid machineId)
|
||||||
|
{
|
||||||
|
var data = QueryAsync(filter: $"RowKey eq '{machineId}'");
|
||||||
|
|
||||||
|
return await data.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,17 +0,0 @@
|
|||||||
using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
|
|
||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Microsoft.OneFuzz.Service;
|
|
||||||
|
|
||||||
public partial record Node
|
|
||||||
{
|
|
||||||
public async static Task<Node?> GetByMachineId(IStorageProvider storageProvider, Guid machineId) {
|
|
||||||
var tableClient = await storageProvider.GetTableClient("Node");
|
|
||||||
|
|
||||||
var data = storageProvider.QueryAsync<Node>(filter: $"RowKey eq '{machineId}'");
|
|
||||||
|
|
||||||
return await data.FirstOrDefaultAsync();
|
|
||||||
}
|
|
||||||
}
|
|
81
src/ApiService/ApiService/onefuzzlib/Queue.cs
Normal file
81
src/ApiService/ApiService/onefuzzlib/Queue.cs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
using Azure.Storage;
|
||||||
|
using Azure.Storage.Queues;
|
||||||
|
using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Microsoft.OneFuzz.Service;
|
||||||
|
public interface IQueue
|
||||||
|
{
|
||||||
|
Task SendMessage(string name, byte[] message, StorageType storageType, TimeSpan? visibilityTimeout = null, TimeSpan? timeToLive = null);
|
||||||
|
Task<bool> QueueObject<T>(string name, T obj, StorageType storageType, TimeSpan? visibilityTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class Queue : IQueue
|
||||||
|
{
|
||||||
|
IStorage _storage;
|
||||||
|
ILog _logger;
|
||||||
|
|
||||||
|
public Queue(IStorage storage, ILog logger)
|
||||||
|
{
|
||||||
|
_storage = storage;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task SendMessage(string name, byte[] message, StorageType storageType, TimeSpan? visibilityTimeout=null, TimeSpan? timeToLive=null ) {
|
||||||
|
var queue = GetQueue(name, storageType);
|
||||||
|
if (queue != null) {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await queue.SendMessageAsync(Convert.ToBase64String(message), visibilityTimeout: visibilityTimeout, timeToLive: timeToLive);
|
||||||
|
}
|
||||||
|
catch (Exception) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueueClient? GetQueue(string name, StorageType storageType ) {
|
||||||
|
var client = GetQueueClient(storageType);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return client.GetQueueClient(name);
|
||||||
|
}
|
||||||
|
catch (Exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public QueueServiceClient GetQueueClient(StorageType storageType)
|
||||||
|
{
|
||||||
|
var accountId = _storage.GetPrimaryAccount(storageType);
|
||||||
|
//_logger.LogDEbug("getting blob container (account_id: %s)", account_id)
|
||||||
|
(var name, var key) = _storage.GetStorageAccountNameAndKey(accountId);
|
||||||
|
var accountUrl = new Uri($"https://%s.queue.core.windows.net{name}");
|
||||||
|
var client = new QueueServiceClient(accountUrl, new StorageSharedKeyCredential(name, key));
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> QueueObject<T>(string name, T obj, StorageType storageType, TimeSpan? visibilityTimeout)
|
||||||
|
{
|
||||||
|
var queue = GetQueue(name, storageType) ?? throw new Exception($"unable to queue object, no such queue: {name}");
|
||||||
|
|
||||||
|
var serialized = JsonSerializer.Serialize(obj, EntityConverter.GetJsonSerializerOptions()) ;
|
||||||
|
//var encoded = Encoding.UTF8.GetBytes(serialized);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await queue.SendMessageAsync(serialized, visibilityTimeout: visibilityTimeout);
|
||||||
|
return true;
|
||||||
|
} catch (Exception) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -5,13 +5,23 @@ using Azure.ResourceManager.Storage;
|
|||||||
using Azure.Core;
|
using Azure.Core;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Azure.Data.Tables;
|
||||||
|
|
||||||
namespace Microsoft.OneFuzz.Service;
|
namespace Microsoft.OneFuzz.Service;
|
||||||
|
|
||||||
|
public enum StorageType {
|
||||||
|
Corpus,
|
||||||
|
Config
|
||||||
|
}
|
||||||
|
|
||||||
public interface IStorage {
|
public interface IStorage {
|
||||||
public ArmClient GetMgmtClient();
|
public ArmClient GetMgmtClient();
|
||||||
|
|
||||||
public IEnumerable<string> CorpusAccounts();
|
public IEnumerable<string> CorpusAccounts();
|
||||||
|
string GetPrimaryAccount(StorageType storageType);
|
||||||
|
public (string?, string?) GetStorageAccountNameAndKey(string accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Storage : IStorage {
|
public class Storage : IStorage {
|
||||||
@ -76,4 +86,24 @@ public class Storage : IStorage {
|
|||||||
_logger.LogInformation($"corpus accounts: {JsonSerializer.Serialize(results)}");
|
_logger.LogInformation($"corpus accounts: {JsonSerializer.Serialize(results)}");
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string GetPrimaryAccount(StorageType storageType)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
storageType switch
|
||||||
|
{
|
||||||
|
StorageType.Corpus => GetFuzzStorage(),
|
||||||
|
StorageType.Config => GetFuncStorage(),
|
||||||
|
_ => throw new NotImplementedException(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public (string?, string?) GetStorageAccountNameAndKey(string accountId)
|
||||||
|
{
|
||||||
|
var resourceId = new ResourceIdentifier(accountId);
|
||||||
|
var armClient = GetMgmtClient();
|
||||||
|
var storageAccount = armClient.GetStorageAccount(resourceId);
|
||||||
|
var key = storageAccount.GetKeys().Value.Keys.FirstOrDefault();
|
||||||
|
return (resourceId.Name, key?.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
110
src/ApiService/ApiService/onefuzzlib/WebhookOperations.cs
Normal file
110
src/ApiService/ApiService/onefuzzlib/WebhookOperations.cs
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
using ApiService.OneFuzzLib.Orm;
|
||||||
|
using Microsoft.OneFuzz.Service;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ApiService.OneFuzzLib;
|
||||||
|
|
||||||
|
|
||||||
|
public interface IWebhookMessageLogOperations: IOrm<WebhookMessageLog>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class WebhookMessageLogOperations : Orm<WebhookMessageLog>, IWebhookMessageLogOperations
|
||||||
|
{
|
||||||
|
record WebhookMessageQueueObj (
|
||||||
|
Guid WebhookId,
|
||||||
|
Guid EventId
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly IQueue _queue;
|
||||||
|
private readonly ILogTracer _log;
|
||||||
|
public WebhookMessageLogOperations(IStorage storage, IQueue queue, ILogTracer log) : base(storage)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task QueueWebhook(WebhookMessageLog webhookLog) {
|
||||||
|
var obj = new WebhookMessageQueueObj(webhookLog.WebhookId, webhookLog.EventId);
|
||||||
|
|
||||||
|
TimeSpan? visibilityTimeout = webhookLog.State switch
|
||||||
|
{
|
||||||
|
WebhookMessageState.Queued => TimeSpan.Zero,
|
||||||
|
WebhookMessageState.Retrying => TimeSpan.FromSeconds(30),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (visibilityTimeout == null)
|
||||||
|
{
|
||||||
|
_log.Error($"invalid WebhookMessage queue state, not queuing. {webhookLog.WebhookId}:{webhookLog.EventId} - {webhookLog.State}");
|
||||||
|
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _queue.QueueObject("webhooks", obj, StorageType.Config, visibilityTimeout: visibilityTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueObject(string v, WebhookMessageQueueObj obj, StorageType config, int? visibility_timeout)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public interface IWebhookOperations
|
||||||
|
{
|
||||||
|
Task SendEvent(EventMessage eventMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WebhookOperations: Orm<Webhook>, IWebhookOperations
|
||||||
|
{
|
||||||
|
private readonly IWebhookMessageLogOperations _webhookMessageLogOperations;
|
||||||
|
public WebhookOperations(IStorage storage, IWebhookMessageLogOperations webhookMessageLogOperations)
|
||||||
|
:base(storage)
|
||||||
|
{
|
||||||
|
_webhookMessageLogOperations = webhookMessageLogOperations;
|
||||||
|
}
|
||||||
|
|
||||||
|
async public Task SendEvent(EventMessage eventMessage)
|
||||||
|
{
|
||||||
|
await foreach (var webhook in GetWebhooksCached())
|
||||||
|
{
|
||||||
|
if (!webhook.EventTypes.Contains(eventMessage.EventType))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await AddEvent(webhook, eventMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async private Task AddEvent(Webhook webhook, EventMessage eventMessage)
|
||||||
|
{
|
||||||
|
var message = new WebhookMessageLog(
|
||||||
|
EventId: eventMessage.EventId,
|
||||||
|
EventType: eventMessage.EventType,
|
||||||
|
Event: eventMessage.Event,
|
||||||
|
InstanceId: eventMessage.InstanceId,
|
||||||
|
InstanceName: eventMessage.InstanceName,
|
||||||
|
WebhookId: webhook.WebhookId
|
||||||
|
);
|
||||||
|
|
||||||
|
await _webhookMessageLogOperations.Replace(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//todo: caching
|
||||||
|
public IAsyncEnumerable<Webhook> GetWebhooksCached()
|
||||||
|
{
|
||||||
|
return QueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
261
src/ApiService/ApiService/onefuzzlib/orm/EntityConverter.cs
Normal file
261
src/ApiService/ApiService/onefuzzlib/orm/EntityConverter.cs
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
using Azure.Data.Tables;
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Azure;
|
||||||
|
|
||||||
|
namespace Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
|
||||||
|
|
||||||
|
public abstract record EntityBase
|
||||||
|
{
|
||||||
|
public ETag? ETag { get; set; }
|
||||||
|
public DateTimeOffset? TimeStamp { get; set; }
|
||||||
|
|
||||||
|
//public ApiService.OneFuzzLib.Orm.IOrm<EntityBase>? Orm { get; set; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates that the enum cases should no be renamed
|
||||||
|
[AttributeUsage(AttributeTargets.Enum)]
|
||||||
|
public class SkipRename : Attribute { }
|
||||||
|
|
||||||
|
public class RowKeyAttribute : Attribute { }
|
||||||
|
public class PartitionKeyAttribute : Attribute { }
|
||||||
|
public enum EntityPropertyKind
|
||||||
|
{
|
||||||
|
PartitionKey,
|
||||||
|
RowKey,
|
||||||
|
Column
|
||||||
|
}
|
||||||
|
public record EntityProperty(string name, string columnName, Type type, EntityPropertyKind kind);
|
||||||
|
public record EntityInfo(Type type, EntityProperty[] properties, Func<object?[], object> constructor);
|
||||||
|
|
||||||
|
class OnefuzzNamingPolicy : JsonNamingPolicy
|
||||||
|
{
|
||||||
|
public override string ConvertName(string name)
|
||||||
|
{
|
||||||
|
return CaseConverter.PascalToSnake(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public class EntityConverter
|
||||||
|
{
|
||||||
|
private readonly JsonSerializerOptions _options;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<Type, EntityInfo> _cache;
|
||||||
|
|
||||||
|
|
||||||
|
public EntityConverter()
|
||||||
|
{
|
||||||
|
_options = GetJsonSerializerOptions();
|
||||||
|
_cache = new ConcurrentDictionary<Type, EntityInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static JsonSerializerOptions GetJsonSerializerOptions() {
|
||||||
|
var options = new JsonSerializerOptions()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = new OnefuzzNamingPolicy(),
|
||||||
|
};
|
||||||
|
options.Converters.Add(new CustomEnumConverterFactory());
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Func<object?[], object> BuildConstructerFrom(ConstructorInfo constructorInfo)
|
||||||
|
{
|
||||||
|
var constructorParameters = Expression.Parameter(typeof(object?[]));
|
||||||
|
|
||||||
|
var parameterExpressions =
|
||||||
|
constructorInfo.GetParameters().Select((parameterInfo, i) =>
|
||||||
|
{
|
||||||
|
var ithIndex = Expression.Constant(i);
|
||||||
|
var ithParameter = Expression.ArrayIndex(constructorParameters, ithIndex);
|
||||||
|
var unboxedIthParameter = Expression.Convert(ithParameter, parameterInfo.ParameterType);
|
||||||
|
return unboxedIthParameter;
|
||||||
|
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
NewExpression constructorCall = Expression.New(constructorInfo, parameterExpressions);
|
||||||
|
|
||||||
|
Func<object?[], object> ctor = Expression.Lambda<Func<object?[], object>>(constructorCall, constructorParameters).Compile();
|
||||||
|
return ctor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EntityInfo GetEntityInfo<T>()
|
||||||
|
{
|
||||||
|
return _cache.GetOrAdd(typeof(T), type =>
|
||||||
|
{
|
||||||
|
var constructor = type.GetConstructors()[0];
|
||||||
|
var parameterInfos = constructor.GetParameters();
|
||||||
|
var parameters =
|
||||||
|
parameterInfos.Select(f =>
|
||||||
|
{
|
||||||
|
var name = f.Name.EnsureNotNull($"Invalid paramter {f}");
|
||||||
|
var parameterType = f.ParameterType.EnsureNotNull($"Invalid paramter {f}");
|
||||||
|
var isRowkey = f.GetCustomAttribute(typeof(RowKeyAttribute)) != null;
|
||||||
|
var isPartitionkey = f.GetCustomAttribute(typeof(PartitionKeyAttribute)) != null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var (columnName, kind) =
|
||||||
|
isRowkey
|
||||||
|
? ("RowKey", EntityPropertyKind.RowKey)
|
||||||
|
: isPartitionkey
|
||||||
|
? ("PartitionKey", EntityPropertyKind.PartitionKey)
|
||||||
|
: (// JsonPropertyNameAttribute can only be applied to properties
|
||||||
|
typeof(T).GetProperty(name)?.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
|
||||||
|
?? CaseConverter.PascalToSnake(name),
|
||||||
|
EntityPropertyKind.Column
|
||||||
|
);
|
||||||
|
|
||||||
|
return new EntityProperty(name, columnName, parameterType, kind);
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
return new EntityInfo(typeof(T), parameters, BuildConstructerFrom(constructor));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public TableEntity ToTableEntity<T>(T typedEntity) where T: EntityBase
|
||||||
|
{
|
||||||
|
if (typedEntity == null)
|
||||||
|
{
|
||||||
|
throw new NullReferenceException();
|
||||||
|
}
|
||||||
|
var type = typeof(T)!;
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
throw new NullReferenceException();
|
||||||
|
}
|
||||||
|
var tableEntity = new TableEntity();
|
||||||
|
var entityInfo = GetEntityInfo<T>();
|
||||||
|
foreach (var prop in entityInfo.properties)
|
||||||
|
{
|
||||||
|
var value = entityInfo.type.GetProperty(prop.name)?.GetValue(typedEntity);
|
||||||
|
if (prop.type == typeof(Guid) || prop.type == typeof(Guid?))
|
||||||
|
{
|
||||||
|
tableEntity.Add(prop.columnName, value?.ToString());
|
||||||
|
}
|
||||||
|
else if (prop.type == typeof(bool)
|
||||||
|
|| prop.type == typeof(bool?)
|
||||||
|
|| prop.type == typeof(string)
|
||||||
|
|| prop.type == typeof(DateTime)
|
||||||
|
|| prop.type == typeof(DateTime?)
|
||||||
|
|| prop.type == typeof(DateTimeOffset)
|
||||||
|
|| prop.type == typeof(DateTimeOffset?)
|
||||||
|
|| prop.type == typeof(int)
|
||||||
|
|| prop.type == typeof(int?)
|
||||||
|
|| prop.type == typeof(Int64)
|
||||||
|
|| prop.type == typeof(Int64?)
|
||||||
|
|| prop.type == typeof(double)
|
||||||
|
|| prop.type == typeof(double?)
|
||||||
|
|
||||||
|
)
|
||||||
|
{
|
||||||
|
tableEntity.Add(prop.columnName, value);
|
||||||
|
}
|
||||||
|
else if (prop.type.IsEnum)
|
||||||
|
{
|
||||||
|
var values =
|
||||||
|
(value?.ToString()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(CaseConverter.PascalToSnake)).EnsureNotNull($"Unable to read enum data {value}");
|
||||||
|
|
||||||
|
tableEntity.Add(prop.columnName, string.Join(",", values));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var serialized = JsonSerializer.Serialize(value, _options);
|
||||||
|
tableEntity.Add(prop.columnName, serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typedEntity.ETag.HasValue) {
|
||||||
|
tableEntity.ETag = typedEntity.ETag.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tableEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public T ToRecord<T>(TableEntity entity) where T: EntityBase
|
||||||
|
{
|
||||||
|
var entityInfo = GetEntityInfo<T>();
|
||||||
|
var parameters =
|
||||||
|
entityInfo.properties.Select(ef =>
|
||||||
|
{
|
||||||
|
if (ef.kind == EntityPropertyKind.PartitionKey || ef.kind == EntityPropertyKind.RowKey)
|
||||||
|
{
|
||||||
|
if (ef.type == typeof(string))
|
||||||
|
return entity.GetString(ef.kind.ToString());
|
||||||
|
else if (ef.type == typeof(Guid))
|
||||||
|
return Guid.Parse(entity.GetString(ef.kind.ToString()));
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new Exception("invalid ");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
var fieldName = ef.columnName;
|
||||||
|
if (ef.type == typeof(string))
|
||||||
|
{
|
||||||
|
return entity.GetString(fieldName);
|
||||||
|
}
|
||||||
|
else if (ef.type == typeof(bool))
|
||||||
|
{
|
||||||
|
return entity.GetBoolean(fieldName);
|
||||||
|
}
|
||||||
|
else if (ef.type == typeof(DateTimeOffset) || ef.type == typeof(DateTimeOffset?))
|
||||||
|
{
|
||||||
|
return entity.GetDateTimeOffset(fieldName);
|
||||||
|
}
|
||||||
|
else if (ef.type == typeof(DateTime))
|
||||||
|
{
|
||||||
|
return entity.GetDateTime(fieldName);
|
||||||
|
}
|
||||||
|
else if (ef.type == typeof(double))
|
||||||
|
{
|
||||||
|
return entity.GetDouble(fieldName);
|
||||||
|
}
|
||||||
|
else if (ef.type == typeof(Guid) || ef.type == typeof(Guid?))
|
||||||
|
{
|
||||||
|
return (object?)Guid.Parse(entity.GetString(fieldName));
|
||||||
|
}
|
||||||
|
else if (ef.type == typeof(int))
|
||||||
|
{
|
||||||
|
return entity.GetInt32(fieldName);
|
||||||
|
}
|
||||||
|
else if (ef.type == typeof(Int64))
|
||||||
|
{
|
||||||
|
return entity.GetInt64(fieldName);
|
||||||
|
}
|
||||||
|
else if (ef.type.IsEnum)
|
||||||
|
{
|
||||||
|
var stringValues =
|
||||||
|
entity.GetString(fieldName).Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(CaseConverter.SnakeToPascal);
|
||||||
|
|
||||||
|
return Enum.Parse(ef.type, string.Join(",", stringValues));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var value = entity.GetString(fieldName);
|
||||||
|
return JsonSerializer.Deserialize(value, ef.type, options: _options); ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).ToArray();
|
||||||
|
|
||||||
|
var entityRecord = (T)entityInfo.constructor.Invoke(parameters);
|
||||||
|
entityRecord.ETag = entity.ETag;
|
||||||
|
entityRecord.TimeStamp = entity.Timestamp;
|
||||||
|
|
||||||
|
return entityRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,259 +1,60 @@
|
|||||||
|
using Azure.Core;
|
||||||
using Azure.Data.Tables;
|
using Azure.Data.Tables;
|
||||||
|
using Microsoft.OneFuzz.Service;
|
||||||
|
using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
|
||||||
using System;
|
using System;
|
||||||
using System.Reflection;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Linq.Expressions;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Threading.Tasks;
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using Azure;
|
|
||||||
|
|
||||||
namespace Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
|
namespace ApiService.OneFuzzLib.Orm
|
||||||
|
|
||||||
public abstract record EntityBase
|
|
||||||
{
|
{
|
||||||
public ETag? ETag { get; set; }
|
public interface IOrm<T> where T : EntityBase
|
||||||
public DateTimeOffset? TimeStamp { get; set; }
|
{
|
||||||
|
Task<TableClient> GetTableClient(string table, string? accountId = null);
|
||||||
|
IAsyncEnumerable<T> QueryAsync(string filter);
|
||||||
|
Task<bool> Replace(T entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Orm<T> : IOrm<T> where T : EntityBase
|
||||||
|
{
|
||||||
|
IStorage _storage;
|
||||||
|
EntityConverter _entityConverter;
|
||||||
|
|
||||||
|
public Orm(IStorage storage)
|
||||||
|
{
|
||||||
|
_storage = storage;
|
||||||
|
_entityConverter = new EntityConverter();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<T> QueryAsync(string? filter=null)
|
||||||
|
{
|
||||||
|
var tableClient = await GetTableClient(typeof(T).Name);
|
||||||
|
|
||||||
|
await foreach (var x in tableClient.QueryAsync<TableEntity>(filter).Select(x => _entityConverter.ToRecord<T>(x)))
|
||||||
|
{
|
||||||
|
yield return x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Replace(T entity)
|
||||||
|
{
|
||||||
|
var tableClient = await GetTableClient(typeof(T).Name);
|
||||||
|
var tableEntity = _entityConverter.ToTableEntity(entity);
|
||||||
|
var response = await tableClient.UpsertEntityAsync(tableEntity);
|
||||||
|
return !response.IsError;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Indicates that the enum cases should no be renamed
|
public async Task<TableClient> GetTableClient(string table, string? accountId = null)
|
||||||
[AttributeUsage(AttributeTargets.Enum)]
|
|
||||||
public class SkipRename : Attribute { }
|
|
||||||
|
|
||||||
public class RowKeyAttribute : Attribute { }
|
|
||||||
public class PartitionKeyAttribute : Attribute { }
|
|
||||||
public enum EntityPropertyKind
|
|
||||||
{
|
{
|
||||||
PartitionKey,
|
var account = accountId ?? EnvironmentVariables.OneFuzz.FuncStorage ?? throw new ArgumentNullException(nameof(accountId));
|
||||||
RowKey,
|
var (name, key) = _storage.GetStorageAccountNameAndKey(account);
|
||||||
Column
|
var identifier = new ResourceIdentifier(account);
|
||||||
}
|
var tableClient = new TableServiceClient(new Uri($"https://{identifier.Name}.table.core.windows.net"), new TableSharedKeyCredential(name, key));
|
||||||
public record EntityProperty(string name, string columnName, Type type, EntityPropertyKind kind);
|
await tableClient.CreateTableIfNotExistsAsync(table);
|
||||||
public record EntityInfo(Type type, EntityProperty[] properties, Func<object?[], object> constructor);
|
return tableClient.GetTableClient(table);
|
||||||
|
|
||||||
class OnefuzzNamingPolicy : JsonNamingPolicy
|
|
||||||
{
|
|
||||||
public override string ConvertName(string name)
|
|
||||||
{
|
|
||||||
return CaseConverter.PascalToSnake(name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public class EntityConverter
|
|
||||||
{
|
|
||||||
private readonly JsonSerializerOptions _options;
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<Type, EntityInfo> _cache;
|
|
||||||
|
|
||||||
|
|
||||||
public EntityConverter()
|
|
||||||
{
|
|
||||||
_options = GetJsonSerializerOptions();
|
|
||||||
_cache = new ConcurrentDictionary<Type, EntityInfo>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static JsonSerializerOptions GetJsonSerializerOptions() {
|
|
||||||
var options = new JsonSerializerOptions()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = new OnefuzzNamingPolicy(),
|
|
||||||
};
|
|
||||||
options.Converters.Add(new CustomEnumConverterFactory());
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal Func<object?[], object> BuildConstructerFrom(ConstructorInfo constructorInfo)
|
|
||||||
{
|
|
||||||
var constructorParameters = Expression.Parameter(typeof(object?[]));
|
|
||||||
|
|
||||||
var parameterExpressions =
|
|
||||||
constructorInfo.GetParameters().Select((parameterInfo, i) =>
|
|
||||||
{
|
|
||||||
var ithIndex = Expression.Constant(i);
|
|
||||||
var ithParameter = Expression.ArrayIndex(constructorParameters, ithIndex);
|
|
||||||
var unboxedIthParameter = Expression.Convert(ithParameter, parameterInfo.ParameterType);
|
|
||||||
return unboxedIthParameter;
|
|
||||||
|
|
||||||
}).ToArray();
|
|
||||||
|
|
||||||
NewExpression constructorCall = Expression.New(constructorInfo, parameterExpressions);
|
|
||||||
|
|
||||||
Func<object?[], object> ctor = Expression.Lambda<Func<object?[], object>>(constructorCall, constructorParameters).Compile();
|
|
||||||
return ctor;
|
|
||||||
}
|
|
||||||
|
|
||||||
private EntityInfo GetEntityInfo<T>()
|
|
||||||
{
|
|
||||||
return _cache.GetOrAdd(typeof(T), type =>
|
|
||||||
{
|
|
||||||
var constructor = type.GetConstructors()[0];
|
|
||||||
var parameterInfos = constructor.GetParameters();
|
|
||||||
var parameters =
|
|
||||||
parameterInfos.Select(f =>
|
|
||||||
{
|
|
||||||
var name = f.Name.EnsureNotNull($"Invalid paramter {f}");
|
|
||||||
var parameterType = f.ParameterType.EnsureNotNull($"Invalid paramter {f}");
|
|
||||||
var isRowkey = f.GetCustomAttribute(typeof(RowKeyAttribute)) != null;
|
|
||||||
var isPartitionkey = f.GetCustomAttribute(typeof(PartitionKeyAttribute)) != null;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
var (columnName, kind) =
|
|
||||||
isRowkey
|
|
||||||
? ("RowKey", EntityPropertyKind.RowKey)
|
|
||||||
: isPartitionkey
|
|
||||||
? ("PartitionKey", EntityPropertyKind.PartitionKey)
|
|
||||||
: (// JsonPropertyNameAttribute can only be applied to properties
|
|
||||||
typeof(T).GetProperty(name)?.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
|
|
||||||
?? CaseConverter.PascalToSnake(name),
|
|
||||||
EntityPropertyKind.Column
|
|
||||||
);
|
|
||||||
|
|
||||||
return new EntityProperty(name, columnName, parameterType, kind);
|
|
||||||
}).ToArray();
|
|
||||||
|
|
||||||
return new EntityInfo(typeof(T), parameters, BuildConstructerFrom(constructor));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public TableEntity ToTableEntity<T>(T typedEntity) where T: EntityBase
|
|
||||||
{
|
|
||||||
if (typedEntity == null)
|
|
||||||
{
|
|
||||||
throw new NullReferenceException();
|
|
||||||
}
|
|
||||||
var type = typeof(T)!;
|
|
||||||
if (type is null)
|
|
||||||
{
|
|
||||||
throw new NullReferenceException();
|
|
||||||
}
|
|
||||||
var tableEntity = new TableEntity();
|
|
||||||
var entityInfo = GetEntityInfo<T>();
|
|
||||||
foreach (var prop in entityInfo.properties)
|
|
||||||
{
|
|
||||||
var value = entityInfo.type.GetProperty(prop.name)?.GetValue(typedEntity);
|
|
||||||
if (prop.type == typeof(Guid) || prop.type == typeof(Guid?))
|
|
||||||
{
|
|
||||||
tableEntity.Add(prop.columnName, value?.ToString());
|
|
||||||
}
|
|
||||||
else if (prop.type == typeof(bool)
|
|
||||||
|| prop.type == typeof(bool?)
|
|
||||||
|| prop.type == typeof(string)
|
|
||||||
|| prop.type == typeof(DateTime)
|
|
||||||
|| prop.type == typeof(DateTime?)
|
|
||||||
|| prop.type == typeof(DateTimeOffset)
|
|
||||||
|| prop.type == typeof(DateTimeOffset?)
|
|
||||||
|| prop.type == typeof(int)
|
|
||||||
|| prop.type == typeof(int?)
|
|
||||||
|| prop.type == typeof(Int64)
|
|
||||||
|| prop.type == typeof(Int64?)
|
|
||||||
|| prop.type == typeof(double)
|
|
||||||
|| prop.type == typeof(double?)
|
|
||||||
|
|
||||||
)
|
|
||||||
{
|
|
||||||
tableEntity.Add(prop.columnName, value);
|
|
||||||
}
|
|
||||||
else if (prop.type.IsEnum)
|
|
||||||
{
|
|
||||||
var values =
|
|
||||||
(value?.ToString()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Select(CaseConverter.PascalToSnake)).EnsureNotNull($"Unable to read enum data {value}");
|
|
||||||
|
|
||||||
tableEntity.Add(prop.columnName, string.Join(",", values));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var serialized = JsonSerializer.Serialize(value, _options);
|
|
||||||
tableEntity.Add(prop.columnName, serialized);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typedEntity.ETag.HasValue) {
|
|
||||||
tableEntity.ETag = typedEntity.ETag.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return tableEntity;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public T ToRecord<T>(TableEntity entity) where T: EntityBase
|
|
||||||
{
|
|
||||||
var entityInfo = GetEntityInfo<T>();
|
|
||||||
var parameters =
|
|
||||||
entityInfo.properties.Select(ef =>
|
|
||||||
{
|
|
||||||
if (ef.kind == EntityPropertyKind.PartitionKey || ef.kind == EntityPropertyKind.RowKey)
|
|
||||||
{
|
|
||||||
if (ef.type == typeof(string))
|
|
||||||
return entity.GetString(ef.kind.ToString());
|
|
||||||
else if (ef.type == typeof(Guid))
|
|
||||||
return Guid.Parse(entity.GetString(ef.kind.ToString()));
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new Exception("invalid ");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
var fieldName = ef.columnName;
|
|
||||||
if (ef.type == typeof(string))
|
|
||||||
{
|
|
||||||
return entity.GetString(fieldName);
|
|
||||||
}
|
|
||||||
else if (ef.type == typeof(bool))
|
|
||||||
{
|
|
||||||
return entity.GetBoolean(fieldName);
|
|
||||||
}
|
|
||||||
else if (ef.type == typeof(DateTimeOffset) || ef.type == typeof(DateTimeOffset?))
|
|
||||||
{
|
|
||||||
return entity.GetDateTimeOffset(fieldName);
|
|
||||||
}
|
|
||||||
else if (ef.type == typeof(DateTime))
|
|
||||||
{
|
|
||||||
return entity.GetDateTime(fieldName);
|
|
||||||
}
|
|
||||||
else if (ef.type == typeof(double))
|
|
||||||
{
|
|
||||||
return entity.GetDouble(fieldName);
|
|
||||||
}
|
|
||||||
else if (ef.type == typeof(Guid) || ef.type == typeof(Guid?))
|
|
||||||
{
|
|
||||||
return (object?)Guid.Parse(entity.GetString(fieldName));
|
|
||||||
}
|
|
||||||
else if (ef.type == typeof(int))
|
|
||||||
{
|
|
||||||
return entity.GetInt32(fieldName);
|
|
||||||
}
|
|
||||||
else if (ef.type == typeof(Int64))
|
|
||||||
{
|
|
||||||
return entity.GetInt64(fieldName);
|
|
||||||
}
|
|
||||||
else if (ef.type.IsEnum)
|
|
||||||
{
|
|
||||||
var stringValues =
|
|
||||||
entity.GetString(fieldName).Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Select(CaseConverter.SnakeToPascal);
|
|
||||||
|
|
||||||
return Enum.Parse(ef.type, string.Join(",", stringValues));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var value = entity.GetString(fieldName);
|
|
||||||
return JsonSerializer.Deserialize(value, ef.type, options: _options); ;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
).ToArray();
|
|
||||||
|
|
||||||
var entityRecord = (T)entityInfo.constructor.Invoke(parameters);
|
|
||||||
entityRecord.ETag = entity.ETag;
|
|
||||||
entityRecord.TimeStamp = entity.Timestamp;
|
|
||||||
|
|
||||||
return entityRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -14,8 +14,8 @@ namespace Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
|
|||||||
public interface IStorageProvider
|
public interface IStorageProvider
|
||||||
{
|
{
|
||||||
Task<TableClient> GetTableClient(string table);
|
Task<TableClient> GetTableClient(string table);
|
||||||
IAsyncEnumerable<T> QueryAsync<T>(string filter) where T : EntityBase;
|
//IAsyncEnumerable<T> QueryAsync<T>(string filter) where T : EntityBase;
|
||||||
Task<bool> Replace<T>(T entity) where T : EntityBase;
|
//Task<bool> Replace<T>(T entity) where T : EntityBase;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,21 +48,5 @@ public class StorageProvider : IStorageProvider
|
|||||||
return (resourceId.Name, key?.Value);
|
return (resourceId.Name, key?.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<T> QueryAsync<T>(string filter) where T : EntityBase
|
|
||||||
{
|
|
||||||
var tableClient = await GetTableClient(typeof(T).Name);
|
|
||||||
|
|
||||||
await foreach (var x in tableClient.QueryAsync<TableEntity>(filter).Select(x => _entityConverter.ToRecord<T>(x))) {
|
|
||||||
yield return x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> Replace<T>(T entity) where T : EntityBase
|
|
||||||
{
|
|
||||||
var tableClient = await GetTableClient(typeof(T).Name);
|
|
||||||
var tableEntity = _entityConverter.ToTableEntity(entity);
|
|
||||||
var response = await tableClient.UpsertEntityAsync(tableEntity);
|
|
||||||
return !response.IsError;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
|
Reference in New Issue
Block a user