diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 37c485b..92d1667 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -1140,6 +1140,116 @@ } } }, + "/api/admin/releases": { + "post": { + "tags": [ + "admin-build" + ], + "summary": "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", + "operationId": "releases_publish", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishReleaseRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "get": { + "tags": [ + "admin-build" + ], + "summary": "The latest signed release manifest of each component", + "operationId": "releases_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfReleaseDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/api/admin/auth/logout": { "post": { "tags": [ @@ -3732,116 +3842,6 @@ } } }, - "/api/admin/releases": { - "get": { - "tags": [ - "admin-build" - ], - "summary": "The latest signed release manifest of each component", - "operationId": "releases_list", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PageDtoOfReleaseDto" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - } - } - }, - "post": { - "tags": [ - "admin-build" - ], - "summary": "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature)", - "operationId": "releases_publish", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublishReleaseRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReleaseDto" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - } - } - } - }, "/api/admin/nodes/{id}/update": { "post": { "tags": [ @@ -7421,6 +7421,9 @@ { "name": "admin-auth" }, + { + "name": "admin-build" + }, { "name": "admin-agents" }, @@ -7448,9 +7451,6 @@ { "name": "admin-users" }, - { - "name": "admin-build" - }, { "name": "ops" }, diff --git a/backend/src/Monitor.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/BuildEndpoints.cs index 3b0058e..c85ef3f 100644 --- a/backend/src/Monitor.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/Monitor.Backend.Api/Admin/BuildEndpoints.cs @@ -19,13 +19,18 @@ public static class BuildEndpoints .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("/releases", PublishReleaseAsync) - .Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature)", Tag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); 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); } + /// 登记发布清单:挂在 下,除了管理员,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, CancellationToken ct) { var items = (await nodes.ListAsync(ct)) diff --git a/backend/src/Monitor.Backend.Api/ApiModule.cs b/backend/src/Monitor.Backend.Api/ApiModule.cs index cf57733..8ced47e 100644 --- a/backend/src/Monitor.Backend.Api/ApiModule.cs +++ b/backend/src/Monitor.Backend.Api/ApiModule.cs @@ -103,6 +103,8 @@ public static class ApiModule RouteGroupBuilder admin = app.MapGroup("/api/admin"); AuthEndpoints.MapLogin(admin); + BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(ReleaseGuard.InvokeAsync)); + RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync); AuthEndpoints.MapSession(guarded); AgentEndpoints.Map(guarded); diff --git a/backend/src/Monitor.Backend.Api/Common/Guards.cs b/backend/src/Monitor.Backend.Api/Common/Guards.cs index 4167689..9e865c5 100644 --- a/backend/src/Monitor.Backend.Api/Common/Guards.cs +++ b/backend/src/Monitor.Backend.Api/Common/Guards.cs @@ -62,7 +62,7 @@ public static class AdminGuard Principal principal = http.GetPrincipal(); if (!principal.IsAdmin) { - return principal.CanRead + return principal.Scope != ApiScope.None ? Problems.Create(http, MonitorErrors.Forbidden, "the token scope does not allow this operation") : Problems.Create(http, MonitorErrors.Unauthorized, "authentication is required"); } @@ -80,6 +80,13 @@ public static class AdminGuard } } +/// 登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。 +public static class ReleaseGuard +{ + public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) => + context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next); +} + /// 展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。 public static class PublicGuard { diff --git a/backend/src/Monitor.Backend.Application/Auth/AuthModels.cs b/backend/src/Monitor.Backend.Application/Auth/AuthModels.cs index e157b4c..9caf89d 100644 --- a/backend/src/Monitor.Backend.Application/Auth/AuthModels.cs +++ b/backend/src/Monitor.Backend.Application/Auth/AuthModels.cs @@ -7,12 +7,13 @@ public enum PrincipalKind Token, } -/// 访问级别:作用域为 read 的令牌只读,admin 可以管理。 +/// 访问级别:作用域为 read 的令牌只读,admin 可以管理,release 只能登记发布清单(给 CI 用,什么都读不了)。 public enum ApiScope { None = 0, - Read = 1, - Admin = 2, + Release = 1, + Read = 2, + Admin = 3, } public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserId, string? Username, string? CsrfToken, string? TokenId) @@ -22,6 +23,8 @@ public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserI public bool CanRead => Scope >= ApiScope.Read; public bool IsAdmin => Scope >= ApiScope.Admin; + + public bool CanPublishRelease => Scope is ApiScope.Release or ApiScope.Admin; } public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username); diff --git a/backend/src/Monitor.Backend.Application/Auth/AuthService.cs b/backend/src/Monitor.Backend.Application/Auth/AuthService.cs index 18780c9..81e4086 100644 --- a/backend/src/Monitor.Backend.Application/Auth/AuthService.cs +++ b/backend/src/Monitor.Backend.Application/Auth/AuthService.cs @@ -77,7 +77,7 @@ public sealed partial class AuthService await _store.TouchApiTokenAsync(token.Id, now, ct); } - ApiScope scope = token.Scope == "admin" ? ApiScope.Admin : ApiScope.Read; + ApiScope scope = token.Scope switch { "admin" => ApiScope.Admin, "release" => ApiScope.Release, _ => ApiScope.Read }; return new Principal(PrincipalKind.Token, scope, null, token.Name, null, token.Id); } @@ -135,9 +135,9 @@ public sealed partial class AuthService return ValidationError.Fail("name", "must be 1-64 characters"); } - if (scope is not ("read" or "admin")) + if (scope is not ("read" or "admin" or "release")) { - return ValidationError.Fail("scope", "must be read or admin"); + return ValidationError.Fail("scope", "must be read, admin or release"); } string plaintext = TokenHash.NewToken("mon"); diff --git a/backend/tests/Monitor.Backend.Tests/UpdateTests.cs b/backend/tests/Monitor.Backend.Tests/UpdateTests.cs index c889a0d..caf8443 100644 --- a/backend/tests/Monitor.Backend.Tests/UpdateTests.cs +++ b/backend/tests/Monitor.Backend.Tests/UpdateTests.cs @@ -67,6 +67,40 @@ public sealed class UpdateTests Assert.Contains(anonymous.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); } + [Fact] + public async Task ReleaseToken_CanOnlyRegisterReleases_AndAReadTokenCannotEvenDoThat() + { + async Task NewTokenAsync(string scope) + { + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["scope"] = scope })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); + } + + HttpClient Client(string token) + { + var client = new HttpClient { BaseAddress = _backend.BaseAddress }; + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + return client; + } + + long seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000 + 50; + using HttpClient release = Client(await NewTokenAsync("release")); + using HttpClient read = Client(await NewTokenAsync("read")); + var body = BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" }); + + using HttpResponseMessage forbidden = await read.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" })); + Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode); + using HttpResponseMessage published = await release.PostAsync("/api/admin/releases", body); + Assert.Equal(HttpStatusCode.Created, published.StatusCode); + + // release 令牌只能登记发布清单:读不了任何东西,也不能做别的管理操作 + Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/releases")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await release.PostAsync("/api/admin/nodes/agt_x/update", null)).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq + 1), ["signature"] = "MEUC" }))).StatusCode); + } + [Fact] public async Task UpdateRequest_ReachesTheNode_AndItsReportedStatusShowsInBuildInfo() { diff --git a/ci/build-main-amd64.sh b/ci/build-main-amd64.sh index 4222eef..68b1b1d 100644 --- a/ci/build-main-amd64.sh +++ b/ci/build-main-amd64.sh @@ -49,6 +49,7 @@ local_image="localhost/monitor-$component:main-amd64-check" python3 "$root/ci/stage-node-image.py" "$component" "$context" buildah bud --layers --network host --tls-verify=true --arch amd64 \ --build-arg APP_VERSION=main \ + --build-arg "MONITOR_TAG=$dev_tag" --build-arg "MONITOR_COMMIT=${CI_COMMIT_SHA:0:10}" --build-arg "MONITOR_BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ -f "$context/Containerfile" \ -t "$local_image" "$context" diff --git a/ci/release-cross-platform.ps1 b/ci/release-cross-platform.ps1 index 22e5c02..d048fd0 100644 --- a/ci/release-cross-platform.ps1 +++ b/ci/release-cross-platform.ps1 @@ -51,6 +51,11 @@ $output = Join-Path $root ".release\publish\$RuntimeIdentifier\$Component" $buildRoot = Join-Path $root ".release\build\$RuntimeIdentifier-$Component" $null = New-Item -ItemType Directory -Force -Path $output +# 构建信息:程序里显示的 tag、提交、构建时间 +$env:MONITOR_TAG = if ($env:CI_COMMIT_TAG) { $env:CI_COMMIT_TAG } else { "main-$($env:CI_COMMIT_SHA.Substring(0, 10))" } +$env:MONITOR_COMMIT = $env:CI_COMMIT_SHA.Substring(0, 10) +$env:MONITOR_BUILT_AT = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + Push-Location $backend try { & dotnet publish $project ` diff --git a/ci/release-linux.sh b/ci/release-linux.sh index 9843aeb..1d56bff 100644 --- a/ci/release-linux.sh +++ b/ci/release-linux.sh @@ -45,6 +45,11 @@ build_root="$root/.release/build/linux-$arch-backend" container_context="$root/.release/container/linux-$arch" mkdir -p "$output" "$build_root" "$container_context/app" "$container_context/data" +# 构建信息:tag 发布用 tag,main 构建用 main-<提交> +export MONITOR_TAG="${MONITOR_TAG:-${tag:-main-${CI_COMMIT_SHA:0:10}}}" +export MONITOR_COMMIT="${MONITOR_COMMIT:-${CI_COMMIT_SHA:0:10}}" +export MONITOR_BUILT_AT="${MONITOR_BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" + (cd "$root/backend" && dotnet publish src/Monitor.Backend \ -m:3 -c Release --no-self-contained \ -p:DebugType=none -p:DebugSymbols=false \ diff --git a/docs/api.md b/docs/api.md index 0418e4c..e39dcb0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -130,7 +130,7 @@ | GET | `/api/admin/raw/chunks/{chunk_id}` | 下载原始块(`application/vnd.monitor.rawchunk`);过期返回 410 `raw_expired` | | GET | `/api/admin/events?agent_id=&kind=&from=&to=` | 事件 | | GET | `/api/admin/analysis/recompute` | 指标重算进度 | -| GET/POST/DELETE | `/api/admin/api-tokens` | 作用域为 `read` 或 `admin`;明文只显示一次 | +| GET/POST/DELETE | `/api/admin/api-tokens` | 作用域为 `read`、`admin` 或 `release`(只能登记发布清单,给 CI 用,什么都读不了);明文只显示一次 | | GET | `/api/admin/releases` | 各应用、各通道的当前版本,以及节点的版本分布(由 `Fleet.Server.Releases` 提供) | | POST | `/api/admin/releases/{app}/{channel}/rollout` | `{version, batches:[{labels?, count?}], soak_minutes}`:分批灰度;只能选择已经签名的清单 | | POST | `/api/admin/releases/{app}/approve` | 对 `policy = notify` 的节点批准更新 `{version, agents?}` | diff --git a/docs/deployment.md b/docs/deployment.md index 48e0897..fc50d6b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -214,7 +214,7 @@ CI 构建并推镜像(Zot)──► ci/release-manifest.sh:对镜像 diges - **安装更新器**(每个容器节点一次,root):`packaging/update/install-updater.sh agent --data-dir /opt/monitor/etc --image-repo registry.oeo.one/monitor/monitor-agent --unit monitor-agent-container.service [--policy auto] [--authfile ]`。它装好 `monitor-update` 脚本和发布公钥,写 `/etc/monitor-update/<组件>.conf`,启用两个 systemd 路径单元。容器单元要用 `EnvironmentFile=/etc/monitor-update/<组件>.image` 和 `${IMAGE}` 启动容器。 - **镜像仓库、服务名、公钥都在本机配置里**,清单里写什么都不会改变拉取哪个仓库;清单被篡改、签名对不上、组件或仓库不符、`seq` 不比已安装的大(重放旧清单),都会被拒绝,状态是 `invalid`。 - **业务进程不能改二进制,也不执行命令**:它只能往 `update/` 里放清单和请求;状态在 root 拥有的 `update-status/` 里(只读)。 -- **CI 的配置**:Woodpecker 密钥 `release_signing_key`(发布私钥,PEM)、`release_api_token`(面板的 API 令牌,管理员权限,用来登记清单);环境变量 `RELEASE_API_URL`(面板地址)。没有 `release_signing_key` 就只构建镜像、不发清单。 +- **CI 的配置**:Woodpecker 密钥 `release_signing_key`(发布私钥,PEM)、`release_api_token`(面板的 API 令牌,作用域 `release`,只能登记清单);环境变量 `RELEASE_API_URL`(面板地址)。没有 `release_signing_key` 就只构建镜像、不发清单。 - **发布公钥**在 `packaging/update/release-key.pub.pem`;私钥不入库,请离线备份。更换密钥:节点上换新公钥之后再用新私钥发布。 - 纯二进制的安装方式(`fleet-updater`、`fleet-release`,见 [fleet/update.md](fleet/update.md))还没有实现;容器方式优先。 diff --git a/packaging/container/Agent.NativeBuild.Containerfile b/packaging/container/Agent.NativeBuild.Containerfile index d5967f9..c26898a 100644 --- a/packaging/container/Agent.NativeBuild.Containerfile +++ b/packaging/container/Agent.NativeBuild.Containerfile @@ -1,17 +1,17 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build WORKDIR /src/backend -# 构建信息:CI 通过 --build-arg 传入;不传就是空 -ARG MONITOR_TAG= -ARG MONITOR_COMMIT= -ARG MONITOR_BUILT_AT= -ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT COPY restore/backend/ ./ # restore 也要带 PublishAot:不然不会还原 ILCompiler,之后 --no-restore 的发布就悄悄变成「裁剪过的框架依赖程序」 RUN dotnet restore agent/src/Monitor.Agent -r linux-x64 -p:PublishAot=true --artifacts-path /artifacts \ && dotnet restore fleet/tools/fleet-enroll -r linux-x64 -p:PublishAot=true --artifacts-path /artifacts COPY source/backend/ ./ +# 构建信息:CI 通过 --build-arg 传入;不传就是空(放在 restore 之后,每次构建都变的时间不会让依赖还原的缓存失效) +ARG MONITOR_TAG= +ARG MONITOR_COMMIT= +ARG MONITOR_BUILT_AT= +ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT RUN dotnet publish agent/src/Monitor.Agent -m:3 -c Release -r linux-x64 \ --self-contained true --no-restore -p:PublishAot=true -p:UseAotAnywhere=false \ -p:UseAppHost=true -p:StripSymbols=true \ diff --git a/packaging/container/Probe.NativeBuild.Containerfile b/packaging/container/Probe.NativeBuild.Containerfile index 5bd3592..d5df013 100644 --- a/packaging/container/Probe.NativeBuild.Containerfile +++ b/packaging/container/Probe.NativeBuild.Containerfile @@ -1,16 +1,16 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build WORKDIR /src/backend -# 构建信息:CI 通过 --build-arg 传入;不传就是空 -ARG MONITOR_TAG= -ARG MONITOR_COMMIT= -ARG MONITOR_BUILT_AT= -ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT COPY restore/backend/ ./ # restore 也要带 PublishAot:不然不会还原 ILCompiler,之后 --no-restore 的发布就悄悄变成「裁剪过的框架依赖程序」 RUN dotnet restore probe/src/Monitor.Probe -r linux-x64 -p:PublishAot=true --artifacts-path /artifacts COPY source/backend/ ./ +# 构建信息:CI 通过 --build-arg 传入;不传就是空(放在 restore 之后,每次构建都变的时间不会让依赖还原的缓存失效) +ARG MONITOR_TAG= +ARG MONITOR_COMMIT= +ARG MONITOR_BUILT_AT= +ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT RUN dotnet publish probe/src/Monitor.Probe -m:3 -c Release -r linux-x64 \ --self-contained true --no-restore -p:PublishAot=true -p:UseAotAnywhere=false \ -p:UseAppHost=true -p:StripSymbols=true \ diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index 875ea66..2ae8bb0 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,7 +1,7 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' import { useEffect, useState } from 'react' import { api } from '@shared/api/client' -import type { AgentDto, ApiTokenDto, BuildInfoDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo' import { fmtNum, fmtTime } from '@shared/format' import { translateSetting } from '@shared/i18n' @@ -95,8 +95,36 @@ function BuildRow({ name, kind, version, tag, commit, builtAt, platform }: { nam ) } -/** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的(它们没升级就能看出来)。 */ +/** 节点宿主机上的更新器报告的状态;没装更新器(二进制安装、旧版本)的节点没有。 */ +const UPDATE_STATE: Record = { + up_to_date: { text: '已是最新', tone: 'ok' }, + available: { text: '有新版本', tone: 'warn' }, + applying: { text: '更新中…', tone: 'warn' }, + failed: { text: '更新失败', tone: 'bad' }, + rolled_back: { text: '已回滚', tone: 'bad' }, + invalid: { text: '清单无效', tone: 'bad' }, +} + +function UpdateCell({ update, busy, onUpdate }: { update?: NodeUpdateDto | null; busy: boolean; onUpdate: () => void }) { + if (!update) return — + const s = UPDATE_STATE[update.state] ?? { text: update.state, tone: '' as const } + return ( + + {s.text} + {update.state === 'available' ? {update.available_tag} : null} + {update.state === 'available' ? ( + + ) : null} + + ) +} + +/** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的,以及 CI 发布的最新签名清单和各节点的更新状态。 */ function Versions() { + const { busy, run } = useAction() + const releases = useQuery(() => api.get>('/api/admin/releases'), [], 30_000) const info = useQuery(() => api.get('/api/admin/build-info'), [], 15_000) const agents = useQuery(() => api.get>('/api/admin/agents'), [], 30_000) const probes = useQuery(() => api.get>('/api/admin/probes'), [], 30_000) @@ -105,9 +133,33 @@ function Versions() { const nodes = info.data?.nodes ?? [] // 以后端的版本为准,节点的版本不一样就标出来(升级了一半的时候一眼能看到) const mismatch = (v?: string | null) => Boolean(v && back && v !== back.version) + const updatable = nodes.filter((n) => n.update?.state === 'available') + const requestUpdate = async (ids: string[]) => { + for (const id of ids) { + await run(() => api.post(`/api/admin/nodes/${id}/update`), '已请求更新') + } + info.reload() + } return (
- + +
+ + 最新发布: + {(releases.data?.items ?? []).length === 0 + ? '还没有(CI 构建后会自动登记)' + : (releases.data?.items ?? []).map((r) => ( + + {r.component} {r.tag} + + ))} + + {updatable.length > 0 ? ( + + ) : null} +
@@ -118,6 +170,7 @@ function Versions() { + @@ -136,6 +189,9 @@ function Versions() { + ))} @@ -143,6 +199,7 @@ function Versions() { {nodes.length === 0 && !info.loading ? 还没有节点上报过版本 : null}

版本、tag 和构建时间是各个程序自己上报的。设置里打开「site.show_build」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。 + 「更新」一列来自节点宿主机上的更新器:清单是 CI 签名的,面板只负责通知和请求,验签和拉取都在节点上完成;没装更新器的节点这一列是「—」。

) @@ -153,7 +210,7 @@ function Tokens() { const { run } = useAction() const [creating, setCreating] = useState(false) const [name, setName] = useState('') - const [scope, setScope] = useState<'read' | 'admin'>('read') + const [scope, setScope] = useState<'read' | 'admin' | 'release'>('read') const [created, setCreated] = useState() const [revoking, setRevoking] = useState() return ( @@ -237,7 +294,7 @@ function Tokens() { setName(e.target.value)} autoFocus /> - + diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 76982c1..eb8765f 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -310,6 +310,24 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/releases": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The latest signed release manifest of each component */ + get: operations["releases_list"]; + put?: never; + /** Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens */ + post: operations["releases_publish"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/auth/logout": { parameters: { query?: never; @@ -806,7 +824,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Version, tag, commit and build time of the backend and every agent and probe */ + /** Version, tag, commit, build time and update state of the backend and every agent and probe */ get: operations["build_info_get"]; put?: never; post?: never; @@ -816,6 +834,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/nodes/{id}/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Ask an online agent or probe to install the release it has been offered */ + post: operations["nodes_update"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/healthz": { parameters: { query?: never; @@ -1277,6 +1312,7 @@ export interface components { tag: null | string; commit: null | string; built_at: null | string; + update?: null | components["schemas"]["NodeUpdateDto"]; }; NodeInfoDto: { id: string; @@ -1290,6 +1326,15 @@ export interface components { version?: null | string; tag?: null | string; }; + NodeUpdateDto: { + state: string; + current_digest: null | string; + available_tag: null | string; + available_version: null | string; + error: null | string; + /** Format: date-time */ + at: unknown; + }; OverviewDto: { range: string; /** Format: date-time */ @@ -1367,6 +1412,10 @@ export interface components { items: components["schemas"]["RawChunkMetaDto"][]; next_cursor: null | string; }; + PageDtoOfReleaseDto: { + items: components["schemas"]["ReleaseDto"][]; + next_cursor: null | string; + }; PageDtoOfSettingDto: { items: components["schemas"]["SettingDto"][]; next_cursor: null | string; @@ -1465,6 +1514,10 @@ export interface components { [key: string]: string[]; }; }; + PublishReleaseRequest: { + manifest: null | string; + signature: null | string; + }; RawChunkMetaDto: { chunk_id: string; agent_id: string; @@ -1500,6 +1553,14 @@ export interface components { id: string; reason: string; }; + ReleaseDto: { + component: string; + /** Format: int64 */ + seq: number | string; + tag: string; + version: string; + digest: string; + }; ScoreDto: { /** Format: double */ score: null | number | string; @@ -2473,6 +2534,104 @@ export interface operations { }; }; }; + releases_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfReleaseDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + releases_publish: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PublishReleaseRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReleaseDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; auth_logout: { parameters: { query?: never; @@ -4552,6 +4711,55 @@ export interface operations { }; }; }; + nodes_update: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; health_get: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 97e91f1..f678e3c 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -17,6 +17,8 @@ export type AboutDto = Narrow export type BuildDto = Narrow export type BuildInfoDto = Narrow export type NodeBuildDto = Narrow +export type NodeUpdateDto = Narrow +export type ReleaseDto = Narrow export type OverviewDto = Narrow export type OverviewLineDto = Narrow export type SeriesDto = Narrow
提交 构建时间 系统更新
{n.commit || —} {fmtBuiltAt(n.built_at) || —} {[n.os_name, n.arch].filter(Boolean).join(' / ') || —} + requestUpdate([n.id])} /> +