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"]