From deceeb19f33e12017f6c64346b762e39628cc89c Mon Sep 17 00:00:00 2001 From: JustMe Date: Sun, 4 Oct 2026 14:01:26 -0700 Subject: [PATCH] feat: add cross-platform release pipeline [CI SKIP] --- .gitignore | 1 + .woodpecker/gitea-release.yml | 27 ++ .woodpecker/linux-release.yml | 56 ++++ .woodpecker/verify.yml | 30 +++ .woodpecker/windows-release.yml | 246 ++++++++++++++++++ README.md | 5 +- backend/Directory.Build.props | 2 + backend/README.md | 4 +- .../src/Monitor.Agent/Monitor.Agent.csproj | 8 +- .../tools/fleet-enroll/fleet-enroll.csproj | 8 +- backend/global.json | 3 + .../src/Monitor.Probe/Monitor.Probe.csproj | 8 +- .../AgentEndToEndTests.cs | 30 ++- .../Monitor.Backend.Tests/ApiBasicsTests.cs | 3 + .../ArchitectureTests.cs | 5 +- .../ProbeEndToEndTests.cs | 2 +- ci/Monitor.Tar/Monitor.Tar.csproj | 8 + ci/Monitor.Tar/Program.cs | 38 +++ ci/index-backend-image.sh | 55 ++++ ci/package-cross-platform.ps1 | 172 ++++++++++++ ci/publish-gitea-release.sh | 114 ++++++++ ci/release-cross-platform.ps1 | 75 ++++++ ci/release-linux.sh | 74 ++++++ compose.yaml | 26 ++ docs/deployment.md | 30 ++- packaging/container/Backend.Containerfile | 20 ++ scripts/publish.sh | 4 +- 27 files changed, 1036 insertions(+), 18 deletions(-) create mode 100644 .woodpecker/gitea-release.yml create mode 100644 .woodpecker/linux-release.yml create mode 100644 .woodpecker/verify.yml create mode 100644 .woodpecker/windows-release.yml create mode 100644 ci/Monitor.Tar/Monitor.Tar.csproj create mode 100644 ci/Monitor.Tar/Program.cs create mode 100644 ci/index-backend-image.sh create mode 100644 ci/package-cross-platform.ps1 create mode 100644 ci/publish-gitea-release.sh create mode 100644 ci/release-cross-platform.ps1 create mode 100644 ci/release-linux.sh create mode 100644 compose.yaml create mode 100644 packaging/container/Backend.Containerfile diff --git a/.gitignore b/.gitignore index e7833fa..af900e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ artifacts/ +.release/ bin/ obj/ *.user diff --git a/.woodpecker/gitea-release.yml b/.woodpecker/gitea-release.yml new file mode 100644 index 0000000..f355761 --- /dev/null +++ b/.woodpecker/gitea-release.yml @@ -0,0 +1,27 @@ +labels: + ci: domain-linux-native + platform: linux/amd64 + +clone: + - name: git + image: plugin-git + settings: + lfs: false + +when: + - event: tag + ref: refs/tags/v* + +depends_on: + - linux-release + - windows-release + +steps: + - name: publish-gitea-release + image: bash + environment: + GITEA_BASE: http://192.168.2.162:3100 + GITEA_TOKEN: + from_secret: gitea_token + commands: + - bash ci/publish-gitea-release.sh diff --git a/.woodpecker/linux-release.yml b/.woodpecker/linux-release.yml new file mode 100644 index 0000000..19a7a58 --- /dev/null +++ b/.woodpecker/linux-release.yml @@ -0,0 +1,56 @@ +labels: + ci: domain-linux-native + platform: linux/amd64 + +clone: + - name: git + image: plugin-git + settings: + lfs: false + +when: + - event: tag + ref: refs/tags/v* + +concurrency: 1 + +steps: + - name: linux-amd64-backend + 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 + commands: + - bash ci/release-linux.sh amd64 backend + depends_on: [] + + - name: linux-arm64-backend + 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 + commands: + - bash ci/release-linux.sh arm64 backend + depends_on: [] + + - 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 + commands: + - bash ci/index-backend-image.sh + depends_on: + - linux-amd64-backend + - linux-arm64-backend diff --git a/.woodpecker/verify.yml b/.woodpecker/verify.yml new file mode 100644 index 0000000..57a4be3 --- /dev/null +++ b/.woodpecker/verify.yml @@ -0,0 +1,30 @@ +labels: + ci: domain-linux-native + platform: linux/amd64 + +clone: + - name: git + image: plugin-git + settings: + lfs: false + +when: + - event: [push, pull_request] + +steps: + - name: backend-tests + image: bash + environment: + NUGET_PACKAGES: /home/justme/.nuget/packages + commands: + - cd backend && dotnet test --solution Monitor.slnx -c Release + + - name: web-build + image: bash + environment: + NPM_CONFIG_CACHE: /home/justme/.npm + commands: + - npm ci --prefix web --prefer-offline + - npm run build --prefix web + - npm run lint:css --prefix web + depends_on: [] diff --git a/.woodpecker/windows-release.yml b/.woodpecker/windows-release.yml new file mode 100644 index 0000000..2a5d6c0 --- /dev/null +++ b/.woodpecker/windows-release.yml @@ -0,0 +1,246 @@ +labels: + ci: domain-windows + platform: windows/amd64 + +clone: + - name: git + image: plugin-git + settings: + lfs: false + +when: + - event: tag + ref: refs/tags/v* + +concurrency: 1 + +steps: + - name: build-linux-x64-agent + image: powershell + environment: &build-environment + GITEA_BASE: http://localhost:3100 + GITEA_TOKEN: + from_secret: gitea_token + NUGET_PACKAGES: 'C:\Users\JustMe-NAS\.nuget\packages' + RID: linux-x64 + COMPONENT: agent + commands: &build-commands + - Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force + - ./ci/release-cross-platform.ps1 -RuntimeIdentifier $env:RID -Component $env:COMPONENT + depends_on: [] + + - name: build-linux-x64-probe + image: powershell + environment: &rid-linux-x64-probe + <<: *build-environment + COMPONENT: probe + commands: *build-commands + depends_on: [] + + - name: build-linux-x64-enroll + image: powershell + environment: &rid-linux-x64-enroll + <<: *build-environment + COMPONENT: fleet-enroll + commands: *build-commands + depends_on: [] + + - name: build-linux-arm64-agent + image: powershell + environment: &rid-linux-arm64-agent + <<: *build-environment + RID: linux-arm64 + commands: *build-commands + depends_on: [] + + - name: build-linux-arm64-probe + image: powershell + environment: &rid-linux-arm64-probe + <<: *rid-linux-arm64-agent + COMPONENT: probe + commands: *build-commands + depends_on: [] + + - name: build-linux-arm64-enroll + image: powershell + environment: &rid-linux-arm64-enroll + <<: *rid-linux-arm64-agent + COMPONENT: fleet-enroll + commands: *build-commands + depends_on: [] + + - name: build-linux-musl-x64-agent + image: powershell + environment: &rid-linux-musl-x64-agent + <<: *build-environment + RID: linux-musl-x64 + commands: *build-commands + depends_on: [] + + - name: build-linux-musl-x64-probe + image: powershell + environment: &rid-linux-musl-x64-probe + <<: *rid-linux-musl-x64-agent + COMPONENT: probe + commands: *build-commands + depends_on: [] + + - name: build-linux-musl-x64-enroll + image: powershell + environment: &rid-linux-musl-x64-enroll + <<: *rid-linux-musl-x64-agent + COMPONENT: fleet-enroll + commands: *build-commands + depends_on: [] + + - name: build-linux-musl-arm64-agent + image: powershell + environment: &rid-linux-musl-arm64-agent + <<: *build-environment + RID: linux-musl-arm64 + commands: *build-commands + depends_on: [] + + - name: build-linux-musl-arm64-probe + image: powershell + environment: &rid-linux-musl-arm64-probe + <<: *rid-linux-musl-arm64-agent + COMPONENT: probe + commands: *build-commands + depends_on: [] + + - name: build-linux-musl-arm64-enroll + image: powershell + environment: &rid-linux-musl-arm64-enroll + <<: *rid-linux-musl-arm64-agent + COMPONENT: fleet-enroll + commands: *build-commands + depends_on: [] + + - name: build-macos-x64-agent + image: powershell + environment: &rid-macos-x64-agent + <<: *build-environment + RID: osx-x64 + commands: *build-commands + depends_on: [] + + - name: build-macos-x64-probe + image: powershell + environment: &rid-macos-x64-probe + <<: *rid-macos-x64-agent + COMPONENT: probe + commands: *build-commands + depends_on: [] + + - name: build-macos-x64-enroll + image: powershell + environment: &rid-macos-x64-enroll + <<: *rid-macos-x64-agent + COMPONENT: fleet-enroll + commands: *build-commands + depends_on: [] + + - name: build-macos-arm64-agent + image: powershell + environment: &rid-macos-arm64-agent + <<: *build-environment + RID: osx-arm64 + commands: *build-commands + depends_on: [] + + - name: build-macos-arm64-probe + image: powershell + environment: &rid-macos-arm64-probe + <<: *rid-macos-arm64-agent + COMPONENT: probe + commands: *build-commands + depends_on: [] + + - name: build-macos-arm64-enroll + image: powershell + environment: &rid-macos-arm64-enroll + <<: *rid-macos-arm64-agent + COMPONENT: fleet-enroll + commands: *build-commands + depends_on: [] + + - name: build-windows-x64-agent + image: powershell + environment: &rid-windows-x64-agent + <<: *build-environment + RID: win-x64 + commands: *build-commands + depends_on: [] + + - name: build-windows-x64-probe + image: powershell + environment: &rid-windows-x64-probe + <<: *rid-windows-x64-agent + COMPONENT: probe + commands: *build-commands + depends_on: [] + + - name: build-windows-x64-enroll + image: powershell + environment: &rid-windows-x64-enroll + <<: *rid-windows-x64-agent + COMPONENT: fleet-enroll + commands: *build-commands + depends_on: [] + + - name: build-windows-arm64-agent + image: powershell + environment: &rid-windows-arm64-agent + <<: *build-environment + RID: win-arm64 + commands: *build-commands + depends_on: [] + + - name: build-windows-arm64-probe + image: powershell + environment: &rid-windows-arm64-probe + <<: *rid-windows-arm64-agent + COMPONENT: probe + commands: *build-commands + depends_on: [] + + - name: build-windows-arm64-enroll + image: powershell + environment: &rid-windows-arm64-enroll + <<: *rid-windows-arm64-agent + COMPONENT: fleet-enroll + commands: *build-commands + depends_on: [] + + - name: package-and-upload + image: powershell + environment: *build-environment + commands: + - Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force + - ./ci/package-cross-platform.ps1 + depends_on: + - build-linux-x64-agent + - build-linux-x64-probe + - build-linux-x64-enroll + - build-linux-arm64-agent + - build-linux-arm64-probe + - build-linux-arm64-enroll + - build-linux-musl-x64-agent + - build-linux-musl-x64-probe + - build-linux-musl-x64-enroll + - build-linux-musl-arm64-agent + - build-linux-musl-arm64-probe + - build-linux-musl-arm64-enroll + - build-macos-x64-agent + - build-macos-x64-probe + - build-macos-x64-enroll + - build-macos-arm64-agent + - build-macos-arm64-probe + - build-macos-arm64-enroll + - build-windows-x64-agent + - build-windows-x64-probe + - build-windows-x64-enroll + - build-windows-arm64-agent + - build-windows-arm64-probe + - build-windows-arm64-enroll diff --git a/README.md b/README.md index 8ef7dba..dfc4fb4 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ Probe <──测量会话──> Agent ──长连接与数据上报──> Bac ## 快速开始 ### 环境要求 -[README.md](backend/README.md) - .NET SDK 10;项目使用 `backend/global.json` 指定 SDK 版本和滚动策略。 - Node.js 与 npm,用于构建 Web 前端。 @@ -43,7 +42,9 @@ npm ci --prefix web npm run build --prefix web ``` -前端产物位于 `web/display/dist/` 和 `web/admin/dist/`。NativeAOT 发布还需要对应平台的 C/C++ 工具链;Linux 发布脚本和 RID 说明见 [`docs/deployment.md`](docs/deployment.md)。 +前端产物位于 `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)。 ### 本地运行 diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index dd2894b..bbd8e84 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -5,6 +5,8 @@ enable enable true + none + false latest-recommended true true diff --git a/backend/README.md b/backend/README.md index 7d3f43f..aae510d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -36,7 +36,9 @@ dotnet test --solution Monitor.slnx dotnet publish src/Monitor.Backend -c Release -r -p:PublishAot=true ``` -例如 `` 可为 `win-x64`、`linux-x64` 或 `linux-musl-x64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh` 也提供发布和容器构建流程,详见 [`../docs/deployment.md`](../docs/deployment.md)。 +例如 `` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend ` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/deployment.md`](../docs/deployment.md)。 + +正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。 ## 本地运行 diff --git a/backend/agent/src/Monitor.Agent/Monitor.Agent.csproj b/backend/agent/src/Monitor.Agent/Monitor.Agent.csproj index 8ff3c29..86652a2 100644 --- a/backend/agent/src/Monitor.Agent/Monitor.Agent.csproj +++ b/backend/agent/src/Monitor.Agent/Monitor.Agent.csproj @@ -1,4 +1,7 @@ - + + + Exe monitor-agent @@ -11,4 +14,7 @@ + + diff --git a/backend/fleet/tools/fleet-enroll/fleet-enroll.csproj b/backend/fleet/tools/fleet-enroll/fleet-enroll.csproj index 96f2d53..08e149e 100644 --- a/backend/fleet/tools/fleet-enroll/fleet-enroll.csproj +++ b/backend/fleet/tools/fleet-enroll/fleet-enroll.csproj @@ -1,4 +1,7 @@ - + + + Exe fleet-enroll @@ -8,4 +11,7 @@ + + diff --git a/backend/global.json b/backend/global.json index a4e7375..2f5da59 100644 --- a/backend/global.json +++ b/backend/global.json @@ -3,6 +3,9 @@ "version": "10.0.200", "rollForward": "latestFeature" }, + "msbuild-sdks": { + "StuDev.AotAnywhere": "1.0.5" + }, "test": { "runner": "Microsoft.Testing.Platform" } diff --git a/backend/probe/src/Monitor.Probe/Monitor.Probe.csproj b/backend/probe/src/Monitor.Probe/Monitor.Probe.csproj index 9621c17..22b9da3 100644 --- a/backend/probe/src/Monitor.Probe/Monitor.Probe.csproj +++ b/backend/probe/src/Monitor.Probe/Monitor.Probe.csproj @@ -1,4 +1,7 @@ - + + + Exe monitor-probe @@ -11,4 +14,7 @@ + + diff --git a/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs b/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs index 5c41db0..cc4ae75 100644 --- a/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs +++ b/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs @@ -252,9 +252,32 @@ public sealed class AgentEndToEndTests await using ProbeSimulator probe = await agent.ConnectProbeAsync(); await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); - using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } })); - Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync()); - string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); + HttpResponseMessage? accepted = null; + string? startFailure = null; + for (int attempt = 0; attempt < 5; attempt++) + { + accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } })); + if (accepted.StatusCode == System.Net.HttpStatusCode.Accepted) + { + break; + } + + startFailure = await accepted.Content.ReadAsStringAsync(); + string? code = JsonNode.Parse(startFailure)?["code"]?.GetValue(); + accepted.Dispose(); + accepted = null; + if (code != "probe_offline" || attempt == 4) + { + Assert.Fail($"The test request was rejected: {startFailure}"); + } + + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue(), TimeSpan.FromSeconds(15), "the probe should reconnect after a transient disconnect"); + } + + HttpResponseMessage acceptedResponse = accepted ?? throw new InvalidOperationException("The test request was not accepted."); + using (acceptedResponse) + { + string testId = JsonNode.Parse(await acceptedResponse.Content.ReadAsStringAsync())!["id"]!.GetValue(); JsonNode test = null!; await BackendFixture.EventuallyAsync( @@ -273,6 +296,7 @@ public sealed class AgentEndToEndTests await BackendFixture.EventuallyAsync( async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue() == "udp_train") == agent.App.Counters.Fragments, because: (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.Id}")).ToJsonString() + $" fragments={agent.App.Counters.Fragments} discarded={agent.App.Counters.FragmentsDiscarded} pending={agent.App.Link.Outbox.PendingCount}/{agent.App.Link.Outbox.PendingBytes} dropped={agent.App.Link.Outbox.Dropped} link={agent.App.Link.IsConnected}"); + } } [Fact] diff --git a/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs b/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs index 304dcfb..bf95a14 100644 --- a/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs +++ b/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs @@ -199,3 +199,6 @@ public sealed class ApiBasicsTests [CollectionDefinition("backend")] public sealed class BackendCollection : ICollectionFixture; + +[CollectionDefinition("probe-e2e")] +public sealed class ProbeEndToEndCollection : ICollectionFixture; diff --git a/backend/tests/Monitor.Backend.Tests/ArchitectureTests.cs b/backend/tests/Monitor.Backend.Tests/ArchitectureTests.cs index 1c54695..cf81a17 100644 --- a/backend/tests/Monitor.Backend.Tests/ArchitectureTests.cs +++ b/backend/tests/Monitor.Backend.Tests/ArchitectureTests.cs @@ -36,10 +36,13 @@ public sealed class ArchitectureTests Path.GetFileNameWithoutExtension(path), path, Path.GetDirectoryName(path)!, - [.. items.Where(e => e.Name == "ProjectReference").Select(e => Path.GetFileNameWithoutExtension((string?)e.Attribute("Include") ?? ""))], + [.. items.Where(e => e.Name == "ProjectReference").Select(e => Path.GetFileNameWithoutExtension(NormalizePath((string?)e.Attribute("Include") ?? "")))], [.. items.Where(e => e.Name == "PackageReference").Select(e => (string?)e.Attribute("Include") ?? "")]); })]; + private static string NormalizePath(string path) => + path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + private static bool IsTest(Project p) => p.Path.Contains(Path.DirectorySeparatorChar + "tests" + Path.DirectorySeparatorChar, StringComparison.Ordinal); private static bool IsFleet(Project p) => p.Name.StartsWith("Fleet.", StringComparison.Ordinal) && !IsTest(p); diff --git a/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs b/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs index 652ccdf..d5f1b85 100644 --- a/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs +++ b/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs @@ -3,7 +3,7 @@ using System.Text.Json.Nodes; namespace Monitor.Backend.Tests; /// 真实的后端 + 真实的 Agent + 真实的 Probe(都在进程内,回环网络)。 -[Collection("backend")] +[Collection("probe-e2e")] public sealed class ProbeEndToEndTests { private readonly BackendFixture _backend; diff --git a/ci/Monitor.Tar/Monitor.Tar.csproj b/ci/Monitor.Tar/Monitor.Tar.csproj new file mode 100644 index 0000000..238f157 --- /dev/null +++ b/ci/Monitor.Tar/Monitor.Tar.csproj @@ -0,0 +1,8 @@ + + + Exe + net10.0 + enable + enable + + diff --git a/ci/Monitor.Tar/Program.cs b/ci/Monitor.Tar/Program.cs new file mode 100644 index 0000000..a66e340 --- /dev/null +++ b/ci/Monitor.Tar/Program.cs @@ -0,0 +1,38 @@ +using System.Formats.Tar; +using System.IO.Compression; + +if (args.Length < 3) +{ + Console.Error.WriteLine("usage: Monitor.Tar ..."); + return 2; +} + +string sourceDirectory = Path.GetFullPath(args[0]); +string archivePath = Path.GetFullPath(args[1]); +HashSet executables = args.Skip(2).Select(path => path.Replace('\\', '/')).ToHashSet(StringComparer.Ordinal); +UnixFileMode executableMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute; +UnixFileMode regularMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | + UnixFileMode.GroupRead | UnixFileMode.OtherRead; + +Directory.CreateDirectory(Path.GetDirectoryName(archivePath)!); +using FileStream fileStream = File.Create(archivePath); +using GZipStream gzipStream = new(fileStream, CompressionLevel.Optimal, leaveOpen: true); +using TarWriter writer = new(gzipStream, TarEntryFormat.Pax, leaveOpen: true); + +foreach (string filePath in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) +{ + string relativePath = Path.GetRelativePath(sourceDirectory, filePath).Replace('\\', '/'); + PaxTarEntry entry = new(TarEntryType.RegularFile, relativePath) + { + Mode = executables.Contains(relativePath) ? executableMode : regularMode, + DataStream = File.OpenRead(filePath) + }; + using (entry.DataStream) + { + writer.WriteEntry(entry); + } +} + +return 0; diff --git a/ci/index-backend-image.sh b/ci/index-backend-image.sh new file mode 100644 index 0000000..c2d1042 --- /dev/null +++ b/ci/index-backend-image.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Publish a multi-architecture OCI index for the two architecture-specific +# Backend images already pushed by release-linux.sh. +set -euo pipefail + +version="${CI_COMMIT_TAG#v}" +[[ "${CI_COMMIT_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 +} + +registry="${ZOT_REGISTRY:-192.168.2.162:5000}" +scheme="${ZOT_SCHEME:-http}" +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" + +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' + +manifest_head() { + local tag="$1" + curl --silent --show-error --fail --head --netrc-file "$auth_file" \ + -H "Accept: $accept" "$scheme://$registry/v2/$image/manifests/$tag" \ + | tr -d '\r' | awk ' + tolower($1)=="docker-content-digest:" { digest=$2 } + tolower($1)=="content-length:" { size=$2 } + tolower($1)=="content-type:" { media=$2 } + END { if (digest && size && media) print digest, size, media; else exit 1 }' +} + +amd="$(manifest_head "$version-amd64")" +arm="$(manifest_head "$version-arm64")" +read -r amd_digest amd_size amd_media <<< "$amd" +read -r arm_digest arm_size arm_media <<< "$arm" +index_media='application/vnd.oci.image.index.v1+json' +body="$(printf '{"schemaVersion":2,"mediaType":"%s","manifests":[{"mediaType":"%s","digest":"%s","size":%s,"platform":{"architecture":"amd64","os":"linux"}},{"mediaType":"%s","digest":"%s","size":%s,"platform":{"architecture":"arm64","os":"linux"}}]}' \ + "$index_media" "$amd_media" "$amd_digest" "$amd_size" "$arm_media" "$arm_digest" "$arm_size")" + +put_tag() { + local tag="$1" code + code="$(curl --silent --show-error --netrc-file "$auth_file" -o /dev/null -w '%{http_code}' \ + -H "Content-Type: $index_media" -X PUT --data-binary "$body" \ + "$scheme://$registry/v2/$image/manifests/$tag")" + case "$code" in + 200|201) echo "published $image:$tag" ;; + *) echo "failed to publish $image:$tag (HTTP $code)" >&2; return 1 ;; + esac +} + +put_tag "$version" +if [[ "$version" != *-* ]]; then + put_tag latest +fi diff --git a/ci/package-cross-platform.ps1 b/ci/package-cross-platform.ps1 new file mode 100644 index 0000000..32c02be --- /dev/null +++ b/ci/package-cross-platform.ps1 @@ -0,0 +1,172 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not $env:CI_COMMIT_TAG -or $env:CI_COMMIT_TAG -notmatch '^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$') { + throw 'Release must run from a vMAJOR.MINOR.PATCH tag.' +} +if (-not $env:GITEA_TOKEN) { + throw 'GITEA_TOKEN is required.' +} +$version = $Matches[1] +$root = Split-Path -Parent $PSScriptRoot +$backend = Join-Path $root 'backend' +$projectVersion = [regex]::Match((Get-Content (Join-Path $backend 'Directory.Build.props') -Raw), '([^<]+)').Groups[1].Value +if ($version -ne $projectVersion) { + throw "Tag version $version does not match project version $projectVersion." +} + +$tarProject = Join-Path $PSScriptRoot 'Monitor.Tar\Monitor.Tar.csproj' +$tarTool = Join-Path $PSScriptRoot 'Monitor.Tar\bin\Release\net10.0\Monitor.Tar.dll' +& dotnet build $tarProject -c Release --nologo +if ($LASTEXITCODE -ne 0) { + throw 'Could not build the cross-platform tar helper.' +} +$giteaBase = if ($env:GITEA_BASE) { $env:GITEA_BASE.TrimEnd('/') } else { 'http://localhost:3100' } +$netrc = Join-Path $env:TEMP ("monitor-gitea-$([guid]::NewGuid().ToString('N')).netrc") +$giteaHost = ([Uri]$giteaBase).Host +Set-Content -LiteralPath $netrc -Value "machine $giteaHost login justme password $($env:GITEA_TOKEN)" -Encoding ascii -NoNewline + +function New-TarGzArchive([string]$SourceDirectory, [string]$ArchivePath, [string[]]$Executables) { + & dotnet $tarTool $SourceDirectory $ArchivePath @Executables + if ($LASTEXITCODE -ne 0) { + throw "Could not create tar archive $ArchivePath." + } +} + +try { + $targets = @( + @{ Rid = 'linux-x64'; Os = 'linux'; Arch = 'amd64'; Windows = $false }, + @{ Rid = 'linux-arm64'; Os = 'linux'; Arch = 'arm64'; Windows = $false }, + @{ Rid = 'linux-musl-x64'; Os = 'linux'; Arch = 'musl-amd64'; Windows = $false }, + @{ Rid = 'linux-musl-arm64'; Os = 'linux'; Arch = 'musl-arm64'; Windows = $false }, + @{ Rid = 'osx-x64'; Os = 'macos'; Arch = 'x64'; Windows = $false }, + @{ Rid = 'osx-arm64'; Os = 'macos'; Arch = 'arm64'; Windows = $false }, + @{ Rid = 'win-x64'; Os = 'windows'; Arch = 'amd64'; Windows = $true }, + @{ Rid = 'win-arm64'; Os = 'windows'; Arch = 'arm64'; Windows = $true } + ) + $packageRoot = Join-Path $root '.release\packages' + $null = New-Item -ItemType Directory -Force -Path $packageRoot + $uploadFiles = [System.Collections.Generic.List[string]]::new() + foreach ($target in $targets) { + $agentDirectory = Join-Path $root ".release\publish\$($target.Rid)\agent" + $probeDirectory = Join-Path $root ".release\publish\$($target.Rid)\probe" + $enrollDirectory = Join-Path $root ".release\publish\$($target.Rid)\fleet-enroll" + foreach ($path in @($agentDirectory, $probeDirectory, $enrollDirectory)) { + if (-not (Test-Path -LiteralPath $path -PathType Container)) { + throw "Build output is missing: $path" + } + } + + foreach ($role in @('agent', 'probe')) { + $stage = Join-Path $root ".release\stage\$($target.Rid)\$role" + $null = New-Item -ItemType Directory -Force -Path $stage + $extension = if ($target.Windows) { '.exe' } else { '' } + Copy-Item -LiteralPath (Join-Path (Join-Path $root ".release\publish\$($target.Rid)\$role") "monitor-$role$extension") -Destination $stage + Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $stage + Copy-Item -LiteralPath (Join-Path $root "backend\$role\README.md") -Destination $stage + + $executables = @("monitor-$role$extension") + if ($role -eq 'agent') { + $enrollName = if ($target.Windows) { 'fleet-enroll.exe' } else { 'fleet-enroll' } + Copy-Item -LiteralPath (Join-Path $enrollDirectory $enrollName) -Destination $stage + $executables += $enrollName + } + if ($target.Os -eq 'linux') { + Copy-Item -LiteralPath (Join-Path $root 'packaging/install.sh') -Destination $stage + $executables += 'install.sh' + $systemd = Join-Path $stage 'systemd' + $null = New-Item -ItemType Directory -Force -Path $systemd + Copy-Item -LiteralPath (Join-Path $root "packaging/systemd/monitor-$role.service") -Destination $systemd + if ($role -eq 'probe') { + $openwrt = Join-Path $stage 'openwrt' + $null = New-Item -ItemType Directory -Force -Path $openwrt + Copy-Item -LiteralPath (Join-Path $root 'packaging/openwrt/monitor-probe.init') -Destination $openwrt + Copy-Item -LiteralPath (Join-Path $root 'packaging/openwrt/monitor.keep') -Destination $openwrt + } + } + elseif ($target.Os -eq 'macos') { + @' +This macOS NativeAOT binary is not signed with a Developer ID certificate and +has not been notarized by Apple. Apple Silicon builds receive an ad-hoc code +signature; Intel builds are unsigned. Gatekeeper may block browser-downloaded +files. A Developer ID signature and notarization are required for a seamless +consumer install. +'@ | Set-Content -LiteralPath (Join-Path $stage 'MACOS-SECURITY.txt') -Encoding utf8 + } + + $archiveName = if ($target.Windows) { "monitor-$role-v$version-windows-$($target.Arch).zip" } else { "monitor-$role-v$version-$($target.Os)-$($target.Arch).tar.gz" } + $archive = Join-Path $packageRoot $archiveName + if ($target.Windows) { + Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $archive -CompressionLevel Optimal -Force + } + else { + New-TarGzArchive $stage $archive $executables + } + $hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + $checksum = "$hash $archiveName`n" + $checksumPath = "$archive.sha256" + [IO.File]::WriteAllText($checksumPath, $checksum, [Text.UTF8Encoding]::new($false)) + [void]$uploadFiles.Add($archive) + [void]$uploadFiles.Add($checksumPath) + } + } + + $uploadScript = @' +param([string]$File, [string]$Base, [string]$PackageVersion, [string]$Netrc) +$ErrorActionPreference = 'Stop' +$name = [IO.Path]::GetFileName($File) +$url = "$Base/api/packages/justme/generic/monitor-node/$PackageVersion/$name" +$status = & curl.exe --silent --show-error --retry 3 --netrc-file $Netrc --upload-file $File --output NUL --write-out '%{http_code}' $url +if ($LASTEXITCODE -ne 0) { + throw "Gitea package upload failed for $name (curl exit $LASTEXITCODE)." +} +if ($status -match '^2') { + "uploaded Gitea package $name" +} +elseif ($status -eq '409') { + "Gitea package $name already exists; reusing the existing release file" +} +else { + throw "Gitea package upload failed for $name (HTTP $status)." +} +'@ + $runspacePool = [RunspaceFactory]::CreateRunspacePool(1, 8) + $runspacePool.Open() + $pendingUploads = [System.Collections.Generic.List[object]]::new() + try { + foreach ($file in $uploadFiles) { + $powerShell = [PowerShell]::Create() + $powerShell.RunspacePool = $runspacePool + [void]$powerShell.AddScript($uploadScript).AddArgument($file).AddArgument($giteaBase).AddArgument($version).AddArgument($netrc) + $pendingUploads.Add([pscustomobject]@{ PowerShell = $powerShell; Handle = $powerShell.BeginInvoke() }) + } + + $uploadErrors = [System.Collections.Generic.List[string]]::new() + foreach ($pending in $pendingUploads) { + try { + foreach ($message in $pending.PowerShell.EndInvoke($pending.Handle)) { + Write-Output $message + } + foreach ($errorRecord in $pending.PowerShell.Streams.Error) { + $uploadErrors.Add($errorRecord.ToString()) + } + } + catch { + $uploadErrors.Add($_.Exception.Message) + } + } + if ($uploadErrors.Count -gt 0) { + throw "One or more Gitea package uploads failed:`n$($uploadErrors -join "`n")" + } + } + finally { + foreach ($pending in $pendingUploads) { + $pending.PowerShell.Dispose() + } + $runspacePool.Close() + $runspacePool.Dispose() + } +} +finally { + Remove-Item -LiteralPath $netrc -Force -ErrorAction SilentlyContinue +} diff --git a/ci/publish-gitea-release.sh b/ci/publish-gitea-release.sh new file mode 100644 index 0000000..4f0b182 --- /dev/null +++ b/ci/publish-gitea-release.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Create/update the Gitea Release after all platform package workflows succeed. +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; } + +version="${tag#v}" +gitea_base="${GITEA_BASE:-http://192.168.2.162:3100}" +gitea_base="${gitea_base%/}" +gitea_host="${gitea_base#https://}" +gitea_host="${gitea_host#http://}" +gitea_host="${gitea_host%%/*}" +gitea_host="${gitea_host%%:*}" +api="$gitea_base/api/v1/repos/justme/MyMonitor" +tmp="$(mktemp -d)" +netrc="$tmp/netrc" +release_json="$tmp/release.json" +assets_json="$tmp/assets.json" +chmod 700 "$tmp" +printf 'machine %s login justme password %s\n' "$gitea_host" "$GITEA_TOKEN" > "$netrc" +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)." +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" \ + --output "$release_json" --write-out '%{http_code}' "$api/releases/tags/$tag")" +release_created=false +if [[ "$release_status" == 404 ]]; then + release_status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \ + --header 'Content-Type: application/json' --data "$payload" \ + --output "$release_json" --write-out '%{http_code}' "$api/releases")" + release_created=true +fi +[[ "$release_status" =~ ^2 ]] || { + echo "Gitea Release lookup/create failed (HTTP $release_status)" >&2 + cat "$release_json" >&2 + exit 1 +} +release_id="$(grep -o '"id":[[:space:]]*[0-9][0-9]*' "$release_json" | head -1 | tr -cd '0-9')" +[[ -n "$release_id" ]] || { echo "Could not read Gitea Release ID" >&2; exit 1; } +if [[ "$release_created" == false ]]; then + update_response="$tmp/release-update.json" + update_status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \ + --request PATCH --header 'Content-Type: application/json' --data "$payload" \ + --output "$update_response" --write-out '%{http_code}' "$api/releases/$release_id")" + [[ "$update_status" =~ ^2 ]] || { + echo "Gitea Release update failed (HTTP $update_status)" >&2 + cat "$update_response" >&2 + exit 1 + } +fi +curl --fail-with-body --silent --show-error --retry 3 --netrc-file "$netrc" \ + "$api/releases/$release_id/assets" -o "$assets_json" + +assets=( + "monitor-agent-v$version-linux-amd64.tar.gz" + "monitor-agent-v$version-linux-arm64.tar.gz" + "monitor-probe-v$version-linux-amd64.tar.gz" + "monitor-probe-v$version-linux-arm64.tar.gz" + "monitor-agent-v$version-linux-musl-amd64.tar.gz" + "monitor-probe-v$version-linux-musl-amd64.tar.gz" + "monitor-agent-v$version-linux-musl-arm64.tar.gz" + "monitor-probe-v$version-linux-musl-arm64.tar.gz" + "monitor-agent-v$version-macos-x64.tar.gz" + "monitor-probe-v$version-macos-x64.tar.gz" + "monitor-agent-v$version-macos-arm64.tar.gz" + "monitor-probe-v$version-macos-arm64.tar.gz" + "monitor-agent-v$version-windows-amd64.zip" + "monitor-agent-v$version-windows-arm64.zip" + "monitor-probe-v$version-windows-amd64.zip" + "monitor-probe-v$version-windows-arm64.zip" +) + +upload_release_asset() { + local name="$1" file="$tmp/$1" upload_response="$tmp/$1.upload.json" status + curl --fail-with-body --silent --show-error --retry 3 --netrc-file "$netrc" \ + "$gitea_base/api/packages/justme/generic/monitor-node/$version/$name" -o "$file" + status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \ + --request POST --header 'Content-Type: application/octet-stream' --data-binary "@$file" \ + --output "$upload_response" --write-out '%{http_code}' \ + "$api/releases/$release_id/assets?name=$name")" + case "$status" in + 2*) echo "attached $name" ;; + *) echo "Gitea Release asset upload failed for $name (HTTP $status)" >&2; cat "$upload_response" >&2; return 1 ;; + esac +} + +pids=() +for archive in "${assets[@]}"; do + for name in "$archive" "$archive.sha256"; do + if grep -Eq "\"name\"[[:space:]]*:[[:space:]]*\"$name\"" "$assets_json"; then + echo "release asset $name already exists; keeping the existing asset" + continue + fi + upload_release_asset "$name" & + pids+=("$!") + if ((${#pids[@]} >= 8)); then + wait "${pids[0]}" + pids=("${pids[@]:1}") + fi + done +done +for pid in "${pids[@]}"; do + wait "$pid" +done + +echo "published Gitea Release $tag: https://git.oeo.one/justme/MyMonitor/releases/tag/$tag" diff --git a/ci/release-cross-platform.ps1 b/ci/release-cross-platform.ps1 new file mode 100644 index 0000000..f45f67c --- /dev/null +++ b/ci/release-cross-platform.ps1 @@ -0,0 +1,75 @@ +param( + [Parameter(Mandatory)] + [ValidateSet( + 'linux-x64', 'linux-arm64', + 'linux-musl-x64', 'linux-musl-arm64', + 'osx-x64', 'osx-arm64', 'win-x64', 'win-arm64' + )] + [string]$RuntimeIdentifier, + [Parameter(Mandatory)] + [ValidateSet('agent', 'probe', 'fleet-enroll')] + [string]$Component +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not $env:CI_COMMIT_TAG -or $env:CI_COMMIT_TAG -notmatch '^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$') { + throw 'Release must run from a vMAJOR.MINOR.PATCH tag.' +} +$version = $Matches[1] +$root = Split-Path -Parent $PSScriptRoot +$backend = Join-Path $root 'backend' +$projectVersion = [regex]::Match((Get-Content (Join-Path $backend 'Directory.Build.props') -Raw), '([^<]+)').Groups[1].Value +if ($version -ne $projectVersion) { + throw "Tag version $version does not match project version $projectVersion." +} + +if ($RuntimeIdentifier -in @('win-x64', 'win-arm64')) { + $vcArchitecture = if ($RuntimeIdentifier -eq 'win-x64') { 'x64' } else { 'arm64' } + $vsWhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + $vsPath = & $vsWhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vsPath) { + throw 'Visual Studio C++ Build Tools were not found.' + } + $devCmd = Join-Path $vsPath 'Common7\Tools\VsDevCmd.bat' + $environmentLines = & cmd.exe /d /s /c "call `"$devCmd`" -no_logo -arch=$vcArchitecture -host_arch=x64 && set" + foreach ($line in $environmentLines) { + $separator = $line.IndexOf('=') + if ($separator -gt 0) { + [Environment]::SetEnvironmentVariable($line.Substring(0, $separator), $line.Substring($separator + 1), 'Process') + } + } +} + +$project = switch ($Component) { + 'agent' { 'agent/src/Monitor.Agent' } + 'probe' { 'probe/src/Monitor.Probe' } + 'fleet-enroll' { 'fleet/tools/fleet-enroll' } +} +$output = Join-Path $root ".release\publish\$RuntimeIdentifier\$Component" +$buildRoot = Join-Path $root ".release\build\$RuntimeIdentifier-$Component" +$null = New-Item -ItemType Directory -Force -Path $output + +Push-Location $backend +try { + & dotnet publish $project ` + -m:1 ` + -c Release ` + -r $RuntimeIdentifier ` + --self-contained true ` + -p:PublishAot=true ` + -p:StripSymbols=true ` + -p:DebugType=none ` + -p:DebugSymbols=false ` + --artifacts-path $buildRoot ` + -o $output + if ($LASTEXITCODE -ne 0) { + throw "NativeAOT publish failed for $project ($RuntimeIdentifier)." + } +} +finally { + Pop-Location +} + +Write-Output "built $Component for $RuntimeIdentifier" diff --git a/ci/release-linux.sh b/ci/release-linux.sh new file mode 100644 index 0000000..9736ac1 --- /dev/null +++ b/ci/release-linux.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Publish one framework-dependent Backend OCI image for the requested arch. +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:-}" ]] || { + echo "Zot credentials are required" >&2; exit 2; +} +case "$arch" in + amd64|arm64) ;; + *) 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 + echo "tag version $version does not match project version $project_version" >&2 + exit 1 +fi + +registry="${ZOT_REGISTRY:-192.168.2.162:5000}" +image="$registry/monitor/monitor-backend" +output="$root/.release/linux-$arch/backend" +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" + +(cd "$root/backend" && dotnet publish src/Monitor.Backend \ + -m:3 -c Release --no-self-contained \ + -p:DebugType=none -p:DebugSymbols=false \ + -p:UseAppHost=false -p:OpenApiGenerateDocuments=false \ + --artifacts-path "$build_root" -o "$output") +find "$output" -type f -name '*.pdb' -delete +cp -a "$output/." "$container_context/app/" +: > "$container_context/data/.keep" + +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 + 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; } +local_image="localhost/monitor-backend:$version-$arch" +buildah bud --tls-verify=false --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" diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..93b92cc --- /dev/null +++ b/compose.yaml @@ -0,0 +1,26 @@ +services: + backend: + image: registry.oeo.one/monitor/monitor-backend:${MONITOR_VERSION:-0.1.0} + container_name: monitor-backend + restart: unless-stopped + init: true + ports: + - "127.0.0.1:8080:8080" + environment: + ASPNETCORE_HTTP_PORTS: "8080" + Monitor__DataDirectory: /data + Monitor__InitialAdminUser: ${MONITOR_ADMIN_USER:-admin} + Monitor__InitialAdminPassword: "${MONITOR_ADMIN_PASSWORD:?Set MONITOR_ADMIN_PASSWORD in the environment}" + volumes: + - monitor-data:/data + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + stop_grace_period: 30s + +volumes: + monitor-data: diff --git a/docs/deployment.md b/docs/deployment.md index 69ec9d1..22860d1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -11,12 +11,32 @@ scripts/package.sh → backend/art | rid | 目标 | 构建方式 | | --- | --- | --- | -| `linux-musl-x64` | Alpine、OpenWrt x86 | Alpine 的 NativeAOT SDK 容器(任何有 Docker 的主机,包括 Windows) | -| `linux-x64` | Debian、Ubuntu 等 glibc 发行版 | Ubuntu noble 的 NativeAOT SDK 容器 | -| `win-x64` | Windows | 在 Windows 上直接 `dotnet publish -p:PublishAot=true`(需要 VS 的 C++ 工具链,`vswhere.exe` 所在目录要在 PATH 里) | -| `linux-musl-arm64`、`linux-arm64` | 路由器、树莓派 | 需要在 arm64 主机(或 CI 的 arm64 runner)上运行同一个脚本;脚本在 x64 主机上会拒绝并说明 | +| `linux-x64`、`linux-arm64` | Debian/Ubuntu glibc 服务器 | Windows CI 主机通过 AotAnywhere 交叉编译 | +| `linux-musl-x64`、`linux-musl-arm64` | Alpine、amd64 OpenWrt、aarch64 OpenWrt 路由器 | Windows CI 主机通过 AotAnywhere 交叉编译 | +| `osx-x64`、`osx-arm64` | Intel Mac、Apple Silicon | Windows CI 主机通过 AotAnywhere 交叉编译;发布包未做 Developer ID 签名或 Apple 公证 | +| `win-x64`、`win-arm64` | Windows x64、ARM64 | Windows CI 主机上的 Visual Studio C++ 工具链 | -NativeAOT 不能交叉编译到别的操作系统;MIPS 不支持(见 `project-layout.md`)。 +macOS 的 Apple Silicon 产物带临时 ad-hoc 签名,Intel 产物未签名。通过浏览器下载时,macOS Gatekeeper 可能阻止启动;面向普通用户分发前需要 Developer ID 签名和 Apple 公证。MIPS 不在当前目标列表中(见 `project-layout.md`)。 + +仓库内的 `scripts/publish.sh` 面向本机目标平台;Woodpecker 的 tag 流水线在 Windows runner 上构建 Agent、Probe 和 `fleet-enroll` 的所有发布目标,Backend OCI 镜像仍在 Linux runner 上构建。 + +## Backend 容器 + +Backend 使用 OCI 标准镜像,通过 `registry.oeo.one/monitor/monitor-backend:` 分发,支持 `linux/amd64` 和 `linux/arm64`。容器基于 .NET 10 ASP.NET noble-chiseled-extra 运行时镜像,以非 root 用户运行,在 `8080/tcp` 提供 HTTP 服务;数据库和原始数据统一写入 `/data`。`extra` 变体带 IANA 时区数据,供按时区查询使用。将 `/data` 挂载为持久卷,并在反向代理处配置 HTTPS。 + +在部署目录保存此仓库的 `compose.yaml`,先通过部署平台的环境变量或密钥管理设置 `MONITOR_ADMIN_PASSWORD`(可选设置 `MONITOR_ADMIN_USER`、`MONITOR_VERSION`),然后启动: + +```sh +docker compose up -d +``` + +Compose 将容器端口只绑定到宿主机 `127.0.0.1:8080`,适合由同机 HTTPS 反向代理转发;数据保存在 Docker 命名卷 `monitor-data`。首次启动成功创建管理员后,改密应在管理端完成。 + +健康检查端点为 `GET /healthz`。容器定义见 [`../packaging/container/Backend.Containerfile`](../packaging/container/Backend.Containerfile)。 + +## 节点二进制发布 + +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)。 实测体积(`StackTraceSupport=false`、`IlcFoldIdenticalMethodBodies=true`、`OptimizationPreference=Size`): diff --git a/packaging/container/Backend.Containerfile b/packaging/container/Backend.Containerfile new file mode 100644 index 0000000..2bde19a --- /dev/null +++ b/packaging/container/Backend.Containerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra + +ARG APP_VERSION=unknown +LABEL org.opencontainers.image.title="Monitor Backend" \ + org.opencontainers.image.description="Self-hosted network path quality monitoring backend" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.version="$APP_VERSION" + +WORKDIR /app +COPY --chown=1654:1654 app/ /app/ +COPY --chown=1654:1654 data/.keep /data/.keep + +ENV ASPNETCORE_HTTP_PORTS=8080 \ + Monitor__DataDirectory=/data \ + DOTNET_EnableDiagnostics=0 + +USER 1654:1654 +VOLUME ["/data"] +EXPOSE 8080 +ENTRYPOINT ["dotnet", "/app/monitor-backend.dll"] diff --git a/scripts/publish.sh b/scripts/publish.sh index ab745e4..fe1fc70 100644 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -17,7 +17,7 @@ case "$project" in esac out="backend/artifacts/publish/$project/$rid" -args=(publish "$csproj" -c Release -r "$rid" -p:PublishAot=true -p:DebugType=none -o "/src/$out" --artifacts-path /artifacts -nologo -v q) +args=(publish "$csproj" -c Release -r "$rid" -p:PublishAot=true -p:DebugType=none -p:DebugSymbols=false -o "/src/$out" --artifacts-path /artifacts -nologo -v q) case "$rid" in linux-musl-x64) @@ -34,7 +34,7 @@ case "$rid" in ;; *) cd "$root/backend" # global.json 在 backend/ 下 - dotnet publish "${csproj#backend/}" -c Release -r "$rid" -p:PublishAot=true -o "$root/$out" -nologo -v q + dotnet publish "${csproj#backend/}" -c Release -r "$rid" -p:PublishAot=true -p:DebugType=none -p:DebugSymbols=false -o "$root/$out" -nologo -v q ;; esac