refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
This commit is contained in:
1 parent
27ac7fb787
commit
2e146d4da7
309 files changed
+1565
-1572
No files matched your search
@@ -0,0 +1,114 @@
|
||||
using System.Text.Json;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public sealed record EffectiveTask(
|
||||
TaskId Id,
|
||||
string Scope,
|
||||
string Kind,
|
||||
string? Target,
|
||||
IReadOnlyDictionary<string, JsonElement> Params,
|
||||
string Schedule,
|
||||
ParamValues Resolved);
|
||||
|
||||
/// <summary>
|
||||
/// 有效任务集:线路任务模板 + 针对单个 Agent 的覆盖 + 该 Agent 的目标任务。
|
||||
/// 参数经 ParamSpec 校验并补全默认值,Backend 和节点使用同一套规则。
|
||||
/// </summary>
|
||||
public static class EffectiveTasks
|
||||
{
|
||||
public const string LineScope = "line";
|
||||
public const string TargetScope = "target";
|
||||
|
||||
public static IReadOnlyList<EffectiveTask> Compute(IReadOnlyList<TaskRecord> all, AgentRecord agent, bool filterByAgentKinds)
|
||||
{
|
||||
var result = new List<EffectiveTask>();
|
||||
var overrides = new Dictionary<TaskId, TaskRecord>();
|
||||
foreach (TaskRecord task in all)
|
||||
{
|
||||
if (task.AgentId == agent.Id && task.BaseTaskId is { } baseId)
|
||||
{
|
||||
overrides[baseId] = task;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (TaskRecord task in all.OrderBy(t => t.Id.Value, StringComparer.Ordinal))
|
||||
{
|
||||
if (task.Scope == LineScope && task.AgentId is null)
|
||||
{
|
||||
// 模板:有覆盖时以覆盖为准(覆盖可以停用或改参数)
|
||||
TaskRecord? over = overrides.GetValueOrDefault(task.Id);
|
||||
if (over is not null)
|
||||
{
|
||||
if (over.Enabled)
|
||||
{
|
||||
Add(result, over.Id, task.Scope, over.Kind, null, Merge(task.Params, over.Params), over.Schedule, agent, filterByAgentKinds);
|
||||
}
|
||||
}
|
||||
else if (task.Enabled)
|
||||
{
|
||||
Add(result, task.Id, task.Scope, task.Kind, null, task.Params, task.Schedule, agent, filterByAgentKinds);
|
||||
}
|
||||
}
|
||||
else if (task.Scope == TargetScope && task.AgentId == agent.Id && task.Enabled)
|
||||
{
|
||||
Add(result, task.Id, task.Scope, task.Kind, task.Target, task.Params, task.Schedule, agent, filterByAgentKinds);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, JsonElement> Merge(IReadOnlyDictionary<string, JsonElement> baseParams, IReadOnlyDictionary<string, JsonElement> over)
|
||||
{
|
||||
var merged = new Dictionary<string, JsonElement>(baseParams);
|
||||
foreach ((string key, JsonElement value) in over)
|
||||
{
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static void Add(
|
||||
List<EffectiveTask> result,
|
||||
TaskId id,
|
||||
string scope,
|
||||
string kind,
|
||||
string? target,
|
||||
IReadOnlyDictionary<string, JsonElement> parameters,
|
||||
string schedule,
|
||||
AgentRecord agent,
|
||||
bool filterByAgentKinds)
|
||||
{
|
||||
KindSpec? spec = Kinds.Find(kind);
|
||||
if (spec is null || (filterByAgentKinds && agent.Kinds.Count > 0 && !agent.Kinds.Contains(kind)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ParamResolution resolved = ParamResolver.Resolve(spec.Params, parameters);
|
||||
if (!resolved.IsValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
result.Add(new EffectiveTask(id, scope, kind, target, resolved.Values!.Values, schedule, resolved.Values));
|
||||
}
|
||||
|
||||
/// <summary>该 Agent 持续线路任务的总带宽(bit/s)。</summary>
|
||||
public static long ContinuousLineBudget(IReadOnlyList<EffectiveTask> tasks)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (EffectiveTask task in tasks)
|
||||
{
|
||||
if (task.Scope == LineScope && task.Schedule == TaskSchedule.Continuous)
|
||||
{
|
||||
total += Kinds.Find(task.Kind)!.BudgetBitsPerSecond(task.Resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public sealed record DesiredTaskSet(long Version, TaskSet Document);
|
||||
|
||||
/// <summary>
|
||||
/// 为每个 Agent 计算期望状态(<see cref="TaskSet"/>):有效任务集 + 允许连接的 Probe 公钥。
|
||||
/// 只有内容真的变了才递增版本号,所以断线重连后 Agent 收到的永远是当前版本的完整快照。
|
||||
/// </summary>
|
||||
public sealed class TaskPlanner : IDisposable
|
||||
{
|
||||
public const string ProbeAgentsVersionKey = "probe_agents_version";
|
||||
|
||||
private readonly ITaskStore _tasks;
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly IProbeStore _probes;
|
||||
private readonly ISettingsStore _settings;
|
||||
private readonly LinkService _links;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links)
|
||||
{
|
||||
_links = links;
|
||||
_tasks = tasks;
|
||||
_agents = agents;
|
||||
_probes = probes;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>pending、disabled 或不存在的 Agent 返回 null(不下发状态)。</summary>
|
||||
public async ValueTask<DesiredTaskSet?> GetDesiredAsync(NodeId agentId, CancellationToken ct)
|
||||
{
|
||||
AgentRecord? agent = await _agents.GetAsync(agentId, ct);
|
||||
if (agent is null || agent.Status != NodeStatus.Active)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<TaskRecord> all = await _tasks.ListAsync(ct);
|
||||
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
|
||||
LinkSnapshot links = await _links.SnapshotAsync(ct);
|
||||
|
||||
var document = new TaskSet();
|
||||
foreach (EffectiveTask task in EffectiveTasks.Compute(all, agent, filterByAgentKinds: true))
|
||||
{
|
||||
document.Tasks.Add(new TaskSpec
|
||||
{
|
||||
Id = task.Id,
|
||||
Scope = task.Scope,
|
||||
Kind = task.Kind,
|
||||
Target = task.Target,
|
||||
Params = new Dictionary<string, JsonElement>(task.Params),
|
||||
Schedule = task.Schedule,
|
||||
Enabled = true,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && links.IsLinked(p.Id, agentId.Value)))
|
||||
{
|
||||
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PublicKey) });
|
||||
}
|
||||
|
||||
string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MoShiroJsonContext.Default.TaskSet)));
|
||||
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// 重新读取,避免并发的两次计算各自递增
|
||||
AgentRecord current = await _agents.GetAsync(agentId, ct) ?? agent;
|
||||
string? storedHash = await _agents.GetTaskHashAsync(agentId, ct);
|
||||
long version = current.TaskVersion;
|
||||
if (storedHash != hash)
|
||||
{
|
||||
version++;
|
||||
await _agents.SetTaskVersionAsync(agentId, version, hash, ct);
|
||||
}
|
||||
|
||||
document.Version = version;
|
||||
return new DesiredTaskSet(version, document);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _gate.Dispose();
|
||||
|
||||
public ValueTask<long> BumpProbeAgentsVersionAsync(CancellationToken ct) => _settings.IncrementAsync(ProbeAgentsVersionKey, ct);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.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