Files
MoShiro/backend/src/Monitor.Backend.Application/Tasks/TaskService.cs
T
2026-10-04 21:46:39 +08:00

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;
}
}