using System.Text.Json; using Fleet.Core; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using MoShiro.Backend.Application; using MoShiro.Protocol; namespace MoShiro.Backend.Api; /// 构建信息与更新:后端和每个节点的版本、tag、构建时间,CI 签名的发布清单,以及「更新这个节点」的请求。更新本身由节点宿主机上的更新器验签并执行。 public static class BuildEndpoints { private const string Tag = "admin-build"; public static void Map(RouteGroupBuilder group) { group.MapGet("/build-info", GetAsync) .Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag) .Returns().ProducesProblems(401, 403); group.MapGet("/releases", ListReleasesAsync) .Api("releases_list", "The latest signed release manifest of each component", Tag).Returns>().ProducesProblems(401, 403); group.MapPost("/nodes/{id}/update", RequestUpdateAsync) .Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409); group.MapGet("/backend/update", BackendUpdateAsync) .Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns().ProducesProblems(401, 403); group.MapPost("/backend/update", ConfirmBackendUpdateAsync) .Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412); group.MapGet("/backend/update/live", LiveBackendUpdateAsync) .Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403); } /// 登记发布清单:挂在 下,除了管理员,CI 用的 release 令牌也能调用(它只能做这一件事)。 public static void MapPublish(RouteGroupBuilder group) { group.MapPost("/releases", PublishReleaseAsync) .Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag) .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); } private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct) { var items = (await nodes.ListAsync(ct)) .OrderBy(n => n.Kind, StringComparer.Ordinal) .ThenBy(n => n.NodeId, StringComparer.Ordinal) .Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))))) .ToList(); http.Response.Headers.CacheControl = "no-store"; return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto); } private static async Task ListReleasesAsync(ReleaseService releases, CancellationToken ct) => TypedResults.Json(new PageDto([.. (await releases.ListAsync(ct)).Select(ToDto)], null), ApiJsonContext.Default.PageDtoReleaseDto); private static async Task PublishReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request) { Result result = await releases.PublishAsync(request.Manifest ?? "", request.Signature ?? "", http.RequestAborted); return result.TryGet(out ReleaseOffer? offer, out FleetError? error) ? TypedResults.Json(ToDto(offer), ApiJsonContext.Default.ReleaseDto, statusCode: StatusCodes.Status201Created) : Problems.From(http, error); } private static async Task RequestUpdateAsync(HttpContext http, ReleaseService releases, string id, CancellationToken ct) => await releases.RequestUpdateAsync(new NodeId(id), ct) ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.AgentOffline, "the node is not connected"); private static ReleaseDto ToDto(ReleaseOffer offer) => new(offer.Component, offer.Seq, offer.Tag, offer.Version, offer.Digest, offer.Channel); private static BackendUpdateDto ToDto(BackendUpdateView view) => new(view.Channel, view.Enabled, view.State, view.CurrentTag, view.CurrentDigest, view.Available is { } release ? ToDto(release) : null, view.Error); private static async Task BackendUpdateAsync(BackendUpdates updates, CancellationToken ct) => TypedResults.Json(ToDto(await updates.GetAsync(ct)), ApiJsonContext.Default.BackendUpdateDto); private static async Task ConfirmBackendUpdateAsync(HttpContext http, BackendUpdates updates, ConfirmBackendUpdateRequest request, CancellationToken ct) { Result result = await updates.ConfirmAsync(request.Seq, ct); return result.IsSuccess ? TypedResults.NoContent() : Problems.From(http, result.Error); } private static async Task LiveBackendUpdateAsync(HttpContext http, BackendUpdates updates, TimeProvider time, CancellationToken ct) { http.Response.ContentType = "text/event-stream"; http.Response.Headers.CacheControl = "no-store"; http.Response.Headers["X-Accel-Buffering"] = "no"; using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2), time); string? previous = null; try { do { string json = JsonSerializer.Serialize(ToDto(await updates.GetAsync(ct)), ApiJsonContext.Default.BackendUpdateDto); await http.Response.WriteAsync(json != previous ? "event: backend_update\ndata: " + json + "\n\n" : ": heartbeat\n\n", ct); await http.Response.Body.FlushAsync(ct); previous = json; } while (await timer.WaitForNextTickAsync(ct)); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { // 浏览器断开或后端正在升级重启 } } private static NodeUpdateDto? ToDto(UpdateStatus? status) => status is null ? null : new NodeUpdateDto(status.State, status.Current, status.Available?.Tag, status.Available?.Version, status.Error, status.At > 0 ? DateTimeOffset.FromUnixTimeSeconds(status.At) : null); }