304 lines
11 KiB
C#
304 lines
11 KiB
C#
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;
|
|
}
|
|
}
|