chore: initial project import
This commit is contained in:
commit
3fc4e5ca53
347 files changed
+56172
No files matched your search
@@ -0,0 +1,303 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record TaskInput(
|
||||
string Scope,
|
||||
string Kind,
|
||||
string? Target,
|
||||
IReadOnlyDictionary<string, JsonElement>? Params,
|
||||
string? Schedule,
|
||||
bool? Enabled,
|
||||
NodeId? AgentId,
|
||||
TaskId? BaseTaskId);
|
||||
|
||||
/// <summary>可以修改的字段(merge-patch:为 null 的字段不改)。</summary>
|
||||
public sealed record TaskPatch(IReadOnlyDictionary<string, JsonElement>? Params, string? Schedule, bool? Enabled, string? Target);
|
||||
|
||||
public sealed class TaskService
|
||||
{
|
||||
private readonly ITaskStore _tasks;
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly ISettingsStore _settings;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public TaskService(ITaskStore tasks, IAgentStore agents, ISettingsStore settings, IAgentGateway gateway, TimeProvider time)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_agents = agents;
|
||||
_settings = settings;
|
||||
_gateway = gateway;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<TaskRecord>> ListAsync(CancellationToken ct) => _tasks.ListAsync(ct);
|
||||
|
||||
public ValueTask<TaskRecord?> GetAsync(TaskId id, CancellationToken ct) => _tasks.GetAsync(id, ct);
|
||||
|
||||
/// <summary>首次启动时创建默认的线路任务模板(udp_echo、tcp_echo 持续运行),这样新 Agent 注册后自动获得任务。</summary>
|
||||
public async ValueTask SeedDefaultsAsync(CancellationToken ct)
|
||||
{
|
||||
if (await _settings.GetAsync("tasks.seeded", ct) is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
foreach (KindSpec kind in new[] { Kinds.UdpEcho, Kinds.TcpEcho })
|
||||
{
|
||||
await _tasks.CreateAsync(
|
||||
new TaskRecord(new TaskId(Ulid.NewPrefixed(TaskId.Prefix, _time)), EffectiveTasks.LineScope, null, null, kind.Id, null, new Dictionary<string, JsonElement>(), TaskSchedule.Continuous, true, now, 0),
|
||||
ct);
|
||||
}
|
||||
|
||||
await _settings.SetAsync("tasks.seeded", "1", ct);
|
||||
}
|
||||
|
||||
public async ValueTask<Result<TaskRecord>> CreateAsync(TaskInput input, CancellationToken ct)
|
||||
{
|
||||
Result<ValidatedTask> validated = await ValidateAsync(input.Scope, input.Kind, input.Target, input.Params, input.Schedule ?? TaskSchedule.Continuous, input.AgentId, input.BaseTaskId, ct);
|
||||
if (!validated.TryGet(out ValidatedTask? v, out FleetError? error))
|
||||
{
|
||||
return Result<TaskRecord>.Fail(error);
|
||||
}
|
||||
|
||||
var record = new TaskRecord(
|
||||
new TaskId(Ulid.NewPrefixed(TaskId.Prefix, _time)),
|
||||
input.Scope,
|
||||
input.AgentId,
|
||||
input.BaseTaskId,
|
||||
input.Kind,
|
||||
v.Target,
|
||||
input.Params ?? new Dictionary<string, JsonElement>(),
|
||||
input.Schedule ?? TaskSchedule.Continuous,
|
||||
input.Enabled ?? true,
|
||||
_time.GetUtcNow().ToUnixTimeMilliseconds(),
|
||||
0);
|
||||
|
||||
IReadOnlyList<TaskRecord> after = [.. await _tasks.ListAsync(ct), record];
|
||||
Result<bool> budget = await CheckBudgetAsync(after, record.AgentId, ct);
|
||||
if (!budget.IsSuccess)
|
||||
{
|
||||
return Result<TaskRecord>.Fail(budget.Error);
|
||||
}
|
||||
|
||||
await _tasks.CreateAsync(record, ct);
|
||||
Notify(record);
|
||||
return Result<TaskRecord>.Ok(record);
|
||||
}
|
||||
|
||||
public async ValueTask<Result<TaskRecord>> UpdateAsync(TaskId id, TaskPatch patch, long? expectedRowVersion, CancellationToken ct)
|
||||
{
|
||||
TaskRecord? current = await _tasks.GetAsync(id, ct);
|
||||
if (current is null)
|
||||
{
|
||||
return Result<TaskRecord>.Fail(MonitorErrors.TaskNotFound, "task not found");
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, JsonElement> parameters = patch.Params ?? current.Params;
|
||||
string schedule = patch.Schedule ?? current.Schedule;
|
||||
string? target = patch.Target ?? current.Target;
|
||||
Result<ValidatedTask> validated = await ValidateAsync(current.Scope, current.Kind, target, parameters, schedule, current.AgentId, current.BaseTaskId, ct);
|
||||
if (!validated.TryGet(out ValidatedTask? v, out FleetError? error))
|
||||
{
|
||||
return Result<TaskRecord>.Fail(error);
|
||||
}
|
||||
|
||||
TaskRecord updated = current with
|
||||
{
|
||||
Params = parameters,
|
||||
Schedule = schedule,
|
||||
Target = v.Target,
|
||||
Enabled = patch.Enabled ?? current.Enabled,
|
||||
UpdatedAtMs = _time.GetUtcNow().ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
IReadOnlyList<TaskRecord> after = [.. (await _tasks.ListAsync(ct)).Where(t => t.Id != id), updated];
|
||||
Result<bool> budget = await CheckBudgetAsync(after, updated.AgentId, ct);
|
||||
if (!budget.IsSuccess)
|
||||
{
|
||||
return Result<TaskRecord>.Fail(budget.Error);
|
||||
}
|
||||
|
||||
Result<TaskRecord> saved = await _tasks.UpdateAsync(updated, expectedRowVersion, ct);
|
||||
if (saved.IsSuccess)
|
||||
{
|
||||
Notify(saved.Value);
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
public async ValueTask DeleteAsync(TaskId id, CancellationToken ct)
|
||||
{
|
||||
TaskRecord? current = await _tasks.GetAsync(id, ct);
|
||||
if (current is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _tasks.DeleteAsync(id, ct);
|
||||
Notify(current);
|
||||
}
|
||||
|
||||
private void Notify(TaskRecord task)
|
||||
{
|
||||
if (task.AgentId is { } agent)
|
||||
{
|
||||
_gateway.NotifyTaskSetChanged(agent);
|
||||
}
|
||||
else
|
||||
{
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ValidatedTask(string? Target);
|
||||
|
||||
private async ValueTask<Result<ValidatedTask>> ValidateAsync(
|
||||
string scope,
|
||||
string kind,
|
||||
string? target,
|
||||
IReadOnlyDictionary<string, JsonElement>? parameters,
|
||||
string schedule,
|
||||
NodeId? agentId,
|
||||
TaskId? baseTaskId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (scope is not (EffectiveTasks.LineScope or EffectiveTasks.TargetScope))
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("scope", "must be line or target");
|
||||
}
|
||||
|
||||
KindSpec? spec = Kinds.Find(kind);
|
||||
if (spec is null)
|
||||
{
|
||||
return Result<ValidatedTask>.Fail(MonitorErrors.UnknownKind, $"unknown kind {kind}");
|
||||
}
|
||||
|
||||
Scope wanted = scope == EffectiveTasks.LineScope ? Scope.Line : Scope.Target;
|
||||
if (!spec.Scopes.HasFlag(wanted))
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("kind", $"{kind} cannot be used for {scope} tasks");
|
||||
}
|
||||
|
||||
ParamResolution resolved = ParamResolver.Resolve(spec.Params, parameters);
|
||||
if (!resolved.IsValid)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>(resolved.Errors.ToDictionary(e => "params." + e.Key, e => e.Value));
|
||||
}
|
||||
|
||||
Result<int?> scheduleResult = TaskSchedule.Validate(schedule);
|
||||
if (!scheduleResult.IsSuccess)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("schedule", scheduleResult.Error.Message);
|
||||
}
|
||||
|
||||
if (schedule == TaskSchedule.Once)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("schedule", "once tasks are created through the tests API");
|
||||
}
|
||||
|
||||
if (agentId is { } id && await _agents.GetAsync(id, ct) is null)
|
||||
{
|
||||
return Result<ValidatedTask>.Fail(MonitorErrors.AgentNotFound, "agent not found");
|
||||
}
|
||||
|
||||
if (scope == EffectiveTasks.TargetScope)
|
||||
{
|
||||
if (agentId is null || !TryNormalizeTarget(target, out string? normalized))
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("target", agentId is null ? "agent_id is required for target tasks" : "must be an IP address with an optional port");
|
||||
}
|
||||
|
||||
return Result<ValidatedTask>.Ok(new ValidatedTask(normalized));
|
||||
}
|
||||
|
||||
if (target is not null)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("target", "line tasks have no target");
|
||||
}
|
||||
|
||||
if (baseTaskId is { } baseId)
|
||||
{
|
||||
TaskRecord? template = await _tasks.GetAsync(baseId, ct);
|
||||
if (agentId is null || template is null || template.AgentId is not null || template.Scope != EffectiveTasks.LineScope)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("base_task_id", "must reference a line template and requires agent_id");
|
||||
}
|
||||
|
||||
if (template.Kind != kind)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("kind", "an override must use the same kind as its template");
|
||||
}
|
||||
}
|
||||
else if (agentId is not null)
|
||||
{
|
||||
return ValidationError.Fail<ValidatedTask>("agent_id", "per-agent line tasks must be overrides (base_task_id)");
|
||||
}
|
||||
|
||||
return Result<ValidatedTask>.Ok(new ValidatedTask(null));
|
||||
}
|
||||
|
||||
/// <summary>每条线路持续任务的总带宽上限;对所有受影响的 Agent(模板变更时是所有已激活的 Agent)检查。</summary>
|
||||
private async ValueTask<Result<bool>> CheckBudgetAsync(IReadOnlyList<TaskRecord> after, NodeId? agentId, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
|
||||
IEnumerable<AgentRecord> affected = agentId is { } id
|
||||
? agents.Where(a => a.Id == id)
|
||||
: agents.Where(a => a.Status == NodeStatus.Active);
|
||||
var checkedAny = false;
|
||||
foreach (AgentRecord agent in affected)
|
||||
{
|
||||
checkedAny = true;
|
||||
long bits = EffectiveTasks.ContinuousLineBudget(EffectiveTasks.Compute(after, agent with { Kinds = [] }, filterByAgentKinds: false));
|
||||
if (bits > ProtocolLimits.MaxLineBudgetBitsPerSecond)
|
||||
{
|
||||
return Result<bool>.Fail(MonitorErrors.BudgetExceeded, $"continuous tasks for {agent.Name} would use {bits / 1000} kbps (limit {ProtocolLimits.MaxLineBudgetBitsPerSecond / 1000} kbps)");
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkedAny && agentId is null)
|
||||
{
|
||||
// 还没有 Agent:用一个虚拟 Agent 检查模板本身
|
||||
var virtualAgent = new AgentRecord(new NodeId("agt_virtual"), "virtual", NodeStatus.Active, [], [], "", 0, [], [], true, [], null, null, 0, 0, null, 0, null, 0);
|
||||
long bits = EffectiveTasks.ContinuousLineBudget(EffectiveTasks.Compute(after, virtualAgent, filterByAgentKinds: false));
|
||||
if (bits > ProtocolLimits.MaxLineBudgetBitsPerSecond)
|
||||
{
|
||||
return Result<bool>.Fail(MonitorErrors.BudgetExceeded, $"continuous line templates would use {bits / 1000} kbps (limit {ProtocolLimits.MaxLineBudgetBitsPerSecond / 1000} kbps)");
|
||||
}
|
||||
}
|
||||
|
||||
return Result<bool>.Ok(true);
|
||||
}
|
||||
|
||||
/// <summary>只接受 IP 地址(可带端口),不做 DNS 解析,避免把 Agent 用作解析器。</summary>
|
||||
public static bool TryNormalizeTarget(string? target, out string? normalized)
|
||||
{
|
||||
normalized = null;
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IPAddress.TryParse(target, out IPAddress? ip))
|
||||
{
|
||||
normalized = ip.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint) && endpoint.Port is > 0 and <= 65535)
|
||||
{
|
||||
normalized = endpoint.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user