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? Params, string? Schedule, bool? Enabled, NodeId? AgentId, TaskId? BaseTaskId); /// 可以修改的字段(merge-patch:为 null 的字段不改)。 public sealed record TaskPatch(IReadOnlyDictionary? 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> ListAsync(CancellationToken ct) => _tasks.ListAsync(ct); public ValueTask GetAsync(TaskId id, CancellationToken ct) => _tasks.GetAsync(id, ct); /// 首次启动时创建默认的线路任务模板(udp_echo、tcp_echo 持续运行),这样新 Agent 注册后自动获得任务。 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(), TaskSchedule.Continuous, true, now, 0), ct); } await _settings.SetAsync("tasks.seeded", "1", ct); } public async ValueTask> CreateAsync(TaskInput input, CancellationToken ct) { Result 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.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(), input.Schedule ?? TaskSchedule.Continuous, input.Enabled ?? true, _time.GetUtcNow().ToUnixTimeMilliseconds(), 0); IReadOnlyList after = [.. await _tasks.ListAsync(ct), record]; Result budget = await CheckBudgetAsync(after, record.AgentId, ct); if (!budget.IsSuccess) { return Result.Fail(budget.Error); } await _tasks.CreateAsync(record, ct); Notify(record); return Result.Ok(record); } public async ValueTask> UpdateAsync(TaskId id, TaskPatch patch, long? expectedRowVersion, CancellationToken ct) { TaskRecord? current = await _tasks.GetAsync(id, ct); if (current is null) { return Result.Fail(MonitorErrors.TaskNotFound, "task not found"); } IReadOnlyDictionary parameters = patch.Params ?? current.Params; string schedule = patch.Schedule ?? current.Schedule; string? target = patch.Target ?? current.Target; Result 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.Fail(error); } TaskRecord updated = current with { Params = parameters, Schedule = schedule, Target = v.Target, Enabled = patch.Enabled ?? current.Enabled, UpdatedAtMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), }; IReadOnlyList after = [.. (await _tasks.ListAsync(ct)).Where(t => t.Id != id), updated]; Result budget = await CheckBudgetAsync(after, updated.AgentId, ct); if (!budget.IsSuccess) { return Result.Fail(budget.Error); } Result 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> ValidateAsync( string scope, string kind, string? target, IReadOnlyDictionary? parameters, string schedule, NodeId? agentId, TaskId? baseTaskId, CancellationToken ct) { if (scope is not (EffectiveTasks.LineScope or EffectiveTasks.TargetScope)) { return ValidationError.Fail("scope", "must be line or target"); } KindSpec? spec = Kinds.Find(kind); if (spec is null) { return Result.Fail(MonitorErrors.UnknownKind, $"unknown kind {kind}"); } Scope wanted = scope == EffectiveTasks.LineScope ? Scope.Line : Scope.Target; if (!spec.Scopes.HasFlag(wanted)) { return ValidationError.Fail("kind", $"{kind} cannot be used for {scope} tasks"); } ParamResolution resolved = ParamResolver.Resolve(spec.Params, parameters); if (!resolved.IsValid) { return ValidationError.Fail(resolved.Errors.ToDictionary(e => "params." + e.Key, e => e.Value)); } Result scheduleResult = TaskSchedule.Validate(schedule); if (!scheduleResult.IsSuccess) { return ValidationError.Fail("schedule", scheduleResult.Error.Message); } if (schedule == TaskSchedule.Once) { return ValidationError.Fail("schedule", "once tasks are created through the tests API"); } if (agentId is { } id && await _agents.GetAsync(id, ct) is null) { return Result.Fail(MonitorErrors.AgentNotFound, "agent not found"); } if (scope == EffectiveTasks.TargetScope) { if (agentId is null || !TryNormalizeTarget(target, out string? normalized)) { return ValidationError.Fail("target", agentId is null ? "agent_id is required for target tasks" : "must be an IP address with an optional port"); } return Result.Ok(new ValidatedTask(normalized)); } if (target is not null) { return ValidationError.Fail("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("base_task_id", "must reference a line template and requires agent_id"); } if (template.Kind != kind) { return ValidationError.Fail("kind", "an override must use the same kind as its template"); } } else if (agentId is not null) { return ValidationError.Fail("agent_id", "per-agent line tasks must be overrides (base_task_id)"); } return Result.Ok(new ValidatedTask(null)); } /// 每条线路持续任务的总带宽上限;对所有受影响的 Agent(模板变更时是所有已激活的 Agent)检查。 private async ValueTask> CheckBudgetAsync(IReadOnlyList after, NodeId? agentId, CancellationToken ct) { IReadOnlyList agents = await _agents.ListAsync(ct); IEnumerable 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.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.Fail(MonitorErrors.BudgetExceeded, $"continuous line templates would use {bits / 1000} kbps (limit {ProtocolLimits.MaxLineBudgetBitsPerSecond / 1000} kbps)"); } } return Result.Ok(true); } /// 只接受 IP 地址(可带端口),不做 DNS 解析,避免把 Agent 用作解析器。 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; } }