From 12dfaf71e82749bec2ff2e6279b8a9c9dac2f3c5 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Mon, 10 Aug 2026 17:23:48 +0700 Subject: [PATCH] [verified] Fix monitor snapshot contract and release compatibility --- .github/workflows/linux-control-agent.yml | 33 +++- .github/workflows/linux-release.yml | 26 ++- README.md | 2 +- deploy/ochenstarik-server-monitor-manager.sh | 13 +- deploy/smm-setup.sh | 102 ++++++++++++ docs/installer-contract.md | 6 +- docs/release-policy.md | 9 ++ .../DesktopMonitorSnapshotContractTests.cs | 71 +++++++++ .../SchemaCompatibilityTests.cs | 148 ++++++++++++++---- tests/acceptance/generate_alpha7_db.sh | 41 ----- tests/acceptance/test_monitor_snapshot.sh | 82 +++++----- .../acceptance/test_previous_schema_arm64.sh | 44 ++++++ .../test_previous_schema_contract.sh | 42 +++++ tests/bootstrap/test-release-contract.sh | 72 +++++++++ tests/contracts/monitor-snapshot-v1.txt | 18 +++ tests/fixtures/README.md | 11 ++ tests/fixtures/control-v0.1.0-alpha.7.db | Bin 0 -> 167936 bytes 17 files changed, 590 insertions(+), 130 deletions(-) create mode 100644 deploy/smm-setup.sh create mode 100644 docs/release-policy.md create mode 100644 tests/ServerMonitorManager.Control.Tests/DesktopMonitorSnapshotContractTests.cs delete mode 100644 tests/acceptance/generate_alpha7_db.sh create mode 100644 tests/acceptance/test_previous_schema_arm64.sh create mode 100644 tests/acceptance/test_previous_schema_contract.sh create mode 100644 tests/bootstrap/test-release-contract.sh create mode 100644 tests/contracts/monitor-snapshot-v1.txt create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/control-v0.1.0-alpha.7.db diff --git a/.github/workflows/linux-control-agent.yml b/.github/workflows/linux-control-agent.yml index fb27dc6..94c19cc 100644 --- a/.github/workflows/linux-control-agent.yml +++ b/.github/workflows/linux-control-agent.yml @@ -26,9 +26,6 @@ jobs: - name: Build run: dotnet build ServerMonitorManager.slnx --configuration Release --no-restore - - name: Generate reference alpha.7 control database - run: bash tests/acceptance/generate_alpha7_db.sh tests/acceptance/alpha7.db - - name: Test run: dotnet test tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj --configuration Release --no-build @@ -42,6 +39,17 @@ jobs: bash -n tests/acceptance/three-server-mesh.sh shellcheck --severity=error tests/acceptance/three-server-mesh.sh + - name: Verify Monitor snapshot contract + run: | + bash -n tests/acceptance/test_monitor_snapshot.sh + bash -n tests/acceptance/test_previous_schema_contract.sh + bash -n tests/acceptance/test_previous_schema_arm64.sh + shellcheck --severity=error tests/acceptance/test_monitor_snapshot.sh + shellcheck --severity=error tests/acceptance/test_previous_schema_contract.sh + shellcheck --severity=error tests/acceptance/test_previous_schema_arm64.sh + bash tests/acceptance/test_monitor_snapshot.sh + bash tests/acceptance/test_previous_schema_contract.sh + - name: Verify standalone bootstrap run: | bash -n deploy/ochenstarik-server-monitor-manager.sh @@ -50,14 +58,17 @@ jobs: bash -n tests/bootstrap/run-native-systemd-smoke.sh bash -n tests/bootstrap/run-systemd-container-smoke.sh bash -n tests/bootstrap/test-enrollment-token-argv.sh + bash -n tests/bootstrap/test-release-contract.sh shellcheck --severity=error deploy/ochenstarik-server-monitor-manager.sh shellcheck --severity=error deploy/ochenstarik-smm-policy-apply shellcheck --severity=error deploy/ochenstarik-smm-emergency shellcheck --severity=error tests/bootstrap/run-native-systemd-smoke.sh shellcheck --severity=error tests/bootstrap/run-systemd-container-smoke.sh shellcheck --severity=error tests/bootstrap/test-enrollment-token-argv.sh + shellcheck --severity=error tests/bootstrap/test-release-contract.sh bash tests/bootstrap/test-bootstrap-contract.sh bash tests/bootstrap/test-enrollment-token-argv.sh + bash tests/bootstrap/test-release-contract.sh - name: Publish agent amd64 run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true @@ -65,6 +76,22 @@ jobs: - name: Publish agent arm64 run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true + - name: Publish control arm64 compatibility binary + run: dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o compatibility/control-linux-arm64 + + - name: Set up arm64 emulation + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + with: + platforms: arm64 + + - name: Verify previous schema with arm64 published Control + run: | + docker run --rm --platform linux/arm64 \ + --volume "$PWD:/workspace" --workdir /workspace \ + ubuntu:24.04 \ + bash tests/acceptance/test_previous_schema_arm64.sh \ + compatibility/control-linux-arm64/ochenstarik-smm-control + - name: Publish provisioning helper amd64 run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 957e50d..1341fbe 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -23,38 +23,51 @@ jobs: - name: Validate bootstrap run: | bash -n deploy/ochenstarik-server-monitor-manager.sh + bash -n deploy/smm-setup.sh bash -n deploy/ochenstarik-smm-policy-apply bash -n deploy/ochenstarik-smm-emergency bash -n tests/bootstrap/run-native-systemd-smoke.sh bash -n tests/bootstrap/run-systemd-container-smoke.sh shellcheck --severity=error deploy/ochenstarik-server-monitor-manager.sh + shellcheck --severity=error deploy/smm-setup.sh shellcheck --severity=error deploy/ochenstarik-smm-policy-apply shellcheck --severity=error deploy/ochenstarik-smm-emergency shellcheck --severity=error tests/bootstrap/run-native-systemd-smoke.sh shellcheck --severity=error tests/bootstrap/run-systemd-container-smoke.sh bash tests/bootstrap/test-bootstrap-contract.sh bash tests/bootstrap/test-manifest-verification.sh + bash tests/bootstrap/test-release-contract.sh - name: Package bootstrap shell: bash run: | set -Eeuo pipefail - install -m 0755 deploy/ochenstarik-server-monitor-manager.sh ochenstarik-server-monitor-manager.sh + DIST_DIR=dist + install -d "$DIST_DIR" + install -m 0755 deploy/ochenstarik-server-monitor-manager.sh "$DIST_DIR/ochenstarik-server-monitor-manager.sh" + install -m 0755 deploy/smm-setup.sh "$DIST_DIR/smm-setup.sh" # Substitute PROGRAM_VERSION from tag if [[ "${GITHUB_REF}" == refs/tags/* ]]; then - sed -i "s/^PROGRAM_VERSION=.*$/PROGRAM_VERSION=\"${GITHUB_REF_NAME}\"/" ochenstarik-server-monitor-manager.sh + sed -i "s/^PROGRAM_VERSION=.*$/PROGRAM_VERSION=\"${GITHUB_REF_NAME}\"/" "$DIST_DIR/ochenstarik-server-monitor-manager.sh" + grep -Fq "readonly DEFAULT_RELEASE_TAG=\"${GITHUB_REF_NAME}\"" "$DIST_DIR/smm-setup.sh" fi - sha256sum ochenstarik-server-monitor-manager.sh > ochenstarik-server-monitor-manager.sh.sha256 + ( + cd "$DIST_DIR" + sha256sum ochenstarik-server-monitor-manager.sh > ochenstarik-server-monitor-manager.sh.sha256 + sha256sum smm-setup.sh > smm-setup.sh.sha256 + ) - name: Upload bootstrap artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: server-monitor-manager-bootstrap path: | - ochenstarik-server-monitor-manager.sh - ochenstarik-server-monitor-manager.sh.sha256 + dist/ochenstarik-server-monitor-manager.sh + dist/ochenstarik-server-monitor-manager.sh.sha256 + dist/smm-setup.sh + dist/smm-setup.sh.sha256 publish-linux: runs-on: ubuntu-latest @@ -233,6 +246,7 @@ jobs: # Calculate hashes BOOTSTRAP_SHA=$(sha256sum artifacts/server-monitor-manager-bootstrap/ochenstarik-server-monitor-manager.sh | awk '{print $1}') + SETUP_SHA=$(sha256sum artifacts/server-monitor-manager-bootstrap/smm-setup.sh | awk '{print $1}') LINUX_X64_SHA=$(sha256sum artifacts/server-monitor-manager-linux-x64/server-monitor-manager-linux-x64.tar.gz | awk '{print $1}') LINUX_ARM64_SHA=$(sha256sum artifacts/server-monitor-manager-linux-arm64/server-monitor-manager-linux-arm64.tar.gz | awk '{print $1}') MSIX_SHA=$(sha256sum artifacts/server-monitor-manager-win-x64/artifacts/windows-installer/ServerMonitorManager-win-x64.msix | awk '{print $1}') @@ -244,6 +258,7 @@ jobs: --arg schema "smm-manifest/v2" \ --arg version "$VERSION" \ --arg bootstrap_sha256 "$BOOTSTRAP_SHA" \ + --arg setup_sha256 "$SETUP_SHA" \ --arg linux_x64_sha256 "$LINUX_X64_SHA" \ --arg linux_arm64_sha256 "$LINUX_ARM64_SHA" \ --arg msix_sha256 "$MSIX_SHA" \ @@ -264,6 +279,7 @@ jobs: }, hashes: { "ochenstarik-server-monitor-manager.sh": $bootstrap_sha256, + "smm-setup.sh": $setup_sha256, "server-monitor-manager-linux-x64.tar.gz": $linux_x64_sha256, "server-monitor-manager-linux-arm64.tar.gz": $linux_arm64_sha256, "ServerMonitorManager-win-x64.msix": $msix_sha256, diff --git a/README.md b/README.md index 9e3aeb8..39ce4d5 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The control plane and data plane are separated: - **Desktop:** packaged WinUI 3 client with DPAPI-protected operator certificate and SSH identity. - **Agent:** self-contained Linux binary for `amd64` and `arm64`; it only creates outbound mTLS sessions. -See [architecture](docs/architecture.md), [security model](docs/security-model.md), [roadmap](docs/roadmap.md), [Linux bootstrap contract](docs/installer-contract.md), and the [Provisioning and Xray specification](docs/provisioning-vpn-requirements.md). +See [architecture](docs/architecture.md), [security model](docs/security-model.md), [roadmap](docs/roadmap.md), [Linux bootstrap contract](docs/installer-contract.md), [release policy](docs/release-policy.md), and the [Provisioning and Xray specification](docs/provisioning-vpn-requirements.md). Work order is governed by [product horizons](docs/product-horizons.md): the roadmap records what is done, the horizons record what may be started. Planned subsystems are specified separately in [approval policies](docs/approval-policies.md) and the [KAgent integration](docs/integration-kagent.md). Both describe target behaviour and are not descriptions of the current alpha; nothing in them is implemented. diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh index 0dc6203..f5652ed 100755 --- a/deploy/ochenstarik-server-monitor-manager.sh +++ b/deploy/ochenstarik-server-monitor-manager.sh @@ -1245,11 +1245,6 @@ install_monitor() { set -euo pipefail # ochenstarik-smm-metrics -MESH_STATUS="" -if [[ -x /usr/bin/wg && -f /etc/wireguard/smm0.conf ]]; then - MESH_STATUS=$(wg show smm0 2>/dev/null || true) -fi - echo "PROTOCOL=1" echo "HOSTNAME=$(hostname)" UPTIME=$(awk '{print int($1)}' /proc/uptime 2>/dev/null || echo "0") @@ -1287,10 +1282,10 @@ echo "NETWORK_RX_BYTES=${NET_RX}" echo "NETWORK_TX_BYTES=${NET_TX}" KERNEL=$(uname -r 2>/dev/null || echo "unknown") echo "KERNEL=${KERNEL}" -if [ -n "$MESH_STATUS" ]; then - echo "--- MESH STATUS ---" - echo "$MESH_STATUS" -fi +SYSTEMD_SSH=$(systemctl is-active ssh.service 2>/dev/null || true) +echo "SYSTEMD_SSH=${SYSTEMD_SSH:-unknown}" +SYSTEMD_WIREGUARD=$(systemctl is-active wg-quick@smm0.service 2>/dev/null || true) +echo "SYSTEMD_WIREGUARD=${SYSTEMD_WIREGUARD:-unknown}" EOF chown root:root "$metrics_script" chmod 0755 "$metrics_script" diff --git a/deploy/smm-setup.sh b/deploy/smm-setup.sh new file mode 100644 index 0000000..5ef182e --- /dev/null +++ b/deploy/smm-setup.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +readonly PROGRAM_NAME="smm-setup" +readonly DEFAULT_RELEASE_TAG="v0.1.0-alpha.9" +readonly DEFAULT_REPOSITORY="ochenstarik-ui/server-monitor-manager" +readonly INNER_ASSET="ochenstarik-server-monitor-manager.sh" + +RELEASE_TAG="${SMM_TAG:-$DEFAULT_RELEASE_TAG}" +REPOSITORY="${SMM_REPOSITORY:-$DEFAULT_REPOSITORY}" +CACHE_DIR="${SMM_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/server-monitor-manager}" + +usage() { + cat <<'USAGE' +Usage: + smm-setup.sh [--tag TAG] [--repository OWNER/REPO] COMMAND [ARG...] + +Commands are passed to the verified ochenstarik-server-monitor-manager.sh +asset from the selected immutable GitHub release. Common commands: + install-agent | install-control | uninstall-agent | uninstall-control + backup-create | backup-restore | version + +Environment overrides: + SMM_TAG Release tag (default: v0.1.0-alpha.9) + SMM_REPOSITORY GitHub repository (default: ochenstarik-ui/server-monitor-manager) + SMM_CACHE_DIR Verified-download cache directory +USAGE +} + +die() { + printf '%s: %s\n' "$PROGRAM_NAME" "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "required command is unavailable: $1" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) + [[ $# -ge 2 ]] || die '--tag requires a value' + RELEASE_TAG="$2" + shift 2 + ;; + --repository) + [[ $# -ge 2 ]] || die '--repository requires a value' + REPOSITORY="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + break + ;; + -*) + die "unknown option: $1" + ;; + *) + break + ;; + esac +done + +[[ $# -gt 0 ]] || { + usage >&2 + exit 2 +} +[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] \ + || die "invalid release tag: $RELEASE_TAG" +[[ "$REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] \ + || die "invalid repository: $REPOSITORY" + +require_command curl +require_command sha256sum +require_command mktemp + +release_base="https://github.com/$REPOSITORY/releases/download/$RELEASE_TAG" +cache_release="$CACHE_DIR/$REPOSITORY/$RELEASE_TAG" +cached_script="$cache_release/$INNER_ASSET" +cached_checksum="$cache_release/$INNER_ASSET.sha256" +mkdir -p "$cache_release" + +temporary_directory="$(mktemp -d -t smm-setup.XXXXXXXX)" +trap 'rm -rf -- "$temporary_directory"' EXIT + +curl -fsSL "$release_base/$INNER_ASSET" -o "$temporary_directory/$INNER_ASSET" +curl -fsSL "$release_base/$INNER_ASSET.sha256" -o "$temporary_directory/$INNER_ASSET.sha256" + +( + cd "$temporary_directory" + sha256sum -c "$INNER_ASSET.sha256" +) || die "checksum verification failed for $RELEASE_TAG/$INNER_ASSET" + +install -m 0755 "$temporary_directory/$INNER_ASSET" "$cached_script" +install -m 0644 "$temporary_directory/$INNER_ASSET.sha256" "$cached_checksum" + +exec "$cached_script" "$@" diff --git a/docs/installer-contract.md b/docs/installer-contract.md index 88981a5..9a3526e 100644 --- a/docs/installer-contract.md +++ b/docs/installer-contract.md @@ -6,6 +6,8 @@ Bootstrap, helper, systemd units, JSON schemas и manifests являются к Bootstrap не скачивает и не запускает исходники других проектов. Production-установка использует закреплённый release/tag, проверяет signed compatibility manifest и SHA-256 каждого artifact. Mutable `main` не является источником production-установки. +Published release tags and their assets are immutable. A published tag must never be moved, reused, deleted and recreated, or supplied with replacement assets under the same names; corrections must preserve the existing release and publish a new, higher version tag. + Пока bootstrap не опубликован в release, документация не должна предлагать несуществующую команду его скачивания. ## 2. Поддерживаемые роли @@ -104,7 +106,7 @@ CLI является non-interactive, кроме локального ввода ## 7. Forced command monitoring -Monitoring key допускает только versioned metrics snapshot и read-only mesh status. Полный SSH-терминал использует отдельную пользовательскую identity. +Monitoring key permits only the exact versioned metrics snapshot listed below; no additional mesh status or other output is allowed. Полный SSH-терминал использует отдельную пользовательскую identity. Минимальный snapshot: @@ -125,6 +127,8 @@ DISK_INODES_FREE=... NETWORK_RX_BYTES=... NETWORK_TX_BYTES=... KERNEL=... +SYSTEMD_SSH=active|inactive|failed|unknown +SYSTEMD_WIREGUARD=active|inactive|failed|unknown ``` ## 8. Обязательные проверки diff --git a/docs/release-policy.md b/docs/release-policy.md new file mode 100644 index 0000000..dc929a3 --- /dev/null +++ b/docs/release-policy.md @@ -0,0 +1,9 @@ +# Release policy + +Published tags and release assets are immutable. + +A tag that has been published must never be moved, reused, deleted and recreated, or supplied with replacement assets under the same names. If a published build or installer is wrong, preserve the existing release and publish a new, higher version tag containing the correction. + +Release artifacts are built from the commit named by the tag through the repository release workflows, including `.github/workflows/linux-release.yml` and `.github/workflows/windows-release.yml`. The tracked production source for the convenience installer is `deploy/smm-setup.sh`; the Linux release workflow copies that exact file to the release artifact set, records its SHA-256 in the signed manifest, and publishes its standalone checksum. The default release in that source must match the tag being produced. + +For `v0.1.0-alpha.9`, this makes `smm-setup.sh`, `smm-setup.sh.sha256`, the bootstrap script, platform archives, SBOMs, and the signed manifest reproducible from the tagged tree. The installer fetches only same-tag assets and verifies the bootstrap checksum before execution. Corrections after publication require another tag; the `v0.1.0-alpha.9` tag and assets remain unchanged. diff --git a/tests/ServerMonitorManager.Control.Tests/DesktopMonitorSnapshotContractTests.cs b/tests/ServerMonitorManager.Control.Tests/DesktopMonitorSnapshotContractTests.cs new file mode 100644 index 0000000..0e30cb8 --- /dev/null +++ b/tests/ServerMonitorManager.Control.Tests/DesktopMonitorSnapshotContractTests.cs @@ -0,0 +1,71 @@ +using System.Text.RegularExpressions; +using Xunit; + +namespace ServerMonitorManager.Control.Tests; + +public sealed class DesktopMonitorSnapshotContractTests +{ + [Fact] + public void QueryParserFieldNamesMatchCanonicalSnapshot() + { + var root = FindRepositoryRoot(); + var canonicalKeys = ReadKeys(Path.Combine(root, "tests", "contracts", "monitor-snapshot-v1.txt")); + var source = File.ReadAllText(Path.Combine( + root, "src", "ServerMonitorManager.Desktop", "SshMonitorService.cs")); + var queryStart = source.IndexOf("public async Task QueryAsync(", StringComparison.Ordinal); + var queryEnd = source.IndexOf( + "public async Task RunRestrictedCommandAsync(", queryStart, StringComparison.Ordinal); + Assert.True(queryStart >= 0 && queryEnd > queryStart, "Desktop QueryAsync source was not found."); + + var querySource = source[queryStart..queryEnd]; + var parserKeys = Regex.Matches(querySource, "\"([A-Z][A-Z0-9_]*)\"") + .Select(match => match.Groups[1].Value) + .ToHashSet(StringComparer.Ordinal); + + // PROTOCOL gates the wire format and KERNEL is diagnostic metadata; the current + // ServerMetrics model intentionally does not project either value. + var unprojectedKeys = new HashSet(["PROTOCOL", "KERNEL"], StringComparer.Ordinal); + Assert.Subset(canonicalKeys, unprojectedKeys); + Assert.Equal( + canonicalKeys.Except(unprojectedKeys).Order(StringComparer.Ordinal), + parserKeys.Order(StringComparer.Ordinal)); + } + + [Fact] + public void InstallerDocumentationListsCanonicalSnapshotFields() + { + var root = FindRepositoryRoot(); + var canonicalKeys = ReadKeys(Path.Combine(root, "tests", "contracts", "monitor-snapshot-v1.txt")); + var documentation = File.ReadAllText(Path.Combine(root, "docs", "installer-contract.md")); + var sectionStart = documentation.IndexOf("## 7. Forced command monitoring", StringComparison.Ordinal); + var sectionEnd = documentation.IndexOf("## 8.", sectionStart, StringComparison.Ordinal); + Assert.True(sectionStart >= 0 && sectionEnd > sectionStart, "Installer contract section 7 was not found."); + + var documentedKeys = Regex.Matches( + documentation[sectionStart..sectionEnd], + "^([A-Z][A-Z0-9_]*)=", + RegexOptions.Multiline) + .Select(match => match.Groups[1].Value) + .ToHashSet(StringComparer.Ordinal); + + Assert.Equal( + canonicalKeys.Order(StringComparer.Ordinal), + documentedKeys.Order(StringComparer.Ordinal)); + } + + private static HashSet ReadKeys(string path) + => File.ReadAllLines(path) + .Select(line => line.Split('=', 2)[0]) + .ToHashSet(StringComparer.Ordinal); + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "ServerMonitorManager.slnx"))) + { + directory = directory.Parent; + } + return directory?.FullName + ?? throw new DirectoryNotFoundException("Repository root was not found."); + } +} diff --git a/tests/ServerMonitorManager.Control.Tests/SchemaCompatibilityTests.cs b/tests/ServerMonitorManager.Control.Tests/SchemaCompatibilityTests.cs index 272aae0..e7ddeda 100644 --- a/tests/ServerMonitorManager.Control.Tests/SchemaCompatibilityTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/SchemaCompatibilityTests.cs @@ -1,43 +1,137 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; namespace ServerMonitorManager.Control.Tests; -public sealed class SchemaCompatibilityTests +public sealed class SchemaCompatibilityTests : IAsyncDisposable { - [Fact] - public async Task CanOpenAlpha7DatabaseWithoutReapplyingMigrations() - { - // Try to find the alpha7.db file which should be generated by CI - var dbPath = Path.GetFullPath(Path.Combine(System.AppContext.BaseDirectory, "..", "..", "..", "..", "..", "tests", "acceptance", "alpha7.db")); + private readonly string _directory = Path.Combine( + Path.GetTempPath(), $"smm-schema-compatibility-{Guid.NewGuid():N}"); - if (!File.Exists(dbPath)) + [Fact] + public async Task PublishedAlpha7DatabaseRemainsReadableAndRestorable() + { + var cancellationToken = TestContext.Current.CancellationToken; + Directory.CreateDirectory(_directory); + var fixturePath = Path.Combine( + FindRepositoryRoot(), "tests", "fixtures", "control-v0.1.0-alpha.7.db"); + Assert.True(File.Exists(fixturePath), $"Published database fixture is missing: {fixturePath}"); + + var options = new ControlOptions { - // Skip the test locally if the file hasn't been generated - return; + DatabasePath = Path.Combine(_directory, "control.db"), + CertificateAuthorityPath = Path.Combine(_directory, "control-ca.pfx"), + BackupDirectory = Path.Combine(_directory, "backups") + }; + File.Copy(fixturePath, options.DatabasePath); + await File.WriteAllBytesAsync( + options.CertificateAuthorityPath, [1, 2, 3, 4], cancellationToken); + + var schemaVersionBefore = await ReadUserVersionAsync(options.DatabasePath, cancellationToken); + Assert.Equal(8L, schemaVersionBefore); + + var store = new ControlStore(Options.Create(options)); + await store.InitializeAsync(cancellationToken); + + var schemaVersionAfter = await ReadUserVersionAsync(options.DatabasePath, cancellationToken); + Assert.Equal(schemaVersionBefore, schemaVersionAfter); + + var agents = await store.ListAgentsAsync(cancellationToken); + Assert.Equal(["source-node", "target-node"], agents.Select(agent => agent.NodeId)); + Assert.Equal("0.1.0-alpha.7", agents[0].AgentVersion); + + Assert.Equal( + new ControlIdentity("source-node", "Agent"), + await store.ResolveIdentityAsync("AGENT-THUMBPRINT", cancellationToken)); + Assert.Equal( + new ControlIdentity("operator-device", "Operator"), + await store.ResolveIdentityAsync("DEVICE-THUMBPRINT", cancellationToken)); + Assert.Equal( + new ControlIdentity("automation-one", "Automation", "source-node"), + await store.ResolveIdentityAsync("AUTOMATION-THUMBPRINT", cancellationToken)); + + var link = Assert.Single(await store.ListLinksAsync(cancellationToken)); + Assert.Equal("source-node", link.SourceNodeId); + Assert.Equal("target-node", link.TargetNodeId); + Assert.Equal("Active", link.ActualState); + + var job = await store.GetProvisioningJobAsync( + "22222222222222222222222222222222", cancellationToken); + Assert.NotNull(job); + Assert.Equal("source-node", job.NodeId); + Assert.Equal("Completed", job.State); + var provisioningEvent = Assert.Single((await store.ListProvisioningEventsAsync( + job.Id, 100, cancellationToken))!); + Assert.Equal("job.completed", provisioningEvent.EventType); + + await using (var connection = new SqliteConnection($"Data Source={options.DatabasePath};Pooling=False")) + { + await connection.OpenAsync(cancellationToken); + var audit = connection.CreateCommand(); + audit.CommandText = "SELECT actor, action, subject, details_json FROM audit;"; + await using var reader = await audit.ExecuteReaderAsync(cancellationToken); + Assert.True(await reader.ReadAsync(cancellationToken)); + Assert.Equal("operator-device", reader.GetString(0)); + Assert.Equal("fixture.created", reader.GetString(1)); + Assert.Equal("source-node", reader.GetString(2)); + Assert.Contains("v0.1.0-alpha.7", reader.GetString(3), StringComparison.Ordinal); + Assert.False(await reader.ReadAsync(cancellationToken)); } - // 1. Verify PRAGMA user_version is readable and hasn't changed abruptly - await using var connection = new SqliteConnection($"Data Source={dbPath}"); - await connection.OpenAsync(); + var backups = new ControlBackupService( + store, Options.Create(options), NullLogger.Instance); + var backupPath = await backups.CreateAsync(DateTimeOffset.UtcNow, cancellationToken); + await using (var connection = new SqliteConnection($"Data Source={options.DatabasePath};Pooling=False")) + { + await connection.OpenAsync(cancellationToken); + var mutate = connection.CreateCommand(); + mutate.CommandText = "UPDATE agents SET name = 'mutated';"; + await mutate.ExecuteNonQueryAsync(cancellationToken); + } + await File.WriteAllBytesAsync(options.CertificateAuthorityPath, [9, 9], cancellationToken); + + SqliteConnection.ClearAllPools(); + await backups.RestoreAsync(backupPath, cancellationToken); + + var restoredStore = new ControlStore(Options.Create(options)); + await restoredStore.InitializeAsync(cancellationToken); + Assert.Equal(schemaVersionBefore, await ReadUserVersionAsync(options.DatabasePath, cancellationToken)); + Assert.Equal("Source Node", (await restoredStore.ListAgentsAsync(cancellationToken))[0].Name); + Assert.Equal([1, 2, 3, 4], await File.ReadAllBytesAsync( + options.CertificateAuthorityPath, cancellationToken)); + } + + public ValueTask DisposeAsync() + { + SqliteConnection.ClearAllPools(); + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + return ValueTask.CompletedTask; + } + + private static async Task ReadUserVersionAsync( + string databasePath, + CancellationToken cancellationToken) + { + await using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + await connection.OpenAsync(cancellationToken); var command = connection.CreateCommand(); command.CommandText = "PRAGMA user_version;"; - var userVersion = Convert.ToInt64(await command.ExecuteScalarAsync()); - - Assert.True(userVersion > 0, "Database user_version should be greater than 0"); - - // 2. Open it with the current ControlStore to see if data reads correctly and it doesn't crash - var store = new ControlStore(Options.Create(new ControlOptions { DatabasePath = dbPath })); - await store.InitializeAsync(CancellationToken.None); - - // 3. Test that we can read from it - var links = await store.ListEffectiveLinksForNodeAsync("nonexistent", CancellationToken.None); - Assert.Empty(links); + return (long)(await command.ExecuteScalarAsync(cancellationToken))!; + } + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "ServerMonitorManager.slnx"))) + { + directory = directory.Parent; + } + return directory?.FullName + ?? throw new DirectoryNotFoundException("Repository root was not found."); } } diff --git a/tests/acceptance/generate_alpha7_db.sh b/tests/acceptance/generate_alpha7_db.sh deleted file mode 100644 index 3aef7c3..0000000 --- a/tests/acceptance/generate_alpha7_db.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash -set -euo pipefail - -OUTPUT_DB=$(realpath "${1:-alpha7.db}") - -if [ -f "$OUTPUT_DB" ]; then - echo "Database $OUTPUT_DB already exists." - exit 0 -fi - -echo "Generating reference database from v0.1.0-alpha.7 to $OUTPUT_DB" -WORK_DIR=$(mktemp -d) -trap 'rm -rf "$WORK_DIR"' EXIT - -git clone --depth 1 -b v0.1.0-alpha.7 https://github.com/ochenstarik-ui/server-monitor-manager.git "$WORK_DIR/repo" - -cat << 'EOF' > "$WORK_DIR/repo/tests/ServerMonitorManager.Control.Tests/GenerateDbTest.cs" -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Xunit; -using ServerMonitorManager.Control; -using Microsoft.Extensions.Options; - -namespace ServerMonitorManager.Control.Tests; - -public class GenerateDbTest -{ - [Fact] - public async Task GenerateReferenceDatabase() - { - var dbPath = System.Environment.GetEnvironmentVariable("OUTPUT_DB_PATH"); - var store = new ControlStore(Options.Create(new ControlOptions { DatabasePath = dbPath })); - await store.InitializeAsync(CancellationToken.None); - } -} -EOF - -export OUTPUT_DB_PATH="$OUTPUT_DB" -cd "$WORK_DIR/repo" -dotnet test tests/ServerMonitorManager.Control.Tests --filter "GenerateReferenceDatabase" diff --git a/tests/acceptance/test_monitor_snapshot.sh b/tests/acceptance/test_monitor_snapshot.sh index 79855ce..4901c5f 100644 --- a/tests/acceptance/test_monitor_snapshot.sh +++ b/tests/acceptance/test_monitor_snapshot.sh @@ -1,58 +1,54 @@ #!/usr/bin/env bash -set -euo pipefail +set -Eeuo pipefail +IFS=$'\n\t' -# test_monitor_snapshot.sh -# Acceptance test for Monitor role installation and metrics format +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +bootstrap="$root/deploy/ochenstarik-server-monitor-manager.sh" +contract="$root/tests/contracts/monitor-snapshot-v1.txt" +installer_contract="$root/docs/installer-contract.md" -cd "$(dirname "$0")/../.." -BOOTSTRAP="./deploy/ochenstarik-server-monitor-manager.sh" -DUMMY_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDummyTestKeyForAcceptanceTest dummy@test" +grep -Fq 'Monitoring key permits only the exact versioned metrics snapshot listed below; no additional mesh status or other output is allowed.' "$installer_contract" +if grep -Fq 'read-only mesh status' "$installer_contract"; then + printf '%s\n' 'installer contract still permits mesh status outside the closed snapshot' >&2 + exit 1 +fi -echo "=== Testing install-monitor ===" -sudo "$BOOTSTRAP" install-monitor "$DUMMY_KEY" +extract_metrics_script() { + awk ' + /cat >"\$metrics_script" <<'"'"'EOF'"'"'/ { emitting = 1; next } + emitting && $0 == "EOF" { exit } + emitting { print } + ' "$bootstrap" +} -# 1. Verify user exists and has nologin -getent passwd ochenstarik-monitor | grep -q "/usr/sbin/nologin" || { - echo "ERROR: User ochenstarik-monitor does not exist or does not have nologin" +fixture="$(mktemp -d -t smm-monitor-snapshot.XXXXXXXX)" +trap 'rm -rf -- "$fixture"' EXIT +metrics_script="$fixture/ochenstarik-smm-metrics" +extract_metrics_script >"$metrics_script" +chmod 0755 "$metrics_script" + +[[ -s "$metrics_script" ]] || { + printf '%s\n' 'monitor metrics script was not found in bootstrap' >&2 exit 1 } -# 2. Verify authorized_keys -AUTH_KEYS="/var/lib/ochenstarik-monitor/.ssh/authorized_keys" -if ! sudo test -f "$AUTH_KEYS"; then - echo "ERROR: authorized_keys not found" +metrics_out="$(bash "$metrics_script")" +printf '%s\n' "$metrics_out" + +if grep -Ev '^[A-Z][A-Z0-9_]*=.*$' <<<"$metrics_out"; then + printf '%s\n' 'monitor snapshot contains non-contract output' >&2 exit 1 fi -sudo grep -q "command=\"/usr/local/libexec/ochenstarik-smm-metrics\",restrict" "$AUTH_KEYS" || { - echo "ERROR: Forced command not found in authorized_keys" - exit 1 -} - -# 3. Verify metrics script output format -echo "=== Testing metrics output ===" -METRICS_OUT=$(sudo /usr/local/libexec/ochenstarik-smm-metrics) -echo "$METRICS_OUT" - -for FIELD in PROTOCOL HOSTNAME UPTIME_SECONDS LOAD1 CPU_COUNT MEM_TOTAL_KB MEM_AVAILABLE_KB SWAP_TOTAL_KB SWAP_FREE_KB DISK_TOTAL_KB DISK_AVAILABLE_KB DISK_INODES_TOTAL DISK_INODES_FREE NETWORK_RX_BYTES NETWORK_TX_BYTES KERNEL; do - if ! echo "$METRICS_OUT" | grep -q "^${FIELD}="; then - echo "ERROR: Missing field ${FIELD} in metrics output" - exit 1 - fi -done - -# 4. Verify uninstall -echo "=== Testing uninstall-monitor ===" -sudo "$BOOTSTRAP" uninstall-monitor - -if getent passwd ochenstarik-monitor >/dev/null 2>&1; then - echo "ERROR: User ochenstarik-monitor was not removed" +expected_keys="$(cut -d= -f1 "$contract" | LC_ALL=C sort)" +actual_keys="$(cut -d= -f1 <<<"$metrics_out" | LC_ALL=C sort)" +if [[ "$actual_keys" != "$expected_keys" ]]; then + printf '%s\n' 'monitor snapshot field closure differs from canonical contract' >&2 + diff -u <(printf '%s\n' "$expected_keys") <(printf '%s\n' "$actual_keys") >&2 || true exit 1 fi -if sudo test -f "$AUTH_KEYS" || sudo test -f "/usr/local/libexec/ochenstarik-smm-metrics"; then - echo "ERROR: Leftover files found after uninstall" - exit 1 -fi +[[ "$(grep -c '^PROTOCOL=1$' <<<"$metrics_out")" -eq 1 ]] +[[ "$(wc -l <"$contract")" -eq "$(wc -l <<<"$metrics_out")" ]] -echo "=== PASS: Monitor role acceptance test ===" +printf '%s\n' 'MONITOR_SNAPSHOT_CONTRACT=PASS' diff --git a/tests/acceptance/test_previous_schema_arm64.sh b/tests/acceptance/test_previous_schema_arm64.sh new file mode 100644 index 0000000..0812f4c --- /dev/null +++ b/tests/acceptance/test_previous_schema_arm64.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +[[ $# -eq 1 ]] || { + printf 'usage: %s CONTROL_BINARY\n' "$0" >&2 + exit 2 +} + +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +binary="$(realpath "$1")" +fixture="$root/tests/fixtures/control-v0.1.0-alpha.7.db" +work="$(mktemp -d -t smm-arm64-schema-compatibility.XXXXXXXX)" +trap 'rm -rf -- "$work"' EXIT + +[[ -x "$binary" ]] || { + printf 'published Control binary is not executable: %s\n' "$binary" >&2 + exit 1 +} +cp "$fixture" "$work/control.db" +printf '%s' 'fixture-ca' >"$work/control-ca.pfx" +mkdir -p "$work/backups" "$work/bundle" + +export Control__DatabasePath="$work/control.db" +export Control__CertificateAuthorityPath="$work/control-ca.pfx" +export Control__BackupDirectory="$work/backups" +export Control__HubHelperPath=/bin/true +export Control__PrivilegeEscalationPath=/usr/bin/true +export DOTNET_BUNDLE_EXTRACT_BASE_DIR="$work/bundle" +export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 + +"$binary" backup-create +backup_paths=("$work"/backups/backup-*) +[[ ${#backup_paths[@]} -eq 1 && -d "${backup_paths[0]}" ]] || { + printf '%s\n' 'arm64 published Control did not create exactly one backup' >&2 + exit 1 +} + +rm -f -- "$work/control.db" "$work/control.db-wal" "$work/control.db-shm" "$work/control-ca.pfx" +"$binary" backup-restore "${backup_paths[0]}" +[[ -s "$work/control.db" && -s "$work/control-ca.pfx" ]] +"$binary" backup-create + +printf '%s\n' 'PREVIOUS_SCHEMA_ARM64=PASS' diff --git a/tests/acceptance/test_previous_schema_contract.sh b/tests/acceptance/test_previous_schema_contract.sh new file mode 100644 index 0000000..5b5e5bd --- /dev/null +++ b/tests/acceptance/test_previous_schema_contract.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +test_source="$root/tests/ServerMonitorManager.Control.Tests/SchemaCompatibilityTests.cs" +workflow="$root/.github/workflows/linux-control-agent.yml" +arm64_test="$root/tests/acceptance/test_previous_schema_arm64.sh" +fixture="$root/tests/fixtures/control-v0.1.0-alpha.7.db" + +require_contains() { + local text="$1" file="$2" + grep -Fq "$text" "$file" || { + printf 'required compatibility evidence is missing from %s: %s\n' "$file" "$text" >&2 + exit 1 + } +} + +[[ -s "$fixture" ]] || { + printf '%s\n' 'published previous-schema database fixture is missing' >&2 + exit 1 +} +require_contains 'PublishedAlpha7DatabaseRemainsReadableAndRestorable' "$test_source" +require_contains 'Assert.Equal(schemaVersionBefore, schemaVersionAfter);' "$test_source" +require_contains 'ListAgentsAsync' "$test_source" +require_contains 'ResolveIdentityAsync' "$test_source" +require_contains 'ListLinksAsync' "$test_source" +require_contains 'GetProvisioningJobAsync' "$test_source" +require_contains 'FROM audit' "$test_source" +require_contains 'CreateAsync' "$test_source" +require_contains 'RestoreAsync' "$test_source" + +if grep -Fq 'if (!File.Exists' "$test_source" || grep -Fq 'Generate reference alpha.7' "$workflow"; then + printf '%s\n' 'previous-schema coverage is still skip- or generation-prone' >&2 + exit 1 +fi + +require_contains 'Publish control arm64 compatibility binary' "$workflow" +require_contains 'Verify previous schema with arm64 published Control' "$workflow" +require_contains 'export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1' "$arm64_test" + +printf '%s\n' 'PREVIOUS_SCHEMA_CONTRACT=PASS' diff --git a/tests/bootstrap/test-release-contract.sh b/tests/bootstrap/test-release-contract.sh new file mode 100644 index 0000000..62fd0c4 --- /dev/null +++ b/tests/bootstrap/test-release-contract.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +setup="$root/deploy/smm-setup.sh" +workflow="$root/.github/workflows/linux-release.yml" +policy="$root/docs/release-policy.md" +installer_contract="$root/docs/installer-contract.md" + +[[ -s "$setup" ]] || { + printf '%s\n' 'tracked production smm-setup.sh source is missing' >&2 + exit 1 +} +bash -n "$setup" +grep -Fq 'readonly DEFAULT_RELEASE_TAG="v0.1.0-alpha.9"' "$setup" +if grep -Fq 'validate_control_url' "$setup" || grep -Fq '${CONTROL_URL%/}/control' "$setup"; then + printf '%s\n' 'temporary control URL workaround must not be present in smm-setup.sh' >&2 + exit 1 +fi + +grep -Fq 'install -m 0755 deploy/smm-setup.sh "$DIST_DIR/smm-setup.sh"' "$workflow" +grep -Fq 'smm-setup.sh.sha256' "$workflow" +grep -Fq 'dist/smm-setup.sh' "$workflow" +grep -Fq 'Published tags and release assets are immutable.' "$policy" +grep -Fq 'publish a new, higher version tag' "$policy" +grep -Fq 'Published release tags and their assets are immutable.' "$installer_contract" +grep -Fq 'publish a new, higher version tag' "$installer_contract" + +work="$(mktemp -d -t smm-setup-contract.XXXXXXXX)" +trap 'rm -rf -- "$work"' EXIT +mkdir -p "$work/bin" "$work/home" +cat >"$work/inner.sh" <<'INNER' +#!/usr/bin/env bash +printf 'INNER_COMMAND=%s\n' "$1" +INNER +chmod +x "$work/inner.sh" +inner_hash="$(sha256sum "$work/inner.sh" | cut -d' ' -f1)" +cat >"$work/bin/curl" <>'$work/urls' +case "\$url" in + */ochenstarik-server-monitor-manager.sh) + cp '$work/inner.sh' "\$out" + ;; + */ochenstarik-server-monitor-manager.sh.sha256) + printf '%s %s\n' '$inner_hash' 'ochenstarik-server-monitor-manager.sh' >"\$out" + ;; + *) + printf 'unexpected URL: %s\n' "\$url" >&2 + exit 1 + ;; +esac +EOF_CURL +chmod +x "$work/bin/curl" + +HOME="$work/home" PATH="$work/bin:$PATH" bash "$setup" version >"$work/output" +grep -Fq 'INNER_COMMAND=version' "$work/output" +grep -Fq '/releases/download/v0.1.0-alpha.9/ochenstarik-server-monitor-manager.sh' "$work/urls" +grep -Fq '/releases/download/v0.1.0-alpha.9/ochenstarik-server-monitor-manager.sh.sha256' "$work/urls" + +printf '%s\n' 'RELEASE_CONTRACT=PASS' diff --git a/tests/contracts/monitor-snapshot-v1.txt b/tests/contracts/monitor-snapshot-v1.txt new file mode 100644 index 0000000..403fe5c --- /dev/null +++ b/tests/contracts/monitor-snapshot-v1.txt @@ -0,0 +1,18 @@ +PROTOCOL=1 +HOSTNAME=contract-host +UPTIME_SECONDS=12345 +LOAD1=0.42 +CPU_COUNT=4 +MEM_TOTAL_KB=8192 +MEM_AVAILABLE_KB=4096 +SWAP_TOTAL_KB=2048 +SWAP_FREE_KB=1024 +DISK_TOTAL_KB=1048576 +DISK_AVAILABLE_KB=524288 +DISK_INODES_TOTAL=65536 +DISK_INODES_FREE=32768 +NETWORK_RX_BYTES=123456 +NETWORK_TX_BYTES=654321 +KERNEL=6.8.0-contract +SYSTEMD_SSH=active +SYSTEMD_WIREGUARD=inactive diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..3896106 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,11 @@ +# Published Control database fixture + +`control-v0.1.0-alpha.7.db` was created by the ControlStore from the immutable source tag `v0.1.0-alpha.7` (`d645812d29d077e9a4dee1596ef09a70dc138090`). That project pins `SQLitePCLRaw.bundle_e_sqlite3` 2.1.12. The fixture contains stable rows for agents, Agent/Operator/Automation identities, a Link, a provisioning job and event, and audit data. + +Fixture properties: + +- `PRAGMA user_version`: 8 +- `PRAGMA integrity_check`: `ok` +- SHA-256: `15bf788dd5789a55bd54a4a548d339b4e29e54e1c75311b21eec079e0ef2faa2` + +The compatibility test always copies this file before opening it, so the committed previous-release database is never migrated or mutated in place. diff --git a/tests/fixtures/control-v0.1.0-alpha.7.db b/tests/fixtures/control-v0.1.0-alpha.7.db new file mode 100644 index 0000000000000000000000000000000000000000..293042c148a1e46221a0958fc9b5e62a1b9f8f1f GIT binary patch literal 167936 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 literal 0 HcmV?d00001