diff --git a/.woodpecker/gitea-release.yml b/.woodpecker/gitea-release.yml
index f355761..322c7ce 100644
--- a/.woodpecker/gitea-release.yml
+++ b/.woodpecker/gitea-release.yml
@@ -17,11 +17,27 @@ depends_on:
- windows-release
steps:
+ - name: publish-agent-probe-oci
+ image: bash
+ environment:
+ GITEA_BASE: http://127.0.0.1:3100
+ GITEA_TOKEN:
+ from_secret: gitea_token
+ ZOT_REGISTRY: registry.oeo.one
+ ZOT_USERNAME: justme
+ ZOT_SECRET:
+ from_secret: zot_secret
+ commands:
+ - bash ci/release-node-images.sh
+ depends_on: []
+
- name: publish-gitea-release
image: bash
environment:
- GITEA_BASE: http://192.168.2.162:3100
+ GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
commands:
- bash ci/publish-gitea-release.sh
+ depends_on:
+ - publish-agent-probe-oci
diff --git a/.woodpecker/linux-release.yml b/.woodpecker/linux-release.yml
index 19a7a58..9bc65d5 100644
--- a/.woodpecker/linux-release.yml
+++ b/.woodpecker/linux-release.yml
@@ -19,11 +19,10 @@ steps:
image: bash
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
- ZOT_REGISTRY: 192.168.2.162:5000
- ZOT_USERNAME:
- from_secret: zot_username
- ZOT_PASSWORD:
- from_secret: zot_password
+ ZOT_REGISTRY: registry.oeo.one
+ ZOT_USERNAME: justme
+ ZOT_SECRET:
+ from_secret: zot_secret
commands:
- bash ci/release-linux.sh amd64 backend
depends_on: []
@@ -32,11 +31,10 @@ steps:
image: bash
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
- ZOT_REGISTRY: 192.168.2.162:5000
- ZOT_USERNAME:
- from_secret: zot_username
- ZOT_PASSWORD:
- from_secret: zot_password
+ ZOT_REGISTRY: registry.oeo.one
+ ZOT_USERNAME: justme
+ ZOT_SECRET:
+ from_secret: zot_secret
commands:
- bash ci/release-linux.sh arm64 backend
depends_on: []
@@ -44,11 +42,11 @@ steps:
- name: backend-multiarch-index
image: bash
environment:
- ZOT_REGISTRY: 192.168.2.162:5000
- ZOT_USERNAME:
- from_secret: zot_username
- ZOT_PASSWORD:
- from_secret: zot_password
+ ZOT_REGISTRY: registry.oeo.one
+ ZOT_SCHEME: https
+ ZOT_USERNAME: justme
+ ZOT_SECRET:
+ from_secret: zot_secret
commands:
- bash ci/index-backend-image.sh
depends_on:
diff --git a/.woodpecker/main-fastlane.yml b/.woodpecker/main-fastlane.yml
new file mode 100644
index 0000000..bcd4079
--- /dev/null
+++ b/.woodpecker/main-fastlane.yml
@@ -0,0 +1,28 @@
+labels:
+ ci: domain-linux-native
+ platform: linux/amd64
+
+clone:
+ - name: git
+ image: plugin-git
+ settings:
+ lfs: false
+
+when:
+ - event: push
+ branch: main
+
+concurrency: 1
+
+steps:
+ - name: publish-affected-amd64-images
+ image: bash
+ environment:
+ NUGET_PACKAGES: /home/justme/.nuget/packages
+ ZOT_REGISTRY: registry.oeo.one
+ ZOT_USERNAME: justme
+ ZOT_SECRET:
+ from_secret: zot_secret
+ commands:
+ - bash ci/release-main-images.sh
+ depends_on: []
diff --git a/.woodpecker/verify.yml b/.woodpecker/verify.yml
index 57a4be3..0b19941 100644
--- a/.woodpecker/verify.yml
+++ b/.woodpecker/verify.yml
@@ -9,7 +9,9 @@ clone:
lfs: false
when:
- - event: [push, pull_request]
+ - event: pull_request
+ - event: push
+ branch: dev
steps:
- name: backend-tests
diff --git a/README.md b/README.md
index dfc4fb4..dda569a 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ npm run build --prefix web
前端产物位于 `web/display/dist/` 和 `web/admin/dist/`。NativeAOT 目标平台和 Windows 上使用 AotAnywhere 交叉构建的说明见 [`docs/deployment.md`](docs/deployment.md)。
-Backend 以 OCI 镜像发布,支持 Linux/amd64 与 Linux/arm64;Agent 和 Probe 发布 Linux glibc/musl x64、arm64,macOS x64/arm64,以及 Windows x64/arm64 二进制包。容器部署配置见 [`compose.yaml`](compose.yaml),发布说明见 [`docs/deployment.md`](docs/deployment.md)。
+Backend、Agent 和 Probe 均以 OCI 镜像发布,支持 Linux/amd64 与 Linux/arm64;Agent 和 Probe 另外提供 Linux glibc/musl、macOS 与 Windows 二进制包。容器部署配置见 [`compose.yaml`](compose.yaml),发布说明见 [`docs/deployment.md`](docs/deployment.md)。
### 本地运行
diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props
index bbd8e84..65e3c69 100644
--- a/backend/Directory.Build.props
+++ b/backend/Directory.Build.props
@@ -13,7 +13,7 @@
true
$(MSBuildThisFileDirectory)artifacts
true
- 0.1.0
+ 0.1.1
false
diff --git a/backend/agent/README.md b/backend/agent/README.md
index 53ae9b4..42266c5 100644
--- a/backend/agent/README.md
+++ b/backend/agent/README.md
@@ -47,6 +47,8 @@ fleet-enroll --server https:// --join --name --ide
monitor-agent --config /etc/monitor/agent.json
```
+也可使用 OCI 镜像 `registry.oeo.one/monitor/monitor-agent:`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取 `/etc/monitor/agent.json`,需要挂载配置与身份目录;建议使用宿主网络,并按 ICMP 测量需求添加 `NET_RAW` 能力。镜像以 UID/GID 1654 运行,挂载目录必须允许该用户读取配置和身份文件。
+
不传 `--config` 时默认读取 `/etc/monitor/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。
## 设计说明
diff --git a/backend/agent/src/Monitor.Agent/AgentApp.cs b/backend/agent/src/Monitor.Agent/AgentApp.cs
index a7136a7..c79fb0a 100644
--- a/backend/agent/src/Monitor.Agent/AgentApp.cs
+++ b/backend/agent/src/Monitor.Agent/AgentApp.cs
@@ -12,7 +12,7 @@ namespace Monitor.Agent;
/// Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。
public sealed class AgentApp : IDisposable
{
- public const string Version = "0.1.0";
+ public const string Version = "0.1.1";
private readonly AgentConfig _config;
private readonly LoadedNodeIdentity _identity;
diff --git a/backend/probe/README.md b/backend/probe/README.md
index 4b50574..cfeb861 100644
--- a/backend/probe/README.md
+++ b/backend/probe/README.md
@@ -39,6 +39,8 @@ monitor-probe --config /etc/monitor/probe.json
不传 `--config` 时默认读取 `/etc/monitor/probe.json`。配置中的 `limits` 是本地硬上限,Backend 无法远程放宽;路由器等低性能设备可调低测试码率和包速率,避免 Probe 自身成为测量瓶颈。部署时可使用仓库的 `packaging/` 文件和安装脚本。
+也可使用 OCI 镜像 `registry.oeo.one/monitor/monitor-probe:`,自动选择 Linux amd64 或 arm64 镜像。容器默认读取 `/etc/monitor/probe.json`,并在同一目录维护 `agents.json` 缓存,因此挂载目录必须允许 UID/GID 1654 读写。建议使用宿主网络,并按 ICMP 测量需求添加 `NET_RAW` 能力。
+
## 设计说明
- Probe 只执行编译进程序的测量模块。它不认识 Backend,不落盘测量数据,也不能执行命令或自行更新。
diff --git a/backend/probe/src/Monitor.Probe/ProbeApp.cs b/backend/probe/src/Monitor.Probe/ProbeApp.cs
index f6a71a9..aeb880c 100644
--- a/backend/probe/src/Monitor.Probe/ProbeApp.cs
+++ b/backend/probe/src/Monitor.Probe/ProbeApp.cs
@@ -11,7 +11,7 @@ namespace Monitor.Probe;
///
public sealed class ProbeApp : IDisposable
{
- public const string Version = "0.1.0";
+ public const string Version = "0.1.1";
private readonly ConnectionEnv _env;
private readonly Dictionary _connections = [];
diff --git a/ci/index-backend-image.sh b/ci/index-backend-image.sh
index c2d1042..aff521b 100644
--- a/ci/index-backend-image.sh
+++ b/ci/index-backend-image.sh
@@ -9,13 +9,13 @@ version="${CI_COMMIT_TAG#v}"
exit 2
}
-registry="${ZOT_REGISTRY:-192.168.2.162:5000}"
-scheme="${ZOT_SCHEME:-http}"
+registry="${ZOT_REGISTRY:-registry.oeo.one}"
+scheme="${ZOT_SCHEME:-https}"
image="monitor/monitor-backend"
auth_file="$(mktemp)"
chmod 600 "$auth_file"
trap 'rm -f "$auth_file"' EXIT
-printf 'machine %s login %s password %s\n' "${registry%%:*}" "$ZOT_USERNAME" "$ZOT_PASSWORD" > "$auth_file"
+printf 'machine %s login %s password %s\n' "${registry%%:*}" "$ZOT_USERNAME" "$ZOT_SECRET" > "$auth_file"
accept='application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
diff --git a/ci/main-fastlane.py b/ci/main-fastlane.py
new file mode 100644
index 0000000..295b305
--- /dev/null
+++ b/ci/main-fastlane.py
@@ -0,0 +1,209 @@
+#!/usr/bin/env python3
+"""Find impacted OCI images from changed paths and stage NativeAOT contexts."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import shutil
+import subprocess
+from pathlib import Path
+from xml.etree import ElementTree
+
+
+ROOT = Path(__file__).resolve().parents[1]
+BACKEND = ROOT / "backend"
+IMAGE_ROOTS = {
+ "backend": [BACKEND / "src/Monitor.Backend/Monitor.Backend.csproj"],
+ "agent": [
+ BACKEND / "agent/src/Monitor.Agent/Monitor.Agent.csproj",
+ BACKEND / "fleet/tools/fleet-enroll/fleet-enroll.csproj",
+ ],
+ "probe": [BACKEND / "probe/src/Monitor.Probe/Monitor.Probe.csproj"],
+}
+GLOBAL_BACKEND_FILES = {
+ "Directory.Build.props",
+ "Directory.Build.targets",
+ "Directory.Packages.props",
+ "global.json",
+ "NuGet.Config",
+ "NuGet.config",
+}
+IGNORED_PARTS = {".git", ".release", "artifacts", "bin", "obj", "TestResults", "tests"}
+
+
+def read_changed_files() -> list[str]:
+ raw = os.environ.get("CI_PIPELINE_FILES")
+ if raw:
+ try:
+ value = json.loads(raw)
+ if isinstance(value, list):
+ return [str(path).replace("\\", "/") for path in value]
+ except json.JSONDecodeError:
+ return [path.strip().replace("\\", "/") for path in raw.split(",") if path.strip()]
+
+ previous = os.environ.get("CI_PREV_COMMIT_SHA")
+ try:
+ base = previous if previous else "HEAD^"
+ return subprocess.check_output(
+ ["git", "diff", "--name-only", base, "HEAD"],
+ cwd=ROOT,
+ text=True,
+ ).splitlines()
+ except (OSError, subprocess.CalledProcessError):
+ return []
+
+
+def project_graph() -> tuple[dict[Path, set[Path]], dict[Path, Path]]:
+ projects = [path.resolve() for path in BACKEND.rglob("*.csproj") if not (IGNORED_PARTS & set(path.parts))]
+ graph: dict[Path, set[Path]] = {}
+ directories: dict[Path, Path] = {}
+ for project in projects:
+ graph[project] = set()
+ directories[project.parent] = project
+ try:
+ document = ElementTree.parse(project)
+ except ElementTree.ParseError:
+ continue
+ for reference in document.iter():
+ if reference.tag.rsplit("}", 1)[-1] == "ProjectReference":
+ include = reference.attrib.get("Include")
+ if include:
+ graph[project].add((project.parent / include.replace("\\", os.sep)).resolve())
+ return graph, directories
+
+
+def reachable_projects(graph: dict[Path, set[Path]], roots: list[Path]) -> set[Path]:
+ found: set[Path] = set()
+ pending = [path.resolve() for path in roots]
+ while pending:
+ project = pending.pop()
+ if project in found:
+ continue
+ found.add(project)
+ pending.extend(graph.get(project, ()))
+ return found
+
+
+def is_within(path: Path, directory: Path) -> bool:
+ try:
+ path.relative_to(directory)
+ return True
+ except ValueError:
+ return False
+
+
+def affected_images(changed: list[str]) -> list[str]:
+ graph, project_dirs = project_graph()
+ image_dependencies = {
+ name: reachable_projects(graph, roots) for name, roots in IMAGE_ROOTS.items()
+ }
+ selected: set[str] = set()
+
+ def select_for_project(project: Path) -> None:
+ for image, dependencies in image_dependencies.items():
+ if project in dependencies:
+ selected.add(image)
+
+ project_directories = sorted(project_dirs, key=lambda path: len(path.parts), reverse=True)
+ container_map = {
+ "packaging/container/Backend.Containerfile": "backend",
+ "packaging/container/Agent.Containerfile": "agent",
+ "packaging/container/Probe.Containerfile": "probe",
+ "packaging/container/Agent.Fastlane.Containerfile": "agent",
+ "packaging/container/Probe.Fastlane.Containerfile": "probe",
+ }
+
+ for filename in changed:
+ normalized = filename.replace("\\", "/")
+ path = (ROOT / normalized).resolve()
+ if normalized in container_map:
+ selected.add(container_map[normalized])
+ continue
+ if normalized.startswith("ci/") or normalized.startswith(".woodpecker/"):
+ if not normalized.endswith(".md"):
+ selected.update(IMAGE_ROOTS)
+ continue
+ if any(part in IGNORED_PARTS for part in Path(normalized).parts):
+ continue
+ if not normalized.startswith("backend/"):
+ continue
+ if "/README" in normalized or normalized.endswith(".md"):
+ continue
+
+ if path.name in GLOBAL_BACKEND_FILES and path.parent == BACKEND:
+ selected.update(IMAGE_ROOTS)
+ continue
+
+ matching = [directory for directory in project_directories if is_within(path, directory)]
+ if matching:
+ select_for_project(project_dirs[matching[0]])
+ continue
+
+ # A props/targets file above one or more projects affects each project
+ # below it and every image that consumes those projects.
+ descendants = [project for directory, project in project_dirs.items() if is_within(directory, path.parent)]
+ if path.suffix in {".props", ".targets"} and descendants:
+ for project in descendants:
+ select_for_project(project)
+ elif normalized.startswith("backend/agent/"):
+ selected.add("agent")
+ elif normalized.startswith("backend/probe/"):
+ selected.add("probe")
+ elif path.is_file() and path.suffix in {".cs", ".csproj", ".props", ".targets", ".json"}:
+ selected.update(IMAGE_ROOTS)
+
+ return [name for name in ("backend", "agent", "probe") if name in selected]
+
+
+def stage_context(component: str, destination: Path) -> None:
+ containerfiles = {
+ "agent": ROOT / "packaging/container/Agent.Fastlane.Containerfile",
+ "probe": ROOT / "packaging/container/Probe.Fastlane.Containerfile",
+ }
+ if component not in containerfiles:
+ raise SystemExit(f"NativeAOT context is not available for {component}")
+ if destination.exists():
+ shutil.rmtree(destination)
+ restore = destination / "restore/backend"
+ source = destination / "source/backend"
+ restore.mkdir(parents=True)
+ source.mkdir(parents=True)
+ shutil.copy2(containerfiles[component], destination / "Containerfile")
+
+ for path in BACKEND.rglob("*"):
+ if not path.is_file() or IGNORED_PARTS.intersection(path.parts):
+ continue
+ relative = path.relative_to(BACKEND)
+ if path.suffix in {".csproj", ".props", ".targets", ".slnx"} or path.name in GLOBAL_BACKEND_FILES or path.name == "packages.lock.json":
+ target = restore / relative
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(path, target)
+
+ target = source / relative
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(path, target)
+
+ (destination / "config").mkdir()
+ (destination / "config/.keep").write_text("", encoding="utf-8")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--stage", choices=("agent", "probe"))
+ parser.add_argument("--destination", type=Path)
+ args = parser.parse_args()
+
+ if args.stage:
+ if args.destination is None:
+ parser.error("--destination is required with --stage")
+ stage_context(args.stage, args.destination)
+ return
+
+ for image in affected_images(read_changed_files()):
+ print(image)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ci/publish-gitea-release.sh b/ci/publish-gitea-release.sh
index 4f0b182..80dabea 100644
--- a/ci/publish-gitea-release.sh
+++ b/ci/publish-gitea-release.sh
@@ -26,7 +26,7 @@ printf 'machine %s login justme password %s\n' "$gitea_host" "$GITEA_TOKEN" > "$
chmod 600 "$netrc"
trap 'rm -rf "$tmp"' EXIT
-release_body="Agent and Probe builds: Linux glibc and musl (x64, arm64), macOS (x64, arm64), and Windows (x64, arm64). macOS binaries are not Developer ID signed or notarized. Backend OCI images: registry.oeo.one/monitor/monitor-backend:$version (linux/amd64 and linux/arm64)."
+release_body="Agent and Probe builds: Linux glibc and musl (x64, arm64), macOS (x64, arm64), and Windows (x64, arm64). Linux glibc Agent and Probe OCI images: registry.oeo.one/monitor/monitor-agent:$version and registry.oeo.one/monitor/monitor-probe:$version (linux/amd64 and linux/arm64). Backend OCI image: registry.oeo.one/monitor/monitor-backend:$version (linux/amd64 and linux/arm64). macOS binaries are not Developer ID signed or notarized."
payload="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"MyMonitor %s","body":"%s","draft":false,"prerelease":false}' \
"$tag" "${CI_COMMIT_SHA:-}" "$tag" "$release_body")"
release_status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \
diff --git a/ci/release-linux.sh b/ci/release-linux.sh
index 9736ac1..6ad5f63 100644
--- a/ci/release-linux.sh
+++ b/ci/release-linux.sh
@@ -5,11 +5,20 @@ set -euo pipefail
arch="${1:?usage: release-linux.sh }"
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tag="${CI_COMMIT_TAG:-}"
-[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || {
- echo "release must run from a vMAJOR.MINOR.PATCH tag" >&2
- exit 2
-}
-[[ -n "${ZOT_USERNAME:-}" && -n "${ZOT_PASSWORD:-}" ]] || {
+if [[ -n "$tag" ]]; then
+ [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || {
+ echo "release must run from a vMAJOR.MINOR.PATCH tag" >&2
+ exit 2
+ }
+ version="${tag#v}"
+else
+ [[ "${CI_PIPELINE_EVENT:-}" == push && "${CI_COMMIT_BRANCH:-}" == main ]] || {
+ echo "untagged image builds are restricted to pushes on main" >&2
+ exit 2
+ }
+ version=main
+fi
+[[ -n "${ZOT_USERNAME:-}" && -n "${ZOT_SECRET:-}" ]] || {
echo "Zot credentials are required" >&2; exit 2;
}
case "$arch" in
@@ -17,14 +26,13 @@ case "$arch" in
*) echo "unsupported architecture: $arch" >&2; exit 2 ;;
esac
-version="${tag#v}"
project_version="$(sed -n 's#.*\(.*\).*#\1#p' "$root/backend/Directory.Build.props" | head -1)"
-if [[ "$version" != "$project_version" ]]; then
+if [[ -n "$tag" && "$version" != "$project_version" ]]; then
echo "tag version $version does not match project version $project_version" >&2
exit 1
fi
-registry="${ZOT_REGISTRY:-192.168.2.162:5000}"
+registry="${ZOT_REGISTRY:-registry.oeo.one}"
image="$registry/monitor/monitor-backend"
output="$root/.release/linux-$arch/backend"
build_root="$root/.release/build/linux-$arch-backend"
@@ -40,11 +48,18 @@ find "$output" -type f -name '*.pdb' -delete
cp -a "$output/." "$container_context/app/"
: > "$container_context/data/.keep"
+# Woodpecker's local backend isolates HOME per workflow. Keep Buildah's image
+# store outside that temporary directory so the runtime base image is reusable
+# across releases. Separate stores avoid concurrent amd64/arm64 writers.
+buildah_home="${BUILDAH_HOME:-/home/justme/woodpecker/buildah-home/$arch}"
+mkdir -p "$buildah_home"
+export HOME="$buildah_home"
+
export REGISTRY_AUTH_FILE="$root/.release/auth/zot-linux-$arch.json"
mkdir -p "$(dirname "$REGISTRY_AUTH_FILE")"
logged_in=false
for attempt in 1 2 3 4; do
- if buildah login --tls-verify=false --username "$ZOT_USERNAME" --password-stdin "$registry" <<< "$ZOT_PASSWORD" >/dev/null; then
+ if buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$registry" <<< "$ZOT_SECRET" >/dev/null; then
logged_in=true
break
fi
@@ -55,20 +70,27 @@ for attempt in 1 2 3 4; do
done
[[ "$logged_in" == true ]] || { echo "could not authenticate to Zot after 4 attempts" >&2; exit 1; }
local_image="localhost/monitor-backend:$version-$arch"
-buildah bud --tls-verify=false --arch "$arch" \
+buildah bud --tls-verify=true --arch "$arch" \
--build-arg "APP_VERSION=$version" \
-f "$root/packaging/container/Backend.Containerfile" \
- -t "$local_image" "$container_context" >/dev/null
-pushed=false
-for attempt in 1 2 3; do
- if buildah push --tls-verify=false "$local_image" "docker://$image:$version-$arch" >/dev/null; then
- pushed=true
- break
- fi
- if [[ "$attempt" -lt 3 ]]; then
- echo "Zot push attempt $attempt failed; retrying in 5 seconds" >&2
- sleep 5
- fi
-done
-[[ "$pushed" == true ]] || { echo "could not push image to Zot after 3 attempts" >&2; exit 1; }
-echo "pushed Zot image $image:$version-$arch"
+ -t "$local_image" "$container_context"
+push_tag() {
+ local target_tag="$1" pushed=false
+ for attempt in 1 2 3; do
+ if buildah push --tls-verify=true "$local_image" "docker://$image:$target_tag"; then
+ pushed=true
+ break
+ fi
+ if [[ "$attempt" -lt 3 ]]; then
+ echo "Zot push attempt $attempt failed; retrying in 5 seconds" >&2
+ sleep 5
+ fi
+ done
+ [[ "$pushed" == true ]] || { echo "could not push image to Zot after 3 attempts" >&2; return 1; }
+ echo "pushed Zot image $image:$target_tag"
+}
+
+push_tag "$version-$arch"
+if [[ -z "$tag" ]]; then
+ push_tag "$version"
+fi
diff --git a/ci/release-main-images.sh b/ci/release-main-images.sh
new file mode 100644
index 0000000..f63543b
--- /dev/null
+++ b/ci/release-main-images.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+# Build and push only the linux/amd64 OCI images affected by a main push.
+set -euo pipefail
+
+[[ "${CI_PIPELINE_EVENT:-}" == push && "${CI_COMMIT_BRANCH:-}" == main ]] || {
+ echo "fast-lane images can only be published from a push to main" >&2
+ exit 2
+}
+[[ -n "${ZOT_USERNAME:-}" && -n "${ZOT_SECRET:-}" ]] || {
+ echo "Zot credentials are required" >&2
+ exit 2
+}
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+registry="${ZOT_REGISTRY:-registry.oeo.one}"
+work="$root/.release/main-fastlane"
+buildah_home="/home/justme/woodpecker/buildah-home/main-fastlane"
+mkdir -p "$work" "$buildah_home"
+export HOME="$buildah_home"
+export BUILDAH_HOME="$buildah_home"
+export REGISTRY_AUTH_FILE="$work/zot-auth.json"
+
+targets="$(python3 "$root/ci/main-fastlane.py")"
+if [[ -z "$targets" ]]; then
+ echo "no runtime image changes detected; skipping container builds"
+ exit 0
+fi
+echo "affected linux/amd64 images: ${targets//$'\n'/, }"
+
+logged_in=false
+for attempt in 1 2 3 4; do
+ if buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$registry" <<< "$ZOT_SECRET" >/dev/null; then
+ logged_in=true
+ break
+ fi
+ if [[ "$attempt" -lt 4 ]]; then
+ echo "Zot login attempt $attempt failed; retrying in 3 seconds" >&2
+ sleep 3
+ fi
+done
+[[ "$logged_in" == true ]] || { echo "could not authenticate to Zot after 4 attempts" >&2; exit 1; }
+
+publish_node_image() {
+ local component="$1" context="$work/$1" image="$registry/monitor/monitor-$1"
+ local local_image="localhost/monitor-$component:main-amd64"
+
+ python3 "$root/ci/main-fastlane.py" --stage "$component" --destination "$context"
+ buildah bud --layers --tls-verify=true --arch amd64 \
+ --build-arg APP_VERSION=main \
+ -f "$context/Containerfile" -t "$local_image" "$context"
+
+ for tag in main-amd64 main; do
+ local pushed=false
+ for attempt in 1 2 3; do
+ if buildah push --tls-verify=true "$local_image" "docker://$image:$tag"; then
+ pushed=true
+ break
+ fi
+ if [[ "$attempt" -lt 3 ]]; then
+ echo "Zot push for $component:$tag attempt $attempt failed; retrying in 5 seconds" >&2
+ sleep 5
+ fi
+ done
+ [[ "$pushed" == true ]] || { echo "could not push $component:$tag after 3 attempts" >&2; return 1; }
+ echo "pushed Zot image $image:$tag"
+ done
+}
+
+while IFS= read -r component; do
+ [[ -n "$component" ]] || continue
+ case "$component" in
+ backend) bash "$root/ci/release-linux.sh" amd64 backend ;;
+ agent|probe) publish_node_image "$component" ;;
+ *) echo "unknown affected image: $component" >&2; exit 2 ;;
+ esac
+done <<< "$targets"
diff --git a/ci/release-node-images.sh b/ci/release-node-images.sh
new file mode 100644
index 0000000..8adb51f
--- /dev/null
+++ b/ci/release-node-images.sh
@@ -0,0 +1,119 @@
+#!/usr/bin/env bash
+# Publish glibc OCI images for the Linux Agent and Probe on amd64 and arm64.
+set -euo pipefail
+
+tag="${CI_COMMIT_TAG:-}"
+[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || {
+ echo "release must run from a vMAJOR.MINOR.PATCH tag" >&2
+ exit 2
+}
+[[ -n "${GITEA_TOKEN:-}" ]] || { echo "GITEA_TOKEN is required" >&2; exit 2; }
+[[ -n "${ZOT_USERNAME:-}" && -n "${ZOT_SECRET:-}" ]] || {
+ echo "Zot credentials are required" >&2
+ exit 2
+}
+
+version="${tag#v}"
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+project_version="$(sed -n 's#.*\(.*\).*#\1#p' "$root/backend/Directory.Build.props" | head -1)"
+if [[ "$version" != "$project_version" ]]; then
+ echo "tag version $version does not match project version $project_version" >&2
+ exit 1
+fi
+
+gitea_base="${GITEA_BASE:-http://127.0.0.1:3100}"
+gitea_base="${gitea_base%/}"
+gitea_host="${gitea_base#https://}"
+gitea_host="${gitea_host#http://}"
+gitea_host="${gitea_host%%/*}"
+gitea_host="${gitea_host%%:*}"
+registry="${ZOT_REGISTRY:-registry.oeo.one}"
+image_root="$registry/monitor"
+work="$root/.release/node-images"
+netrc="$work/netrc"
+buildah_home="/home/justme/woodpecker/buildah-home/node-images"
+mkdir -p "$buildah_home"
+export HOME="$buildah_home"
+mkdir -p "$work"
+chmod 700 "$work"
+printf 'machine %s login justme password %s\n' "$gitea_host" "$GITEA_TOKEN" > "$netrc"
+chmod 600 "$netrc"
+export REGISTRY_AUTH_FILE="$work/zot-auth.json"
+trap 'rm -rf "$work"' EXIT
+
+logged_in=false
+for attempt in 1 2 3 4; do
+ if buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$registry" <<< "$ZOT_SECRET" >/dev/null; then
+ logged_in=true
+ break
+ fi
+ if [[ "$attempt" -lt 4 ]]; then
+ echo "Zot login attempt $attempt failed; retrying in 3 seconds" >&2
+ sleep 3
+ fi
+done
+[[ "$logged_in" == true ]] || { echo "could not authenticate to Zot after 4 attempts" >&2; exit 1; }
+
+build_arch_image() {
+ local component="$1" arch="$2" suffix="$3"
+ local filename="monitor-$component-v$version-linux-$suffix.tar.gz"
+ local context="$work/$component-$arch"
+ local archive="$work/$filename"
+ local containerfile="$root/packaging/container/${component^}.Containerfile"
+ local image="$image_root/monitor-$component"
+ local local_image="localhost/monitor-$component:$version-$arch"
+
+ curl --fail-with-body --silent --show-error --retry 3 --netrc-file "$netrc" \
+ "$gitea_base/api/packages/justme/generic/monitor-node/$version/$filename" -o "$archive"
+ mkdir -p "$context/app" "$context/config"
+ if [[ "$component" == agent ]]; then
+ tar -xzf "$archive" -C "$work" monitor-agent fleet-enroll
+ install -m 0755 "$work/fleet-enroll" "$context/app/fleet-enroll"
+ else
+ tar -xzf "$archive" -C "$work" "monitor-$component"
+ fi
+ install -m 0755 "$work/monitor-$component" "$context/app/monitor-$component"
+ : > "$context/config/.keep"
+
+ buildah bud --tls-verify=true --arch "$arch" \
+ --build-arg "APP_VERSION=$version" \
+ -f "$containerfile" -t "$local_image" "$context" >/dev/null
+
+ local pushed=false
+ for attempt in 1 2 3; do
+ if buildah push --tls-verify=true "$local_image" "docker://$image:$version-$arch" >/dev/null; then
+ pushed=true
+ break
+ fi
+ if [[ "$attempt" -lt 3 ]]; then
+ echo "Zot push for $component/$arch attempt $attempt failed; retrying in 5 seconds" >&2
+ sleep 5
+ fi
+ done
+ [[ "$pushed" == true ]] || { echo "could not push $component/$arch image after 3 attempts" >&2; return 1; }
+ echo "pushed Zot image $image:$version-$arch"
+}
+
+publish_index() {
+ local component="$1" image="$image_root/monitor-$component"
+ local manifest="localhost/monitor-$component:$version-multiarch"
+ buildah manifest rm "$manifest" >/dev/null 2>&1 || true
+ buildah manifest create "$manifest" >/dev/null
+ buildah manifest add "$manifest" "docker://$image:$version-amd64" >/dev/null
+ buildah manifest add "$manifest" "docker://$image:$version-arm64" >/dev/null
+ buildah manifest push --all --format oci "$manifest" "docker://$image:$version" >/dev/null
+ if [[ "$version" != *-* ]]; then
+ buildah manifest push --all --format oci "$manifest" "docker://$image:latest" >/dev/null
+ fi
+ buildah manifest rm "$manifest" >/dev/null
+ echo "published multi-architecture index $image:$version"
+ if [[ "$version" != *-* ]]; then
+ echo "published multi-architecture index $image:latest"
+ fi
+}
+
+for component in agent probe; do
+ build_arch_image "$component" amd64 amd64
+ build_arch_image "$component" arm64 arm64
+ publish_index "$component"
+done
diff --git a/compose.yaml b/compose.yaml
index 93b92cc..1b83feb 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,6 +1,6 @@
services:
backend:
- image: registry.oeo.one/monitor/monitor-backend:${MONITOR_VERSION:-0.1.0}
+ image: registry.oeo.one/monitor/monitor-backend:${MONITOR_VERSION:-0.1.1}
container_name: monitor-backend
restart: unless-stopped
init: true
diff --git a/docs/deployment.md b/docs/deployment.md
index 22860d1..660a0d9 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -36,7 +36,23 @@ Compose 将容器端口只绑定到宿主机 `127.0.0.1:8080`,适合由同机
## 节点二进制发布
-Woodpecker 在 `vMAJOR.MINOR.PATCH` tag 上,由 Windows runner 使用 AotAnywhere 并行构建 Agent/Probe 的 Linux glibc、Linux musl、macOS 和 Windows 产物。Agent 包还包含一次性注册工具 `fleet-enroll`。Linux 归档带有安装脚本和 systemd/OpenWrt 单元;Windows 产物为 `.zip`,Linux/macOS 产物为 `.tar.gz`。所有平台产物和 Backend OCI 镜像成功后,CI 会创建对应的 Gitea Release,并附上压缩包和 SHA-256 校验文件。macOS 产物目前未签名/公证。安装说明见 [`../backend/agent/README.md`](../backend/agent/README.md) 与 [`../backend/probe/README.md`](../backend/probe/README.md)。
+Woodpecker 在 `vMAJOR.MINOR.PATCH` tag 上,由 Windows runner 使用 AotAnywhere 并行构建 Agent/Probe 的 Linux glibc、Linux musl、macOS 和 Windows 产物。Agent 包还包含一次性注册工具 `fleet-enroll`。Linux 归档带有安装脚本和 systemd/OpenWrt 单元;Windows 产物为 `.zip`,Linux/macOS 产物为 `.tar.gz`。Linux glibc 的 Agent 与 Probe 二进制还会打包为 OCI 镜像,支持 `linux/amd64` 和 `linux/arm64`;musl、macOS、Windows 仍通过对应平台归档分发。所有平台产物和 OCI 镜像发布成功后,CI 会创建对应的 Gitea Release,并附上压缩包和 SHA-256 校验文件。macOS 产物目前未签名/公证。安装说明见 [`../backend/agent/README.md`](../backend/agent/README.md) 与 [`../backend/probe/README.md`](../backend/probe/README.md)。
+
+推送到 `main` 会触发 Linux/amd64 快车道。CI 根据变更文件所在项目及 `.csproj` 的传递引用,只构建受影响的 Backend、Agent 或 Probe 镜像;Agent 镜像也包含 `fleet-enroll`。例如只改 Probe 时只构建 Probe;改动 `Monitor.Protocol` 等被多个程序引用的库时,会构建所有受影响镜像。结果推送到对应镜像的 `main` 和 `main-amd64` 标签,不改写 release 版本和 `latest`。只改 Web、文档或部署配置时不会构建容器。完整多架构和跨平台构建仍由版本 tag 流水线负责。
+
+Agent 和 Probe 镜像分别发布为 `registry.oeo.one/monitor/monitor-agent:` 与 `registry.oeo.one/monitor/monitor-probe:`,包含 amd64、arm64 多架构索引;也可指定 `:-amd64` 或 `:-arm64`。稳定版本同时更新 `latest`。镜像以非 root 用户(UID/GID 1654)运行,默认读取 `/etc/monitor/agent.json` 或 `/etc/monitor/probe.json`。运行容器时挂载对应配置目录;Probe 还会在该目录写入 `agents.json`,因此目录需可由 1654 用户写入。Agent、Probe 需使用宿主网络以保持节点端口和测量流量行为,并按需添加 `NET_RAW` 能力以执行 ICMP 测量:
+
+```sh
+docker run -d --name monitor-agent --restart unless-stopped --network host \
+ --cap-add NET_RAW -v "$PWD/agent-config:/etc/monitor" \
+ registry.oeo.one/monitor/monitor-agent:
+
+docker run -d --name monitor-probe --restart unless-stopped --network host \
+ --cap-add NET_RAW -v "$PWD/probe-config:/etc/monitor" \
+ registry.oeo.one/monitor/monitor-probe:
+```
+
+宿主机配置目录应允许 UID/GID 1654 读写;Probe 需要写权限来更新 `agents.json`。也可通过 Docker Compose 配置相同的网络、卷和能力。
实测体积(`StackTraceSupport=false`、`IlcFoldIdenticalMethodBodies=true`、`OptimizationPreference=Size`):
diff --git a/docs/project-layout.md b/docs/project-layout.md
index 6dfdf2c..b3a7c83 100644
--- a/docs/project-layout.md
+++ b/docs/project-layout.md
@@ -253,7 +253,7 @@ Backend 允许的包:ASP.NET Core 共享框架、`Microsoft.Data.Sqlite`、`Mi
true
true
true
- 0.1.0
+ 0.1.1
diff --git a/packaging/container/Agent.Containerfile b/packaging/container/Agent.Containerfile
new file mode 100644
index 0000000..7b0e07b
--- /dev/null
+++ b/packaging/container/Agent.Containerfile
@@ -0,0 +1,17 @@
+FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled-extra
+
+ARG APP_VERSION=unknown
+LABEL org.opencontainers.image.title="Monitor Agent" \
+ org.opencontainers.image.description="MyMonitor node agent for Linux" \
+ org.opencontainers.image.licenses="Apache-2.0" \
+ org.opencontainers.image.version="$APP_VERSION"
+
+COPY --chown=1654:1654 app/monitor-agent /usr/local/bin/monitor-agent
+COPY --chown=1654:1654 app/fleet-enroll /usr/local/bin/fleet-enroll
+COPY --chown=1654:1654 config/ /etc/monitor/
+
+USER 1654:1654
+WORKDIR /etc/monitor
+VOLUME ["/etc/monitor"]
+ENTRYPOINT ["/usr/local/bin/monitor-agent"]
+CMD ["--config", "/etc/monitor/agent.json"]
diff --git a/packaging/container/Agent.Fastlane.Containerfile b/packaging/container/Agent.Fastlane.Containerfile
new file mode 100644
index 0000000..32b8502
--- /dev/null
+++ b/packaging/container/Agent.Fastlane.Containerfile
@@ -0,0 +1,32 @@
+FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
+
+WORKDIR /src/backend
+COPY restore/backend/ ./
+RUN dotnet restore agent/src/Monitor.Agent -r linux-x64 --artifacts-path /artifacts \
+ && dotnet restore fleet/tools/fleet-enroll -r linux-x64 --artifacts-path /artifacts
+
+COPY source/backend/ ./
+RUN dotnet publish agent/src/Monitor.Agent -m:3 -c Release -r linux-x64 \
+ --self-contained true --no-restore -p:PublishAot=true -p:StripSymbols=true \
+ -p:DebugType=none -p:DebugSymbols=false --artifacts-path /artifacts -o /out/agent \
+ && dotnet publish fleet/tools/fleet-enroll -m:3 -c Release -r linux-x64 \
+ --self-contained true --no-restore -p:PublishAot=true -p:StripSymbols=true \
+ -p:DebugType=none -p:DebugSymbols=false --artifacts-path /artifacts -o /out/enroll
+
+FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled-extra
+
+ARG APP_VERSION=main
+LABEL org.opencontainers.image.title="Monitor Agent" \
+ org.opencontainers.image.description="MyMonitor node agent for Linux" \
+ org.opencontainers.image.licenses="Apache-2.0" \
+ org.opencontainers.image.version="$APP_VERSION"
+
+COPY --chown=1654:1654 --from=build /out/agent/monitor-agent /usr/local/bin/monitor-agent
+COPY --chown=1654:1654 --from=build /out/enroll/fleet-enroll /usr/local/bin/fleet-enroll
+COPY --chown=1654:1654 config/ /etc/monitor/
+
+USER 1654:1654
+WORKDIR /etc/monitor
+VOLUME ["/etc/monitor"]
+ENTRYPOINT ["/usr/local/bin/monitor-agent"]
+CMD ["--config", "/etc/monitor/agent.json"]
diff --git a/packaging/container/Probe.Containerfile b/packaging/container/Probe.Containerfile
new file mode 100644
index 0000000..cfeb418
--- /dev/null
+++ b/packaging/container/Probe.Containerfile
@@ -0,0 +1,16 @@
+FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled-extra
+
+ARG APP_VERSION=unknown
+LABEL org.opencontainers.image.title="Monitor Probe" \
+ org.opencontainers.image.description="MyMonitor network measurement probe for Linux" \
+ org.opencontainers.image.licenses="Apache-2.0" \
+ org.opencontainers.image.version="$APP_VERSION"
+
+COPY --chown=1654:1654 app/monitor-probe /usr/local/bin/monitor-probe
+COPY --chown=1654:1654 config/ /etc/monitor/
+
+USER 1654:1654
+WORKDIR /etc/monitor
+VOLUME ["/etc/monitor"]
+ENTRYPOINT ["/usr/local/bin/monitor-probe"]
+CMD ["--config", "/etc/monitor/probe.json"]
diff --git a/packaging/container/Probe.Fastlane.Containerfile b/packaging/container/Probe.Fastlane.Containerfile
new file mode 100644
index 0000000..71df8bd
--- /dev/null
+++ b/packaging/container/Probe.Fastlane.Containerfile
@@ -0,0 +1,27 @@
+FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
+
+WORKDIR /src/backend
+COPY restore/backend/ ./
+RUN dotnet restore probe/src/Monitor.Probe -r linux-x64 --artifacts-path /artifacts
+
+COPY source/backend/ ./
+RUN dotnet publish probe/src/Monitor.Probe -m:3 -c Release -r linux-x64 \
+ --self-contained true --no-restore -p:PublishAot=true -p:StripSymbols=true \
+ -p:DebugType=none -p:DebugSymbols=false --artifacts-path /artifacts -o /out/probe
+
+FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled-extra
+
+ARG APP_VERSION=main
+LABEL org.opencontainers.image.title="Monitor Probe" \
+ org.opencontainers.image.description="MyMonitor network measurement probe for Linux" \
+ org.opencontainers.image.licenses="Apache-2.0" \
+ org.opencontainers.image.version="$APP_VERSION"
+
+COPY --chown=1654:1654 --from=build /out/probe/monitor-probe /usr/local/bin/monitor-probe
+COPY --chown=1654:1654 config/ /etc/monitor/
+
+USER 1654:1654
+WORKDIR /etc/monitor
+VOLUME ["/etc/monitor"]
+ENTRYPOINT ["/usr/local/bin/monitor-probe"]
+CMD ["--config", "/etc/monitor/probe.json"]