diff --git a/.woodpecker/main-amd64-build.yml b/.woodpecker/main-amd64-build.yml
index 51a2fbd..1b1beb9 100644
--- a/.woodpecker/main-amd64-build.yml
+++ b/.woodpecker/main-amd64-build.yml
@@ -36,6 +36,11 @@ steps:
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
+ RELEASE_API_URL: https://mo.oeo.one
+ RELEASE_SIGNING_KEY:
+ from_secret: release_signing_key
+ RELEASE_API_TOKEN:
+ from_secret: release_api_token
commands:
- bash ci/build-main-amd64.sh agent
depends_on: []
@@ -48,6 +53,11 @@ steps:
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
+ RELEASE_API_URL: https://mo.oeo.one
+ RELEASE_SIGNING_KEY:
+ from_secret: release_signing_key
+ RELEASE_API_TOKEN:
+ from_secret: release_api_token
commands:
- bash ci/build-main-amd64.sh probe
depends_on: []
diff --git a/backend/agent/src/Monitor.Agent/AgentApp.cs b/backend/agent/src/Monitor.Agent/AgentApp.cs
index 181ce9b..a23f2f8 100644
--- a/backend/agent/src/Monitor.Agent/AgentApp.cs
+++ b/backend/agent/src/Monitor.Agent/AgentApp.cs
@@ -25,6 +25,7 @@ public sealed class AgentApp : IDisposable
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AgentAdmission _admission;
private readonly Outbox _outbox = new(64 << 20);
+ private readonly UpdateBridge _update;
private readonly BackendUplink _uplink;
private readonly UdpMux _udp;
private readonly TcpAcceptor _tcp;
@@ -34,8 +35,10 @@ public sealed class AgentApp : IDisposable
private readonly TestRunner _tests;
private readonly FleetLink _link;
- public AgentApp(AgentConfig config, LoadedNodeIdentity identity, TimeProvider time, INodeLog log)
+ /// 配置目录(容器里的 /etc/monitor):宿主机更新器和节点在这里交换清单、请求和状态。
+ public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log)
{
+ _update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
_config = config;
_identity = identity;
_time = time;
@@ -200,6 +203,24 @@ public sealed class AgentApp : IDisposable
(_, cancel, _) => ValueTask.FromResult(Result.Ok(new TestRunResult { Accepted = _tests.Cancel(cancel.TestId) })));
}
+ if (Allow(MonitorMethods.UpdateOffer))
+ {
+ router.OnNotify(MonitorMethods.UpdateOffer, MonitorJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
+ {
+ _update.WriteOffer(offer);
+ return ValueTask.CompletedTask;
+ });
+ }
+
+ if (Allow(MonitorMethods.UpdateApply))
+ {
+ router.OnNotify(MonitorMethods.UpdateApply, MonitorJsonContext.Default.UpdateOfferParams, (_, _, _) =>
+ {
+ _update.RequestApply();
+ return ValueTask.CompletedTask;
+ });
+ }
+
if (Allow(MonitorMethods.ProbeDisconnect))
{
router.On(
@@ -270,6 +291,7 @@ public sealed class AgentApp : IDisposable
DroppedInvalid = _counters.DroppedInvalid,
DroppedRateLimited = _counters.DroppedRateLimited,
QueueBytes = _outbox.PendingBytes,
+ Update = _update.ReadStatus(),
};
_lastIn = packetsIn;
_lastOut = packetsOut;
diff --git a/backend/agent/src/Monitor.Agent/Program.cs b/backend/agent/src/Monitor.Agent/Program.cs
index 5a69731..fd273c2 100644
--- a/backend/agent/src/Monitor.Agent/Program.cs
+++ b/backend/agent/src/Monitor.Agent/Program.cs
@@ -35,7 +35,7 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
using (identity)
{
- using var app = new AgentApp(config, identity, time, log);
+ using var app = new AgentApp(config, identity, Path.GetDirectoryName(Path.GetFullPath(configPath))!, time, log);
await ServiceShell.RunAsync(app.RunAsync, log);
}
diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json
index 336e303..37c485b 100644
--- a/backend/openapi/v1.json
+++ b/backend/openapi/v1.json
@@ -3696,7 +3696,7 @@
"tags": [
"admin-build"
],
- "summary": "Version, tag, commit and build time of the backend and every agent and probe",
+ "summary": "Version, tag, commit, build time and update state of the backend and every agent and probe",
"operationId": "build_info_get",
"responses": {
"200": {
@@ -3732,6 +3732,175 @@
}
}
},
+ "/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": [
+ "admin-build"
+ ],
+ "summary": "Ask an online agent or probe to install the release it has been offered",
+ "operationId": "nodes_update",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "content": {
+ "application/json": {
+ "schema": { }
+ }
+ }
+ },
+ "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"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/healthz": {
"get": {
"tags": [
@@ -5540,6 +5709,16 @@
"null",
"string"
]
+ },
+ "update": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/NodeUpdateDto"
+ }
+ ]
}
}
},
@@ -5612,6 +5791,49 @@
}
}
},
+ "NodeUpdateDto": {
+ "required": [
+ "state",
+ "current_digest",
+ "available_tag",
+ "available_version",
+ "error",
+ "at"
+ ],
+ "type": "object",
+ "properties": {
+ "state": {
+ "type": "string"
+ },
+ "current_digest": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "available_tag": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "available_version": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "error": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "at": {
+ "format": "date-time"
+ }
+ }
+ },
"OverviewDto": {
"required": [
"range",
@@ -5989,6 +6211,27 @@
}
}
},
+ "PageDtoOfReleaseDto": {
+ "required": [
+ "items",
+ "next_cursor"
+ ],
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ReleaseDto"
+ }
+ },
+ "next_cursor": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
"PageDtoOfSettingDto": {
"required": [
"items",
@@ -6432,6 +6675,27 @@
}
}
},
+ "PublishReleaseRequest": {
+ "required": [
+ "manifest",
+ "signature"
+ ],
+ "type": "object",
+ "properties": {
+ "manifest": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "signature": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
"RawChunkMetaDto": {
"required": [
"chunk_id",
@@ -6573,6 +6837,38 @@
}
}
},
+ "ReleaseDto": {
+ "required": [
+ "component",
+ "seq",
+ "tag",
+ "version",
+ "digest"
+ ],
+ "type": "object",
+ "properties": {
+ "component": {
+ "type": "string"
+ },
+ "seq": {
+ "pattern": "^-?(?:0|[1-9]\\d*)$",
+ "type": [
+ "integer",
+ "string"
+ ],
+ "format": "int64"
+ },
+ "tag": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ },
+ "digest": {
+ "type": "string"
+ }
+ }
+ },
"ScoreDto": {
"required": [
"score",
diff --git a/backend/probe/src/Monitor.Probe/ProbeApp.cs b/backend/probe/src/Monitor.Probe/ProbeApp.cs
index 11866ef..b068480 100644
--- a/backend/probe/src/Monitor.Probe/ProbeApp.cs
+++ b/backend/probe/src/Monitor.Probe/ProbeApp.cs
@@ -31,6 +31,7 @@ public sealed class ProbeApp : IDisposable
_env = new ConnectionEnv(identity, file.ProbeId, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release();
+ var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
var options = new FleetLinkOptions
{
Server = new Uri(file.Server),
@@ -38,6 +39,7 @@ public sealed class ProbeApp : IDisposable
NodeId = new NodeId(file.ProbeId),
NodeVersion = Version,
Capabilities = [MonitorCapabilities.Base],
+ HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus() }, MonitorJsonContext.Default.ProbeHeartbeatExtra),
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Platform = PlatformDetector.Detect() }, MonitorJsonContext.Default.ProbeHelloMeta),
};
var list = new StateHandler(
@@ -48,7 +50,18 @@ public sealed class ProbeApp : IDisposable
directory.Apply(doc);
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
});
- _link = new FleetLink(options, new RpcRouter(), [list], new Outbox(1 << 20), time, log);
+ var router = new RpcRouter()
+ .OnNotify(MonitorMethods.UpdateOffer, MonitorJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
+ {
+ update.WriteOffer(offer);
+ return ValueTask.CompletedTask;
+ })
+ .OnNotify(MonitorMethods.UpdateApply, MonitorJsonContext.Default.UpdateOfferParams, (_, _, _) =>
+ {
+ update.RequestApply();
+ return ValueTask.CompletedTask;
+ });
+ _link = new FleetLink(options, router, [list], new Outbox(1 << 20), time, log);
}
/// 到面板的连接(测试和状态查看用)。
diff --git a/backend/shared/Monitor.Measurement/Update/UpdateBridge.cs b/backend/shared/Monitor.Measurement/Update/UpdateBridge.cs
new file mode 100644
index 0000000..4f21963
--- /dev/null
+++ b/backend/shared/Monitor.Measurement/Update/UpdateBridge.cs
@@ -0,0 +1,100 @@
+using System.Text;
+using System.Text.Json;
+using Fleet.Core;
+using Monitor.Protocol;
+
+namespace Monitor.Measurement;
+
+///
+/// 节点进程和宿主机更新器之间的文件交换(容器里 /etc/monitor,也就是数据目录):
+/// 节点把面板发来的清单和签名写进 update/,把「更新」请求写成 update/apply.request;更新器验签并更新,状态写在 root 拥有的 update-status/status.json。
+/// 节点进程不执行任何命令,也不信任清单的内容(验签在宿主机上)。没有安装更新器时目录写不了或没人读,调用只是返回 false。
+///
+public sealed class UpdateBridge
+{
+ private const int MaxManifestBytes = 4096;
+ private const int MaxSignatureBytes = 512;
+ private const int MaxStatusBytes = 4096;
+
+ private readonly string _updateDirectory;
+ private readonly string _statusFile;
+ private readonly Action _write;
+ private readonly INodeLog _log;
+
+ /// 写数据目录里的文件(相对路径,原子替换):由节点传入 ConfigStore.Write——常驻进程里只有它可以写文件。
+ public UpdateBridge(string dataDirectory, Action write, INodeLog log)
+ {
+ _updateDirectory = Path.Combine(dataDirectory, "update");
+ _statusFile = Path.Combine(dataDirectory, "update-status", "status.json");
+ _write = write;
+ _log = log;
+ }
+
+ /// 放下面板发来的清单和签名(和已有的一样就不动,免得每次重连都惊动更新器)。先写清单、后写签名:更新器监视的是签名文件。
+ public bool WriteOffer(UpdateOfferParams offer)
+ {
+ byte[] manifest = Encoding.UTF8.GetBytes(offer.Manifest);
+ byte[] signature = Encoding.UTF8.GetBytes(offer.Signature);
+ if (manifest.Length is 0 or > MaxManifestBytes || signature.Length is 0 or > MaxSignatureBytes)
+ {
+ _log.Warn("ignoring an update offer of an unexpected size");
+ return false;
+ }
+
+ try
+ {
+ string manifestPath = Path.Combine(_updateDirectory, "offer.manifest");
+ string signaturePath = Path.Combine(_updateDirectory, "offer.sig");
+ if (Same(manifestPath, manifest) && Same(signaturePath, signature))
+ {
+ return true;
+ }
+
+ _write("update/offer.manifest", manifest);
+ _write("update/offer.sig", signature);
+ _log.Info("update offer received");
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _log.Warn("could not store the update offer (is the updater installed?)", ("err", ex.Message));
+ return false;
+ }
+ }
+
+ /// 请求更新:写一个空的 apply.request(更新器验签通过、清单更新才会真的动手)。
+ public bool RequestApply()
+ {
+ try
+ {
+ _write("update/apply.request", []);
+ _log.Info("update requested");
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _log.Warn("could not request the update (is the updater installed?)", ("err", ex.Message));
+ return false;
+ }
+ }
+
+ /// 更新器的状态;没有安装更新器、文件不存在或内容不对时返回 null。
+ public UpdateStatus? ReadStatus()
+ {
+ try
+ {
+ if (!File.Exists(_statusFile) || new FileInfo(_statusFile).Length > MaxStatusBytes)
+ {
+ return null;
+ }
+
+ return JsonSerializer.Deserialize(File.ReadAllBytes(_statusFile), MonitorJsonContext.Default.UpdateStatus);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content);
+}
diff --git a/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs b/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs
index 6d5db07..35ba9f2 100644
--- a/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs
+++ b/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs
@@ -13,6 +13,8 @@ public static class MonitorMethods
public const string TestStatus = "monitor.test.status";
public const string Event = "monitor.event";
public const string Host = "monitor.host";
+ public const string UpdateOffer = "monitor.update.offer";
+ public const string UpdateApply = "monitor.update.apply";
// Probe ↔ Agent(点对点会话)
public const string Clock = "monitor.clock";
@@ -217,6 +219,66 @@ public sealed class PlatformInfo
public string? BuiltAt { get; set; }
}
+///
+/// 面板给节点的更新清单(monitor.update.offer):manifest 是发布清单的原始文本,signature 是 base64 的签名。
+/// 节点只是把它们原样放进数据目录;验签、拉取、重启都由宿主机上的更新器完成(节点进程不执行任何命令,也不信任清单的内容)。
+///
+public sealed class UpdateOfferParams
+{
+ [JsonPropertyName("manifest")]
+ public string Manifest { get; set; } = "";
+
+ [JsonPropertyName("signature")]
+ public string Signature { get; set; } = "";
+}
+
+/// 宿主机更新器已经验证过的可用更新。
+public sealed class UpdateAvailable
+{
+ [JsonPropertyName("version")]
+ public string? Version { get; set; }
+
+ [JsonPropertyName("tag")]
+ public string? Tag { get; set; }
+
+ [JsonPropertyName("digest")]
+ public string? Digest { get; set; }
+
+ [JsonPropertyName("seq")]
+ public long Seq { get; set; }
+}
+
+///
+/// 宿主机更新器的状态(它写在 update-status/status.json,节点读出来随心跳上报):
+/// up_to_date、available、applying、failed、rolled_back、invalid(清单验签或校验没过)。
+/// 没有安装更新器的节点没有这个字段。
+///
+public sealed class UpdateStatus
+{
+ [JsonPropertyName("state")]
+ public string State { get; set; } = "";
+
+ /// 正在运行的镜像 digest(更新器记录的;还没更新过时为空)。
+ [JsonPropertyName("current")]
+ public string? Current { get; set; }
+
+ [JsonPropertyName("available")]
+ public UpdateAvailable? Available { get; set; }
+
+ [JsonPropertyName("error")]
+ public string? Error { get; set; }
+
+ [JsonPropertyName("at")]
+ public long At { get; set; }
+}
+
+/// Probe 的 fleet.heartbeat 附加字段:更新器的状态。
+public sealed class ProbeHeartbeatExtra
+{
+ [JsonPropertyName("update")]
+ public UpdateStatus? Update { get; set; }
+}
+
/// Probe 的 fleet.hello 附加字段:系统、架构和构建信息(只用于展示和更新)。
public sealed class ProbeHelloMeta
{
@@ -326,6 +388,9 @@ public sealed class AgentHeartbeatExtra
[JsonPropertyName("queue_bytes")]
public long QueueBytes { get; set; }
+
+ [JsonPropertyName("update")]
+ public UpdateStatus? Update { get; set; }
}
/// Agent 下发给 Probe 的一条线路任务(期望状态类型 line_task_set 里的一项)。
@@ -465,6 +530,9 @@ public static class FragmentKeys
[JsonSerializable(typeof(TestStatusParams))]
[JsonSerializable(typeof(AgentEventParams))]
[JsonSerializable(typeof(ProbeAgentList))]
+[JsonSerializable(typeof(UpdateOfferParams))]
+[JsonSerializable(typeof(UpdateStatus))]
+[JsonSerializable(typeof(ProbeHeartbeatExtra))]
[JsonSerializable(typeof(ProbeHelloMeta))]
[JsonSerializable(typeof(AgentEnrollMeta))]
[JsonSerializable(typeof(PlatformInfo))]
diff --git a/backend/src/Monitor.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/BuildEndpoints.cs
index 09c546f..3b0058e 100644
--- a/backend/src/Monitor.Backend.Api/Admin/BuildEndpoints.cs
+++ b/backend/src/Monitor.Backend.Api/Admin/BuildEndpoints.cs
@@ -1,11 +1,13 @@
+using Fleet.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Monitor.Backend.Application;
+using Monitor.Protocol;
namespace Monitor.Backend.Api;
-/// 管理端的构建信息:后端自己和每个 Agent、Probe 的版本、tag、提交、构建时间(节点上报的,不含任何地址)。
+/// 构建信息与更新:后端和每个节点的版本、tag、构建时间,CI 签名的发布清单,以及「更新这个节点」的请求。更新本身由节点宿主机上的更新器验签并执行。
public static class BuildEndpoints
{
private const string Tag = "admin-build";
@@ -13,18 +15,44 @@ public static class BuildEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/build-info", GetAsync)
- .Api("build_info_get", "Version, tag, commit and build time of the backend and every agent and probe", Tag)
+ .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("/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