diff --git a/.github/workflows/linux-control-agent.yml b/.github/workflows/linux-control-agent.yml
index c157dde..4bd94c6 100644
--- a/.github/workflows/linux-control-agent.yml
+++ b/.github/workflows/linux-control-agent.yml
@@ -21,7 +21,7 @@ jobs:
dotnet-version: 10.0.x
- name: Restore
- run: dotnet restore ServerMonitorManager.slnx
+ run: dotnet restore ServerMonitorManager.slnx --locked-mode
- name: Build
run: dotnet build ServerMonitorManager.slnx --configuration Release --no-restore
@@ -30,7 +30,9 @@ jobs:
run: dotnet test tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj --configuration Release --no-build
- name: Verify formatting
- run: dotnet format ServerMonitorManager.slnx --verify-no-changes --no-restore
+ run: |
+ dotnet format whitespace ServerMonitorManager.slnx --verify-no-changes --no-restore
+ dotnet format style ServerMonitorManager.slnx --verify-no-changes --no-restore
- name: Verify three-server acceptance harness
run: |
@@ -55,28 +57,28 @@ jobs:
bash tests/bootstrap/test-enrollment-token-argv.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
+ 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
- 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
+ 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 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
+ 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
- name: Publish provisioning helper arm64
- run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
+ run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true
- name: Build systemd smoke release
run: |
dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
- -p:PublishSingleFile=true -p:PublishTrimmed=true -o smoke/agent
+ -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o smoke/agent
dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
- -p:PublishSingleFile=true -p:PublishTrimmed=true -o smoke/control
+ -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o smoke/control
dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
- -p:PublishSingleFile=true -p:PublishTrimmed=true -o smoke/provisioning-helper
+ -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o smoke/provisioning-helper
install -d smoke/deploy smoke/bootstrap
install -m 0644 deploy/ochenstarik-smm-control.service smoke/deploy/
install -m 0644 deploy/ochenstarik-smm-agent.service smoke/deploy/
diff --git a/.github/workflows/linux-platform-matrix.yml b/.github/workflows/linux-platform-matrix.yml
index b018276..528699f 100644
--- a/.github/workflows/linux-platform-matrix.yml
+++ b/.github/workflows/linux-platform-matrix.yml
@@ -48,13 +48,13 @@ jobs:
set -Eeuo pipefail
dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj \
--configuration Release --runtime '${{ matrix.runtime }}' --self-contained true \
- -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/agent
+ -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/agent
dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj \
--configuration Release --runtime '${{ matrix.runtime }}' --self-contained true \
- -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/control
+ -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/control
dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj \
--configuration Release --runtime '${{ matrix.runtime }}' --self-contained true \
- -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/provisioning-helper
+ -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/provisioning-helper
install -d out/deploy out/bootstrap
install -m 0644 deploy/ochenstarik-smm-control.service out/deploy/
install -m 0644 deploy/ochenstarik-smm-agent.service out/deploy/
diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml
index e8b263a..923791f 100644
--- a/.github/workflows/linux-release.yml
+++ b/.github/workflows/linux-release.yml
@@ -79,13 +79,13 @@ jobs:
dotnet-version: 10.0.x
- name: Publish agent
- run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/agent
+ run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/agent
- name: Publish control
- run: dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/control
+ run: dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/control
- name: Publish provisioning helper
- run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/provisioning-helper
+ run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/provisioning-helper
- name: Package
shell: bash
diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml
index 83f117a..685541b 100644
--- a/.github/workflows/windows-build.yml
+++ b/.github/workflows/windows-build.yml
@@ -21,20 +21,22 @@ jobs:
dotnet-version: 10.0.x
- name: Restore
- run: dotnet restore src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj -p:Platform=x64 -p:PublishReadyToRun=true -r win-x64
+ run: dotnet restore src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj -p:Platform=x64 -p:PublishReadyToRun=true -p:RestoreLockedMode=true
- name: Build x64 Release
run: dotnet build src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj --configuration Release -p:Platform=x64 -p:PublishReadyToRun=true -r win-x64 --no-restore
- name: Verify formatting
- run: dotnet format src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj --verify-no-changes --no-restore
+ run: |
+ dotnet format whitespace src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj --verify-no-changes --no-restore
+ dotnet format style src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj --verify-no-changes --no-restore
- name: Verify Windows desktop contracts
shell: pwsh
run: ./tests/windows/Test-DesktopContracts.ps1
- name: Test Desktop security
- run: dotnet test tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj --configuration Release
+ run: dotnet test tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj --configuration Release -p:RestoreLockedMode=true
- name: Build test-signed MSIX installer
shell: pwsh
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..b8da30a
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,22 @@
+
+
+
+ enable
+ true
+ latest-recommended
+
+
+ true
+ win-x64;linux-x64;linux-arm64
+
+
+ true
+ true
+ false
+
+
+
+
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..636fd58
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,34 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/windows/Build-Installer.ps1 b/build/windows/Build-Installer.ps1
index 2092665..e398ebf 100644
--- a/build/windows/Build-Installer.ps1
+++ b/build/windows/Build-Installer.ps1
@@ -35,7 +35,7 @@ try {
throw "The certificate subject must match Package.appxmanifest Publisher=CN=AppPublisher; actual: $($signingCertificate.Subject)"
}
- dotnet restore $project -r win-x64 -p:Platform=x64 -p:PublishReadyToRun=false
+ dotnet restore $project -p:Platform=x64 -p:PublishReadyToRun=false -p:RestoreLockedMode=true
if ($LASTEXITCODE -ne 0) { throw 'dotnet restore failed' }
dotnet publish $project `
diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh
index 0d87103..d043fbc 100755
--- a/deploy/ochenstarik-server-monitor-manager.sh
+++ b/deploy/ochenstarik-server-monitor-manager.sh
@@ -495,7 +495,6 @@ Control__BackupDirectory=$STATE_DIR/backups
Control__HubHelperPath=$POLICY_HELPER
Control__PrivilegeEscalationPath=/usr/bin/sudo
Control__LinkReconciliationSeconds=300
-Control__LinkRetentionDays=90
EOF
printf '%s\n' "https://$public_host:$port" >"$ETC_DIR/control-public-url"
chown root:"$CONTROL_USER" "$ETC_DIR/control.env"
diff --git a/deploy/ochenstarik-smm-policy-apply b/deploy/ochenstarik-smm-policy-apply
index 62ea298..87b3392 100755
--- a/deploy/ochenstarik-smm-policy-apply
+++ b/deploy/ochenstarik-smm-policy-apply
@@ -10,7 +10,6 @@ readonly CHAIN_NAME="links"
fail() { printf '%s\n' "policy helper: $*" >&2; exit 78; }
firewall_unavailable() { printf '%s\n' "mesh.firewall-unavailable" >&2; exit 79; }
-node_not_activated() { printf '%s\n' "mesh.node-not-activated" >&2; exit 80; }
testing="${SMM_POLICY_TESTING:-0}"
if [[ "$testing" != "1" ]]; then
@@ -29,36 +28,16 @@ fi
node_pattern='^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$'
ipv4_pattern='^([0-9]{1,3}\.){3}[0-9]{1,3}$'
generation_pattern='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
-# Mesh node lifecycle statuses are lowercase tokens (for example active or reserved).
-node_status_pattern='^[a-z][a-z0-9-]{0,31}$'
validate_node_id() {
[[ "$1" =~ $node_pattern ]] || fail "invalid node id"
}
-is_valid_ipv4() {
- local address="$1" octet
- local -a octets
- [[ "$address" =~ $ipv4_pattern ]] || return 1
- IFS='.' read -r -a octets <<<"$address"
- [[ ${#octets[@]} -eq 4 ]] || return 1
- for octet in "${octets[@]}"; do
- (( 10#$octet <= 255 )) || return 1
- done
-}
-
lookup_node_ip() {
- local node_id="$1" record field_count ip status
+ local node_id="$1" ip
[[ -r "$STATE_FILE" ]] || fail "mesh node state is unavailable"
- record="$(awk -F '\t' -v node="$node_id" '$1 == node { print; exit }' "$STATE_FILE")"
- [[ -n "$record" ]] || node_not_activated
- field_count="$(awk -F '\t' '{ print NF }' <<<"$record")"
- [[ "$field_count" == "4" ]] || fail "invalid mesh node record: $node_id"
- ip="$(awk -F '\t' '{ print $2 }' <<<"$record")"
- status="$(awk -F '\t' '{ print $4 }' <<<"$record")"
- [[ "$status" =~ $node_status_pattern ]] || fail "invalid mesh node status: $node_id"
- [[ "$status" == "active" ]] || node_not_activated
- is_valid_ipv4 "$ip" || fail "node has no valid mesh address: $node_id"
+ ip="$(awk -F '\t' -v node="$node_id" '$1 == node { print $2; exit }' "$STATE_FILE")"
+ [[ "$ip" =~ $ipv4_pattern ]] || fail "node has no valid mesh address: $node_id"
printf '%s\n' "$ip"
}
@@ -86,7 +65,6 @@ run_nft() {
printf ' %q' "$@"
printf '\n'
else
- [[ -x /usr/sbin/nft ]] || fail "nft executable is missing: /usr/sbin/nft"
/usr/sbin/nft "$@"
fi
}
@@ -97,13 +75,8 @@ inspect_firewall() {
[[ "${SMM_POLICY_FIREWALL_UNAVAILABLE:-0}" != "1" ]] || firewall_unavailable
[[ -z "${SMM_POLICY_FIREWALL_ERROR:-}" ]] \
|| fail "could not inspect nftables Link policy: $SMM_POLICY_FIREWALL_ERROR"
- if [[ -n "${SMM_POLICY_LISTING_FILE:-}" ]]; then
- [[ -r "$SMM_POLICY_LISTING_FILE" ]] || fail "test firewall listing is unavailable"
- cat -- "$SMM_POLICY_LISTING_FILE"
- fi
return 0
fi
- [[ -x /usr/sbin/nft ]] || fail "nft executable is missing: /usr/sbin/nft"
if listing="$(/usr/sbin/nft -a list chain "$TABLE_FAMILY" "$TABLE_NAME" "$CHAIN_NAME" 2>&1)"; then
printf '%s\n' "$listing"
return 0
@@ -140,6 +113,9 @@ connect_rule() {
disconnect_rule() {
local source_id="$1" target_id="$2" protocol="$3" port="$4" comment handle
ensure_firewall_available
+ # Resolve both identities before touching firewall state.
+ lookup_node_ip "$source_id" >/dev/null
+ lookup_node_ip "$target_id" >/dev/null
comment="smm:${source_id}:${target_id}:${protocol}:${port}"
if [[ "$testing" == "1" ]]; then
printf 'nft-delete-comment %q\n' "$comment"
@@ -155,39 +131,6 @@ disconnect_rule() {
)
}
-list_rules() {
- local listing line comment source_id target_id protocol port invalid
- local -a fields
- listing="$(inspect_firewall)"
- while IFS= read -r line; do
- [[ "$line" =~ comment[[:space:]]+\"([^\"]*)\" ]] || continue
- comment="${BASH_REMATCH[1]}"
- [[ "$comment" == smm:* ]] || continue
- IFS=':' read -r -a fields <<<"$comment"
- invalid=0
- if [[ ${#fields[@]} -ne 5 ]]; then
- invalid=1
- else
- source_id="${fields[1]}"
- target_id="${fields[2]}"
- protocol="${fields[3]}"
- port="${fields[4]}"
- [[ "$source_id" =~ $node_pattern && "$target_id" =~ $node_pattern \
- && "$source_id" != "$target_id" ]] || invalid=1
- [[ "$protocol" == "tcp" || "$protocol" == "udp" ]] || invalid=1
- if [[ ! "$port" =~ ^[0-9]+$ ]] \
- || (( 10#$port < 1 || 10#$port > 65535 )); then
- invalid=1
- fi
- fi
- if (( invalid != 0 )); then
- printf '%s\n' "policy helper: forged managed comment ignored: $comment" >&2
- continue
- fi
- printf '%s\t%s\t%s\t%s\n' "$source_id" "$target_id" "$protocol" "$port"
- done <<<"$listing"
-}
-
status_rule() {
local source_id="$1" target_id="$2" protocol="$3" port="$4" comment
ensure_firewall_available
@@ -204,10 +147,6 @@ status_rule() {
reconcile_status() {
local generation
- if [[ ! -d "$(dirname -- "$RECONCILE_MARKER")" ]]; then
- printf '%s\n' complete
- return
- fi
exec 9>"$RECONCILE_LOCK"
chmod 0600 "$RECONCILE_LOCK"
"$FLOCK_COMMAND" -x 9
@@ -238,11 +177,6 @@ reconcile_complete() {
action="${1:-}"
case "$action" in
- link-list)
- [[ $# -eq 1 ]] || fail "invalid link-list argument count"
- list_rules
- exit 0
- ;;
reconcile-status)
[[ $# -eq 1 ]] || fail "invalid reconcile-status argument count"
reconcile_status
diff --git a/docs/linux-bootstrap.md b/docs/linux-bootstrap.md
index 1dc5426..47566f8 100644
--- a/docs/linux-bootstrap.md
+++ b/docs/linux-bootstrap.md
@@ -108,4 +108,4 @@ sudo ochenstarik-smm-emergency firewall-restore
sudo ochenstarik-smm-emergency mesh-enable
```
-`mesh-disable` останавливает WireGuard, удаляет только таблицу `inet ochenstarik_smm` и ставит локальный emergency marker, не останавливая Control, Agent или SSH. `firewall-restore` восстанавливает базовую политику deny-by-default и атомарно создаёт root-only запрос реконсиляции с уникальным generation. Control немедленно выполняет первый фоновый проход после старта, затем одним `link-list` сверяет фактические managed-правила со всеми последними Link-политиками не реже настроенного интервала `Control__LinkReconciliationSeconds` (по умолчанию 300 секунд). Новый marker запускает до трёх внеочередных проходов на следующих poll tick; после трёх отказов обычный throttle восстанавливается, marker сохраняется, а журнал получает одно предупреждение с id проблемных политик. Marker не обходит backoff недоступного firewall. Завершённые политики `Disabled/Disabled` удаляются вместе со своей историей после `Control__LinkRetentionDays` (по умолчанию 90 дней); действующие политики и расхождения retention не затрагивает. Helper удаляет только тот generation, который был прочитан перед успешно завершившимся проходом; более новый запрос сохраняется. При недоступной таблице запрос остаётся для retry с backoff, а Desktop показывает единый баннер «Mesh firewall не загружен». Если firewall не удаётся восстановить, команда отключает Mesh для fail-closed результата. `mesh-enable` также создаёт запрос реконсиляции; запускайте его только после проверки конфигурации и доступности Hub.
+`mesh-disable` останавливает WireGuard, удаляет только таблицу `inet ochenstarik_smm` и ставит локальный emergency marker, не останавливая Control, Agent или SSH. `firewall-restore` восстанавливает базовую политику deny-by-default и атомарно создаёт root-only запрос реконсиляции с уникальным generation. Control немедленно выполняет первый фоновый проход после старта, затем сверяет все действующие Link-политики не реже настроенного интервала `Control__LinkReconciliationSeconds` (по умолчанию 300 секунд). Новый marker запускает внеочередной проход на следующем poll tick, но не обходит backoff недоступного firewall. Helper удаляет только тот generation, который был прочитан перед успешно завершившимся проходом; более новый запрос сохраняется. При недоступной таблице запрос остаётся для retry с backoff, а Desktop показывает единый баннер «Mesh firewall не загружен». Если firewall не удаётся восстановить, команда отключает Mesh для fail-closed результата. `mesh-enable` также создаёт запрос реконсиляции; запускайте его только после проверки конфигурации и доступности Hub.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index ecf6d6d..da161fc 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -58,7 +58,7 @@
- [x] append-only audit операций Link;
- [x] интеграционные тесты kill switch, process restart и helper failure;
- [x] B-2: независимая фоновая и emergency-triggered реконсиляция, агрегированное состояние недоступного Mesh firewall и Desktop banner;
-- [x] B-3: факт-первичная реконсиляция использует один `link-list`, удаляет orphan/дубликаты (включая `Disabled/Disabled`), разводит `Examined` / `Converged` / `Failed` (M2), ограничивает marker-prompt тремя попытками, добавляет фильтр истории и retention завершённых Links (M4), а также типизированное ожидание активации Mesh Node (M5). Physical acceptance остаётся внешним блокером;
+- [ ] B-3: развести результат реконсиляции на `Examined` / `Converged` / `Failed` (M2), выбрать фильтр или retention завершённых Disabled-политик (M4) и типизировать ещё не активированный Mesh Node (M5). Эти изменения вынесены отдельно, потому что меняют API/UI semantics и helper classification, тогда как B-2 ограничен восстановлением фактических правил и общим firewall failure;
- [ ] выполнить физический acceptance Hub + source Node + два destination Node с WireGuard/nftables/reboot.
## Этап 4 — мониторинг и терминал
diff --git a/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj b/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj
index cee78b2..a769654 100644
--- a/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj
+++ b/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj
@@ -10,10 +10,10 @@
-
-
-
-
+
+
+
+
diff --git a/src/ServerMonitorManager.Agent/packages.lock.json b/src/ServerMonitorManager.Agent/packages.lock.json
new file mode 100644
index 0000000..c2c96b2
--- /dev/null
+++ b/src/ServerMonitorManager.Agent/packages.lock.json
@@ -0,0 +1,118 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Microsoft.Extensions.Configuration.Binder": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.10",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Configuration.CommandLine": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "33cBeR2HRbzHUTtmcmLdNOApneNGcymwwL4arHuotgVK9Frba8kcDTrvVTj7cSCmF1R9OiSbZH0KxNOwab3HUg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.10",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Configuration.EnvironmentVariables": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "KRfFSSCV58vEdU7mPED/YMzeovIWF5P0g8s9K8n9HEfy0/WzMq37SrPdXdFN5/dFT/rPMHpF7AvpoXHckbcBFg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.10",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Json": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "uvJ6sHwjgrkMEJOgiC76G0mcZGXerwyyWkwX34EOjCbxKG6TCtfAoqDKAMsCvEBf9HxjlGQEgqsSMOGCmGBf+A==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.10",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Configuration.FileExtensions": "10.0.10",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10"
+ }
+ },
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ },
+ "Microsoft.Extensions.Configuration": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Configuration.FileExtensions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "ZOhZYwvbXGTgGVRwswIirofEMVHuWdxjdh0JeUZXwaF9cgcjXdz/t0ELtgaevw7ezTyv47yPNCgGreWtLkn3IQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.10",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10",
+ "Microsoft.Extensions.FileProviders.Physical": "10.0.10",
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.FileProviders.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "c5zqFCY9DiIpMovLd7/d/CTiEtrMOuQ639dhv3PABtKQIKNQikSHwQt8+N679uii9q+B55lgK28Uv64FOwEu8w==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.FileProviders.Physical": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "jhJAyo38kSrH3ARvWUk0h8itogVnQu2DCZuPo+s0Z+tXes0ugTxMPaHYzap85785eHQmPFqD9TYERqBbtGxn/w==",
+ "dependencies": {
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10",
+ "Microsoft.Extensions.FileSystemGlobbing": "10.0.10",
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.FileSystemGlobbing": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "jSOCVxEwCd4Aq925kJVz1kSO1EpX2OHYKL04qVREXkDU7Ce3pVDdHPYm+fEy8y/th2kJf/DAstRHpJAqoNWP8w=="
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
+ },
+ "servermonitormanager.core": {
+ "type": "Project"
+ }
+ },
+ "net10.0/linux-arm64": {},
+ "net10.0/linux-x64": {},
+ "net10.0/win-x64": {}
+ }
+}
\ No newline at end of file
diff --git a/src/ServerMonitorManager.Control/ControlJsonContext.cs b/src/ServerMonitorManager.Control/ControlJsonContext.cs
deleted file mode 100644
index cf30905..0000000
--- a/src/ServerMonitorManager.Control/ControlJsonContext.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace ServerMonitorManager.Control;
-
-internal sealed record LinkOrphanAuditDetails(
- string SourceNodeId,
- string TargetNodeId,
- string Protocol,
- int Port);
-
-[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
-[JsonSerializable(typeof(LinkOrphanAuditDetails))]
-internal sealed partial class ControlJsonContext : JsonSerializerContext;
diff --git a/src/ServerMonitorManager.Control/ControlMaintenance.cs b/src/ServerMonitorManager.Control/ControlMaintenance.cs
index d8e401b..de3db45 100644
--- a/src/ServerMonitorManager.Control/ControlMaintenance.cs
+++ b/src/ServerMonitorManager.Control/ControlMaintenance.cs
@@ -10,7 +10,6 @@ public sealed record ControlMaintenanceResult(
int MetricsDeleted,
int IdempotencyDeleted,
int AuditDeleted,
- int LinksDeleted,
int TokensDeleted,
int ProvisioningJobsCancelled,
int ProvisioningJobsNeedingReconciliation);
@@ -69,17 +68,16 @@ public sealed class ControlMaintenanceBackgroundService(
var result = await store.MaintainAsync(timeProvider.GetUtcNow(), stoppingToken);
await backups.CreateIfDueAsync(timeProvider.GetUtcNow(), stoppingToken);
if (result.MetricsDeleted + result.IdempotencyDeleted + result.AuditDeleted
- + result.LinksDeleted + result.TokensDeleted + result.ProvisioningJobsCancelled
+ + result.TokensDeleted + result.ProvisioningJobsCancelled
+ result.ProvisioningJobsNeedingReconciliation > 0)
{
logger.LogInformation(
"Control maintenance removed {Metrics} metrics, {Idempotency} replay records, "
- + "{Audit} audit records, {Links} completed Link policies, and {Tokens} enrollment tokens; cancelled {Cancelled} "
+ + "{Audit} audit records, and {Tokens} enrollment tokens; cancelled {Cancelled} "
+ "expired jobs and marked {Reconciliation} jobs for reconciliation.",
result.MetricsDeleted,
result.IdempotencyDeleted,
result.AuditDeleted,
- result.LinksDeleted,
result.TokensDeleted,
result.ProvisioningJobsCancelled,
result.ProvisioningJobsNeedingReconciliation);
diff --git a/src/ServerMonitorManager.Control/ControlOptions.cs b/src/ServerMonitorManager.Control/ControlOptions.cs
index a618f6d..3db0896 100644
--- a/src/ServerMonitorManager.Control/ControlOptions.cs
+++ b/src/ServerMonitorManager.Control/ControlOptions.cs
@@ -1,47 +1,38 @@
-using System.Diagnostics.CodeAnalysis;
-
namespace ServerMonitorManager.Control;
public sealed class ControlOptions
{
- [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(ControlOptions))]
- public ControlOptions()
- {
- }
-
public const string SectionName = "Control";
- public string DatabasePath { get; set; } = "/var/lib/ochenstarik-server-monitor-manager/control.db";
+ public string DatabasePath { get; init; } = "/var/lib/ochenstarik-server-monitor-manager/control.db";
- public string CertificateAuthorityPath { get; set; } = "/etc/ochenstarik-server-monitor-manager/control-ca.pfx";
+ public string CertificateAuthorityPath { get; init; } = "/etc/ochenstarik-server-monitor-manager/control-ca.pfx";
- public string? CertificateAuthorityPassword { get; set; }
+ public string? CertificateAuthorityPassword { get; init; }
- public int HeartbeatSeconds { get; set; } = 30;
+ public int HeartbeatSeconds { get; init; } = 30;
- public int MaxBufferedMetricAgeHours { get; set; } = 24;
+ public int MaxBufferedMetricAgeHours { get; init; } = 24;
- public int MetricRetentionHours { get; set; } = 168;
+ public int MetricRetentionHours { get; init; } = 168;
- public int IdempotencyRetentionHours { get; set; } = 24;
+ public int IdempotencyRetentionHours { get; init; } = 24;
- public int AuditRetentionDays { get; set; } = 90;
+ public int AuditRetentionDays { get; init; } = 90;
- public int LinkRetentionDays { get; set; } = 90;
+ public int MaintenanceIntervalMinutes { get; init; } = 15;
- public int MaintenanceIntervalMinutes { get; set; } = 15;
+ public int LinkExpirationPollSeconds { get; init; } = 15;
- public int LinkExpirationPollSeconds { get; set; } = 15;
+ public int LinkReconciliationSeconds { get; init; } = 300;
- public int LinkReconciliationSeconds { get; set; } = 300;
+ public string BackupDirectory { get; init; } = "/var/lib/ochenstarik-server-monitor-manager/backups";
- public string BackupDirectory { get; set; } = "/var/lib/ochenstarik-server-monitor-manager/backups";
+ public int BackupIntervalHours { get; init; } = 24;
- public int BackupIntervalHours { get; set; } = 24;
+ public int BackupRetentionCount { get; init; } = 7;
- public int BackupRetentionCount { get; set; } = 7;
+ public string HubHelperPath { get; init; } = "/usr/local/libexec/ochenstarik-smm-policy-apply";
- public string HubHelperPath { get; set; } = "/usr/local/libexec/ochenstarik-smm-policy-apply";
-
- public string PrivilegeEscalationPath { get; set; } = "/usr/bin/sudo";
+ public string PrivilegeEscalationPath { get; init; } = "/usr/bin/sudo";
}
diff --git a/src/ServerMonitorManager.Control/ControlStore.cs b/src/ServerMonitorManager.Control/ControlStore.cs
index b349fb5..f970fe9 100644
--- a/src/ServerMonitorManager.Control/ControlStore.cs
+++ b/src/ServerMonitorManager.Control/ControlStore.cs
@@ -352,24 +352,6 @@ public sealed partial class ControlStore(IOptions options)
SELECT changes();
DELETE FROM audit WHERE recorded_at < $audit_cutoff;
SELECT changes();
- DELETE FROM links AS historical
- WHERE EXISTS (
- SELECT 1 FROM links AS latest
- WHERE latest.source_node_id = historical.source_node_id
- AND latest.target_node_id = historical.target_node_id
- AND latest.protocol = historical.protocol
- AND latest.port = historical.port
- AND latest.desired_state = 'Disabled'
- AND latest.actual_state = 'Disabled'
- AND latest.updated_at < $link_cutoff
- AND NOT EXISTS (
- SELECT 1 FROM links AS newer
- WHERE newer.source_node_id = latest.source_node_id
- AND newer.target_node_id = latest.target_node_id
- AND newer.protocol = latest.protocol
- AND newer.port = latest.port
- AND newer.version > latest.version));
- SELECT changes();
DELETE FROM enrollment_tokens WHERE consumed_at IS NOT NULL OR expires_at < $now;
SELECT changes();
DELETE FROM device_tokens WHERE consumed_at IS NOT NULL OR expires_at < $now;
@@ -383,10 +365,8 @@ public sealed partial class ControlStore(IOptions options)
"$idempotency_cutoff", now.AddHours(-_options.IdempotencyRetentionHours).ToString("O"));
command.Parameters.AddWithValue(
"$audit_cutoff", now.AddDays(-_options.AuditRetentionDays).ToString("O"));
- command.Parameters.AddWithValue(
- "$link_cutoff", now.AddDays(-_options.LinkRetentionDays).ToString("O"));
command.Parameters.AddWithValue("$now", now.ToString("O"));
- var changes = new int[10];
+ var changes = new int[9];
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
{
for (var index = 0; index < changes.Length; index++)
@@ -404,7 +384,7 @@ public sealed partial class ControlStore(IOptions options)
optimize.CommandText = "PRAGMA optimize; PRAGMA wal_checkpoint(PASSIVE);";
await optimize.ExecuteNonQueryAsync(cancellationToken);
return new ControlMaintenanceResult(
- changes[3], changes[4], changes[5], changes[6], changes[7] + changes[8] + changes[9],
+ changes[3], changes[4], changes[5], changes[6] + changes[7] + changes[8],
changes[0], changes[1] + changes[2]);
}
@@ -1373,31 +1353,6 @@ public sealed partial class ControlStore(IOptions options)
return link;
}
- public async Task RecordLinkOrphanRemovedAsync(
- LinkRule rule,
- string actor,
- CancellationToken cancellationToken = default)
- {
- await using var connection = await OpenAsync(cancellationToken);
- await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
- var subject = $"{rule.SourceNodeId}:{rule.TargetNodeId}:{rule.Protocol}:{rule.Port}";
- await WriteAuditAsync(
- connection,
- transaction,
- actor,
- "link.orphan-removed",
- subject,
- JsonSerializer.Serialize(
- new LinkOrphanAuditDetails(
- rule.SourceNodeId,
- rule.TargetNodeId,
- rule.Protocol,
- rule.Port),
- ControlJsonContext.Default.LinkOrphanAuditDetails),
- cancellationToken);
- await transaction.CommitAsync(cancellationToken);
- }
-
public async Task GetLinkAsync(
string id,
CancellationToken cancellationToken = default)
@@ -1409,29 +1364,6 @@ public sealed partial class ControlStore(IOptions options)
return link;
}
- public async Task GetEffectiveLinkAsync(
- LinkRule rule,
- CancellationToken cancellationToken = default)
- {
- await using var connection = await OpenAsync(cancellationToken);
- var command = connection.CreateCommand();
- command.CommandText = """
- SELECT * FROM links
- WHERE source_node_id = $source
- AND target_node_id = $target
- AND protocol = $protocol
- AND port = $port
- ORDER BY version DESC
- LIMIT 1;
- """;
- command.Parameters.AddWithValue("$source", rule.SourceNodeId);
- command.Parameters.AddWithValue("$target", rule.TargetNodeId);
- command.Parameters.AddWithValue("$protocol", rule.Protocol);
- command.Parameters.AddWithValue("$port", rule.Port);
- await using var reader = await command.ExecuteReaderAsync(cancellationToken);
- return await reader.ReadAsync(cancellationToken) ? ReadLink(reader) : null;
- }
-
public async Task> ListLinksAsync(CancellationToken cancellationToken = default)
{
var result = new List();
@@ -1455,7 +1387,9 @@ public sealed partial class ControlStore(IOptions options)
command.CommandText = """
SELECT current.*
FROM links AS current
- WHERE NOT EXISTS (
+ WHERE (current.desired_state = 'Active'
+ OR (current.desired_state = 'Disabled' AND current.actual_state != 'Disabled'))
+ AND NOT EXISTS (
SELECT 1 FROM links AS newer
WHERE newer.source_node_id = current.source_node_id
AND newer.target_node_id = current.target_node_id
@@ -1771,39 +1705,7 @@ public sealed record AgentHeartbeatMutation(
AgentHeartbeatResponse Response,
bool RequiresReconciliation);
-public sealed record LinkReconciliationResult
-{
- public LinkReconciliationResult(
- int examined,
- int converged,
- int failed,
- int deferred,
- IReadOnlyList failedPolicyIds,
- IReadOnlyList deferredPolicyIds)
- {
- if (converged + failed + deferred != examined)
- {
- throw new InvalidOperationException(
- $"Link reconciliation classification invariant failed: examined={examined}, "
- + $"converged={converged}, failed={failed}, deferred={deferred}; "
- + $"failed IDs=[{string.Join(',', failedPolicyIds)}], "
- + $"deferred IDs=[{string.Join(',', deferredPolicyIds)}].");
- }
- Examined = examined;
- Converged = converged;
- Failed = failed;
- Deferred = deferred;
- FailedPolicyIds = failedPolicyIds;
- DeferredPolicyIds = deferredPolicyIds;
- }
-
- public int Examined { get; }
- public int Converged { get; }
- public int Failed { get; }
- public int Deferred { get; }
- public IReadOnlyList FailedPolicyIds { get; }
- public IReadOnlyList DeferredPolicyIds { get; }
-}
+public sealed record LinkReconciliationResult(int Applied, int Failed);
public sealed record AgentReenrollmentMutation(
CertificateReenrollmentTicket Ticket,
diff --git a/src/ServerMonitorManager.Control/LinkPolicyApplier.cs b/src/ServerMonitorManager.Control/LinkPolicyApplier.cs
index 688deae..106a948 100644
--- a/src/ServerMonitorManager.Control/LinkPolicyApplier.cs
+++ b/src/ServerMonitorManager.Control/LinkPolicyApplier.cs
@@ -6,10 +6,8 @@ namespace ServerMonitorManager.Control;
public interface ILinkPolicyApplier
{
- Task> ListRulesAsync(CancellationToken cancellationToken);
Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken);
Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken);
- Task ApplyDisconnectAsync(LinkRule rule, CancellationToken cancellationToken);
Task IsConnectedAsync(LinkPolicy link, CancellationToken cancellationToken);
Task GetReconciliationRequestAsync(CancellationToken cancellationToken)
=> Task.FromResult(null);
@@ -17,8 +15,6 @@ public interface ILinkPolicyApplier
=> Task.CompletedTask;
}
-public sealed record LinkRule(string SourceNodeId, string TargetNodeId, string Protocol, int Port);
-
public sealed class MeshFirewallUnavailableException : InvalidOperationException
{
public MeshFirewallUnavailableException(string message) : base(message)
@@ -26,38 +22,8 @@ public sealed class MeshFirewallUnavailableException : InvalidOperationException
}
}
-public sealed class MeshNodeNotActivatedException : InvalidOperationException
-{
- public MeshNodeNotActivatedException(string message) : base(message)
- {
- }
-}
-
public sealed class LinkPolicyApplier(IOptions options) : ILinkPolicyApplier
{
- public async Task> ListRulesAsync(CancellationToken cancellationToken)
- {
- var output = await RunAsync(["link-list"], cancellationToken);
- if (output.Length == 0)
- {
- return [];
- }
- var rules = new List();
- foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
- {
- var fields = line.Trim().Split('\t');
- if (fields.Length != 4
- || !int.TryParse(fields[3], System.Globalization.NumberStyles.None,
- System.Globalization.CultureInfo.InvariantCulture, out var port)
- || port is < 1 or > 65535)
- {
- throw new InvalidOperationException("Hub policy helper returned an invalid link-list record.");
- }
- rules.Add(new LinkRule(fields[0], fields[1], fields[2], port));
- }
- return rules;
- }
-
public Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken)
=> RunAsync(
[
@@ -71,18 +37,13 @@ public sealed class LinkPolicyApplier(IOptions options) : ILinkP
cancellationToken);
public Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken)
- => ApplyDisconnectAsync(
- new LinkRule(link.SourceNodeId, link.TargetNodeId, link.Protocol, link.Port),
- cancellationToken);
-
- public Task ApplyDisconnectAsync(LinkRule rule, CancellationToken cancellationToken)
=> RunAsync(
[
"link-disconnect",
- rule.SourceNodeId,
- rule.TargetNodeId,
- rule.Protocol,
- rule.Port.ToString(System.Globalization.CultureInfo.InvariantCulture)
+ link.SourceNodeId,
+ link.TargetNodeId,
+ link.Protocol,
+ link.Port.ToString(System.Globalization.CultureInfo.InvariantCulture)
],
cancellationToken);
@@ -153,11 +114,6 @@ public sealed class LinkPolicyApplier(IOptions options) : ILinkP
{
throw new MeshFirewallUnavailableException(LinkService.FirewallUnavailableCode);
}
- if (process.ExitCode == 80
- && string.Equals(message, LinkService.NodeNotActivatedCode, StringComparison.Ordinal))
- {
- throw new MeshNodeNotActivatedException(LinkService.NodeNotActivatedCode);
- }
throw new InvalidOperationException(string.IsNullOrWhiteSpace(message)
? $"Hub policy helper exited with code {process.ExitCode}."
: message);
diff --git a/src/ServerMonitorManager.Control/LinkReconciliationBackgroundService.cs b/src/ServerMonitorManager.Control/LinkReconciliationBackgroundService.cs
index 5e971bf..ac7abe5 100644
--- a/src/ServerMonitorManager.Control/LinkReconciliationBackgroundService.cs
+++ b/src/ServerMonitorManager.Control/LinkReconciliationBackgroundService.cs
@@ -9,13 +9,10 @@ public sealed class LinkReconciliationBackgroundService(
TimeProvider timeProvider,
ILogger logger) : BackgroundService
{
- private const int PromptAttemptLimit = 3;
private readonly SemaphoreSlim _passGate = new(1, 1);
private DateTimeOffset? _nextRegularAt;
private DateTimeOffset? _backoffUntil;
private int _unavailableAttempts;
- private int _promptFailureAttempts;
- private bool _promptThrottleWarningLogged;
internal async Task RunOnceAsync(
CancellationToken cancellationToken = default)
@@ -37,59 +34,27 @@ public sealed class LinkReconciliationBackgroundService(
{
return null;
}
- var promptBypassesThrottle = requestGeneration is not null
- && _promptFailureAttempts < PromptAttemptLimit;
- if (!promptBypassesThrottle && _nextRegularAt is not null && now < _nextRegularAt)
+ if (requestGeneration is null && _nextRegularAt is not null && now < _nextRegularAt)
{
return null;
}
+ var result = await links.ReconcileAllAsync(cancellationToken);
var interval = TimeSpan.FromSeconds(options.Value.LinkReconciliationSeconds);
- _nextRegularAt = now + interval;
- LinkFullReconciliationResult result;
- try
- {
- result = await links.ReconcileAllAsync(cancellationToken);
- }
- catch (Exception exception) when (
- exception is not OperationCanceledException && requestGeneration is not null)
- {
- RegisterPromptFailure([]);
- throw;
- }
if (result.FirewallUnavailable)
{
_unavailableAttempts = Math.Min(_unavailableAttempts + 1, 4);
_backoffUntil = now + TimeSpan.FromTicks(interval.Ticks * _unavailableAttempts);
- return result;
}
-
- _unavailableAttempts = 0;
- _backoffUntil = null;
- if (requestGeneration is null)
+ else
{
- return result;
- }
-
- if (result.Failed > 0)
- {
- RegisterPromptFailure(result.FailedPolicyIds);
- return result;
- }
-
- try
- {
- await applier.CompleteReconciliationAsync(requestGeneration, cancellationToken);
- _promptFailureAttempts = 0;
- _promptThrottleWarningLogged = false;
- }
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- logger.LogWarning(
- exception,
- "Link reconciliation completed, but marker generation {Generation} could not be consumed.",
- requestGeneration);
- RegisterPromptFailure(["marker-completion"]);
+ _unavailableAttempts = 0;
+ _backoffUntil = null;
+ _nextRegularAt = now + interval;
+ if (requestGeneration is not null && result.Failed == 0)
+ {
+ await applier.CompleteReconciliationAsync(requestGeneration, cancellationToken);
+ }
}
return result;
}
@@ -99,19 +64,6 @@ public sealed class LinkReconciliationBackgroundService(
}
}
- private void RegisterPromptFailure(IReadOnlyList failedPolicyIds)
- {
- _promptFailureAttempts = Math.Min(_promptFailureAttempts + 1, PromptAttemptLimit);
- if (_promptFailureAttempts == PromptAttemptLimit && !_promptThrottleWarningLogged)
- {
- _promptThrottleWarningLogged = true;
- logger.LogWarning(
- "Link reconciliation marker prompt exhausted after {Attempts} failures; regular throttle restored. Failed policy IDs: {FailedPolicyIds}.",
- PromptAttemptLimit,
- failedPolicyIds.Count == 0 ? "unknown" : string.Join(",", failedPolicyIds));
- }
- }
-
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var pollSeconds = Math.Min(options.Value.LinkReconciliationSeconds, 30);
@@ -124,15 +76,10 @@ public sealed class LinkReconciliationBackgroundService(
if (result is not null)
{
logger.LogInformation(
- "Link reconciliation completed: {Examined} examined, {Converged} converged, {Deferred} deferred, {Failed} failed, firewall unavailable: {Unavailable}. Deferred policy IDs: {DeferredPolicyIds}.",
+ "Link reconciliation completed: {Examined} examined, {Failed} failed, firewall unavailable: {Unavailable}.",
result.Examined,
- result.Converged,
- result.Deferred,
result.Failed,
- result.FirewallUnavailable,
- result.DeferredPolicyIds.Count == 0
- ? "none"
- : string.Join(",", result.DeferredPolicyIds));
+ result.FirewallUnavailable);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
diff --git a/src/ServerMonitorManager.Control/LinkService.cs b/src/ServerMonitorManager.Control/LinkService.cs
index 21c5ed9..e76176a 100644
--- a/src/ServerMonitorManager.Control/LinkService.cs
+++ b/src/ServerMonitorManager.Control/LinkService.cs
@@ -11,7 +11,6 @@ public sealed class LinkService(
{
public const string FirewallUnavailableCode = "mesh.firewall-unavailable";
public const string FirewallAvailableCode = "mesh.firewall-available";
- public const string NodeNotActivatedCode = "mesh.node-not-activated";
private readonly ConcurrentDictionary _reconciliationLocks = new();
private readonly ConcurrentDictionary _nodeLocks = new();
@@ -109,12 +108,8 @@ public sealed class LinkService(
string nodeId,
CancellationToken cancellationToken)
{
- var examined = 0;
- var converged = 0;
- var deferred = 0;
+ var reconciled = 0;
var failed = 0;
- var failedPolicyIds = new List();
- var deferredPolicyIds = new List();
var links = await store.ListEffectiveLinksForNodeAsync(nodeId, cancellationToken);
foreach (var candidate in links)
{
@@ -133,20 +128,10 @@ public sealed class LinkService(
current.DesiredState == "Active",
$"system:reconnect:{nodeId}",
cancellationToken);
- examined++;
+ reconciled++;
if (result.ActualState is "Failed" or "Partial")
{
failed++;
- failedPolicyIds.Add(result.Id);
- }
- else if (result.ActualState == "PendingActivation")
- {
- deferred++;
- deferredPolicyIds.Add(result.Id);
- }
- else if (result.ActualState == (current.DesiredState == "Active" ? "Active" : "Disabled"))
- {
- converged++;
}
}
finally
@@ -154,223 +139,61 @@ public sealed class LinkService(
gate.Release();
}
}
- return new LinkReconciliationResult(
- examined, converged, failed, deferred, failedPolicyIds, deferredPolicyIds);
+ return new LinkReconciliationResult(reconciled, failed);
}
public async Task ReconcileAllAsync(
CancellationToken cancellationToken = default)
{
- IReadOnlyList factualRules;
- try
- {
- factualRules = await applier.ListRulesAsync(cancellationToken);
- }
- catch (MeshFirewallUnavailableException)
- {
- var unavailableCandidates = await store.ListEffectiveLinksAsync(cancellationToken);
- return await CompleteFirewallUnavailablePassAsync(
- unavailableCandidates, 0, 0, cancellationToken);
- }
-
var candidates = await store.ListEffectiveLinksAsync(cancellationToken);
var recoveringFirewall = candidates.Any(candidate =>
string.Equals(candidate.LastError, FirewallUnavailableCode, StringComparison.Ordinal));
- var factualCounts = factualRules
- .GroupBy(static rule => rule)
- .ToDictionary(static group => group.Key, static group => group.Count());
- var processedKeys = new HashSet();
- var batch = new FullReconciliationBatch();
- var pendingClassifications = new List<(string Id, string ExpectedState)>();
var examined = 0;
- var converged = 0;
- var deferred = 0;
var failed = 0;
- var failedPolicyIds = new List();
- var deferredPolicyIds = new List();
-
+ var firewallUnavailable = false;
foreach (var candidate in candidates)
{
- var firewallUnavailable = false;
- using (await AcquireNodeLocksAsync(
- [candidate.SourceNodeId, candidate.TargetNodeId], cancellationToken))
+ using var nodeLease = await AcquireNodeLocksAsync(
+ [candidate.SourceNodeId, candidate.TargetNodeId], cancellationToken);
+ var gate = await AcquireLinkGateAsync(candidate.Id, cancellationToken);
+ try
{
- var candidateRule = ToRule(candidate);
- var selected = await store.GetEffectiveLinkAsync(candidateRule, cancellationToken);
- if (selected is null)
+ var current = await GetCurrentEffectiveAsync(candidate.Id, cancellationToken);
+ if (current is null || !IsEligibleForFullReconciliation(current))
{
continue;
}
- var gate = await AcquireLinkGateAsync(selected.Id, cancellationToken);
- try
+ var result = await ConvergeAsync(
+ current, current.DesiredState == "Active", "system:reconcile", cancellationToken);
+ examined++;
+ if (result.LastError == FirewallUnavailableCode)
{
- var current = await store.GetEffectiveLinkAsync(candidateRule, cancellationToken);
- if (current is null)
- {
- continue;
- }
- var rule = ToRule(current);
- if (!processedKeys.Add(rule))
- {
- continue;
- }
- factualCounts.TryGetValue(rule, out var factualCount);
- var result = await ConvergeAsync(
- current,
- current.DesiredState == "Active",
- "system:reconcile",
- cancellationToken,
- factualCount,
- batch: batch);
- examined++;
- if (result.LastError == FirewallUnavailableCode)
- {
- firewallUnavailable = true;
- }
- else if (result.ActualState is "Failed" or "Partial")
- {
- failed++;
- failedPolicyIds.Add(result.Id);
- }
- else if (result.ActualState == "PendingActivation")
- {
- deferred++;
- deferredPolicyIds.Add(result.Id);
- }
- else if (result.ActualState == (current.DesiredState == "Active" ? "Active" : "Disabled"))
- {
- converged++;
- }
- else if (batch.Contains(result.Id))
- {
- pendingClassifications.Add((
- result.Id,
- current.DesiredState == "Active" ? "Active" : "Disabled"));
- }
- else
- {
- failed++;
- failedPolicyIds.Add(result.Id);
- }
+ firewallUnavailable = true;
}
- finally
- {
- gate.Release();
- }
- }
- if (firewallUnavailable)
- {
- return await CompleteFirewallUnavailablePassAsync(
- candidates, examined, converged, cancellationToken);
- }
- }
-
- foreach (var orphan in factualCounts.Keys.Where(rule => !processedKeys.Contains(rule)))
- {
- var firewallUnavailable = false;
- using (await AcquireNodeLocksAsync(
- [orphan.SourceNodeId, orphan.TargetNodeId], cancellationToken))
- {
- var selected = await store.GetEffectiveLinkAsync(orphan, cancellationToken);
- var gate = await AcquireLinkGateAsync(
- selected?.Id
- ?? $"orphan:{orphan.SourceNodeId}:{orphan.TargetNodeId}:{orphan.Protocol}:{orphan.Port}",
- cancellationToken);
- try
- {
- var persisted = await store.GetEffectiveLinkAsync(orphan, cancellationToken);
- var target = persisted ?? new LinkPolicy(
- $"orphan:{orphan.SourceNodeId}:{orphan.TargetNodeId}:{orphan.Protocol}:{orphan.Port}",
- orphan.SourceNodeId,
- orphan.TargetNodeId,
- orphan.Protocol,
- orphan.Port,
- 0,
- "factual orphan",
- "Disabled",
- "Active",
- 0,
- DateTimeOffset.UtcNow,
- null,
- DateTimeOffset.UtcNow,
- null);
- var result = await ConvergeAsync(
- target,
- expectedConnected: persisted?.DesiredState == "Active",
- "system:reconcile",
- cancellationToken,
- factualCounts[orphan],
- persisted: persisted is not null,
- batch: batch);
- examined++;
- var expectedState = persisted?.DesiredState == "Active" ? "Active" : "Disabled";
- if (result.LastError == FirewallUnavailableCode)
- {
- firewallUnavailable = true;
- }
- else if (result.ActualState == expectedState)
- {
- converged++;
- }
- else if (result.ActualState == "PendingActivation")
- {
- deferred++;
- deferredPolicyIds.Add(result.Id);
- }
- else if (batch.Contains(result.Id))
- {
- pendingClassifications.Add((result.Id, expectedState));
- }
- else
- {
- failed++;
- failedPolicyIds.Add(result.Id);
- }
- }
- finally
- {
- gate.Release();
- }
- }
- if (firewallUnavailable)
- {
- return await CompleteFirewallUnavailablePassAsync(
- candidates, examined, converged, cancellationToken);
- }
- }
-
- if (batch.MutationAttempted)
- {
- IReadOnlyDictionary finalized;
- try
- {
- finalized = await FinalizeBatchAsync(batch, cancellationToken);
- }
- catch (MeshFirewallUnavailableException)
- {
- return await CompleteFirewallUnavailablePassAsync(
- candidates, examined, converged, cancellationToken);
- }
- foreach (var pendingClassification in pendingClassifications)
- {
- var result = finalized[pendingClassification.Id];
- if (result.ActualState == pendingClassification.ExpectedState)
- {
- converged++;
- }
- else if (result.ActualState == "PendingActivation")
- {
- deferred++;
- deferredPolicyIds.Add(result.Id);
- }
- else
+ else if (result.ActualState is "Failed" or "Partial")
{
failed++;
- failedPolicyIds.Add(result.Id);
}
}
+ finally
+ {
+ gate.Release();
+ }
+ if (firewallUnavailable)
+ {
+ break;
+ }
+ }
+ if (firewallUnavailable)
+ {
+ await MarkFirewallUnavailableAsync(candidates, cancellationToken);
+ events.Publish(
+ FirewallUnavailableCode,
+ "mesh",
+ JsonSerializer.Serialize(
+ new ControlError(FirewallUnavailableCode), SmmJsonContext.Default.ControlError));
+ return new LinkFullReconciliationResult(examined, candidates.Count, true);
}
-
if (recoveringFirewall)
{
events.Publish(
@@ -379,14 +202,7 @@ public sealed class LinkService(
JsonSerializer.Serialize(
new ControlError(FirewallAvailableCode), SmmJsonContext.Default.ControlError));
}
- return new LinkFullReconciliationResult(
- examined,
- converged,
- failed,
- deferred,
- false,
- failedPolicyIds,
- deferredPolicyIds);
+ return new LinkFullReconciliationResult(examined, failed, false);
}
public async Task ExpireDueLinksAsync(
@@ -443,17 +259,11 @@ public sealed class LinkService(
LinkPolicy link,
bool expectedConnected,
string actor,
- CancellationToken cancellationToken,
- int? knownFactualCount = null,
- bool persisted = true,
- FullReconciliationBatch? batch = null)
+ CancellationToken cancellationToken)
{
- var current = persisted
- ? await store.GetLinkAsync(link.Id, cancellationToken) ?? link
- : link;
- if (persisted
- && ((current.DesiredState == "Active") != expectedConnected
- || !await store.IsEffectiveLinkAsync(current, cancellationToken)))
+ var current = await store.GetLinkAsync(link.Id, cancellationToken) ?? link;
+ if ((current.DesiredState == "Active") != expectedConnected
+ || !await store.IsEffectiveLinkAsync(current, cancellationToken))
{
return current;
}
@@ -464,87 +274,45 @@ public sealed class LinkService(
var failureEvent = expectedConnected ? "link.failed" : "link.partial";
try
{
- var factualCount = knownFactualCount
- ?? await CountExactRulesAsync(current, cancellationToken);
- var isConnected = factualCount > 0;
- var duplicateActiveRule = expectedConnected && factualCount > 1;
- var changedFact = isConnected != expectedConnected || duplicateActiveRule;
+ var isConnected = await applier.IsConnectedAsync(current, cancellationToken);
+ var changedFact = isConnected != expectedConnected;
if (changedFact)
{
var pendingState = expectedConnected ? "Connecting" : "Disconnecting";
- if (persisted && (current.ActualState != pendingState || current.LastError is not null))
+ if (current.ActualState != pendingState || current.LastError is not null)
{
current = await store.SetLinkActualStateAsync(
current.Id, pendingState, null, actor, cancellationToken) ?? current;
}
- if (duplicateActiveRule)
- {
- batch?.MarkMutationAttempted();
- await applier.ApplyDisconnectAsync(current, cancellationToken);
- if (batch is null)
- {
- await VerifyExactFactualCountAsync(current, 0, cancellationToken);
- }
- }
- if (expectedConnected)
- {
- batch?.MarkMutationAttempted();
- await applier.ApplyConnectAsync(current, cancellationToken);
- if (batch is null)
- {
- await VerifyExactFactualCountAsync(current, 1, cancellationToken);
- }
- }
- else if (persisted)
- {
- batch?.MarkMutationAttempted();
- await applier.ApplyDisconnectAsync(current, cancellationToken);
- if (batch is null)
- {
- await VerifyExactFactualCountAsync(current, 0, cancellationToken);
- }
- }
- else
- {
- batch?.MarkMutationAttempted();
- await applier.ApplyDisconnectAsync(ToRule(current), cancellationToken);
- if (batch is null)
- {
- await VerifyExactFactualCountAsync(current, 0, cancellationToken);
- }
- }
if (actor.StartsWith("system:", StringComparison.Ordinal))
{
Publish("link.reconciling", current);
}
- if (batch is not null)
+ if (expectedConnected)
{
- batch.Stage(current, expectedConnected, actor, persisted);
- return current;
+ await applier.ApplyConnectAsync(current, cancellationToken);
}
+ else
+ {
+ await applier.ApplyDisconnectAsync(current, cancellationToken);
+ }
+ await VerifyFactualStateAsync(current, expectedConnected, cancellationToken);
}
- current = await FinalizeConvergenceAsync(
- current, expectedConnected, actor, persisted, changedFact, cancellationToken);
- }
- catch (MeshNodeNotActivatedException) when (expectedConnected)
- {
- if (persisted)
+ if (current.ActualState != completedState || current.LastError is not null)
{
current = await store.SetLinkActualStateAsync(
- current.Id, "PendingActivation", NodeNotActivatedCode, actor, cancellationToken) ?? current;
- Publish("link.pending-node-activation", current);
+ current.Id, completedState, null, actor, cancellationToken) ?? current;
+ Publish(completedEvent, current);
}
- else
+ if (changedFact && actor == "system:reconcile")
{
- current = current with { ActualState = "PendingActivation", LastError = NodeNotActivatedCode };
+ Publish(expectedConnected ? "link.reapplied" : "link.orphan-removed", current);
}
}
catch (MeshFirewallUnavailableException)
{
- current = persisted
- ? await store.SetLinkActualStateAsync(
- current.Id, failureState, FirewallUnavailableCode, actor, cancellationToken) ?? current
- : current with { ActualState = failureState, LastError = FirewallUnavailableCode };
+ current = await store.SetLinkActualStateAsync(
+ current.Id, failureState, FirewallUnavailableCode, actor, cancellationToken) ?? current;
if (actor != "system:reconcile")
{
Publish(failureEvent, current);
@@ -552,148 +320,13 @@ public sealed class LinkService(
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
- var error = CompactError(exception);
- current = persisted
- ? await store.SetLinkActualStateAsync(
- current.Id, failureState, error, actor, cancellationToken) ?? current
- : current with { ActualState = failureState, LastError = error };
+ current = await store.SetLinkActualStateAsync(
+ current.Id, failureState, CompactError(exception), actor, cancellationToken) ?? current;
Publish(failureEvent, current);
}
return current;
}
- private async Task> FinalizeBatchAsync(
- FullReconciliationBatch batch,
- CancellationToken cancellationToken)
- {
- var pendingItems = batch.Pending.ToArray();
- using var nodeLease = await AcquireNodeLocksAsync(
- pendingItems.SelectMany(static pending =>
- new[] { pending.Link.SourceNodeId, pending.Link.TargetNodeId }),
- cancellationToken);
- var selectedByPendingId = new Dictionary(StringComparer.Ordinal);
- foreach (var pending in pendingItems)
- {
- selectedByPendingId[pending.Link.Id] = await store.GetEffectiveLinkAsync(
- ToRule(pending.Link), cancellationToken);
- }
- using var linkLease = await AcquireLinkGatesAsync(
- pendingItems.Select(pending =>
- selectedByPendingId[pending.Link.Id]?.Id ?? pending.Link.Id),
- cancellationToken);
- var finalRules = await applier.ListRulesAsync(cancellationToken);
- var currentByPendingId = new Dictionary(StringComparer.Ordinal);
- foreach (var pending in pendingItems)
- {
- currentByPendingId[pending.Link.Id] = await store.GetEffectiveLinkAsync(
- ToRule(pending.Link), cancellationToken);
- }
- var finalCounts = finalRules
- .GroupBy(static rule => rule)
- .ToDictionary(static group => group.Key, static group => group.Count());
- var finalized = new Dictionary(StringComparer.Ordinal);
- foreach (var pending in pendingItems)
- {
- var current = currentByPendingId[pending.Link.Id];
- if (IsStale(pending, current))
- {
- finalized.Add(pending.Link.Id, CreateStaleBatchResult(pending, current));
- continue;
- }
- finalCounts.TryGetValue(ToRule(pending.Link), out var actualCount);
- var expectedCount = pending.ExpectedConnected ? 1 : 0;
- LinkPolicy result;
- if (actualCount == expectedCount)
- {
- result = await FinalizeConvergenceAsync(
- pending.Link,
- pending.ExpectedConnected,
- pending.Actor,
- pending.Persisted,
- changedFact: true,
- cancellationToken);
- }
- else
- {
- result = await FailConvergenceAsync(
- pending.Link,
- pending.ExpectedConnected,
- pending.Actor,
- pending.Persisted,
- $"Factual Link policy count must be exactly {expectedCount} after application, but was {actualCount}.",
- cancellationToken);
- }
- finalized.Add(pending.Link.Id, result);
- }
- return finalized;
- }
-
- private static bool IsStale(PendingConvergence pending, LinkPolicy? current)
- => pending.Persisted
- ? current is null
- || current.Id != pending.Link.Id
- || current.Version != pending.Link.Version
- || (current.DesiredState == "Active") != pending.ExpectedConnected
- : current is not null;
-
- private static LinkPolicy CreateStaleBatchResult(
- PendingConvergence pending,
- LinkPolicy? current)
- => (current ?? pending.Link) with
- {
- ActualState = pending.ExpectedConnected ? "Failed" : "Partial",
- LastError = "Link policy changed before batch finalization."
- };
-
- private async Task FinalizeConvergenceAsync(
- LinkPolicy current,
- bool expectedConnected,
- string actor,
- bool persisted,
- bool changedFact,
- CancellationToken cancellationToken)
- {
- var completedState = expectedConnected ? "Active" : "Disabled";
- var completedEvent = expectedConnected ? "link.active" : "link.disabled";
- if (persisted && (current.ActualState != completedState || current.LastError is not null))
- {
- current = await store.SetLinkActualStateAsync(
- current.Id, completedState, null, actor, cancellationToken) ?? current;
- Publish(completedEvent, current);
- }
- else if (!persisted)
- {
- current = current with { ActualState = completedState, LastError = null };
- }
- if (changedFact && actor == "system:reconcile")
- {
- if (!expectedConnected)
- {
- await store.RecordLinkOrphanRemovedAsync(ToRule(current), actor, cancellationToken);
- }
- Publish(expectedConnected ? "link.reapplied" : "link.orphan-removed", current);
- }
- return current;
- }
-
- private async Task FailConvergenceAsync(
- LinkPolicy current,
- bool expectedConnected,
- string actor,
- bool persisted,
- string error,
- CancellationToken cancellationToken)
- {
- var failureState = expectedConnected ? "Failed" : "Partial";
- var failureEvent = expectedConnected ? "link.failed" : "link.partial";
- current = persisted
- ? await store.SetLinkActualStateAsync(
- current.Id, failureState, error, actor, cancellationToken) ?? current
- : current with { ActualState = failureState, LastError = error };
- Publish(failureEvent, current);
- return current;
- }
-
private async Task GetCurrentEffectiveAsync(
string id,
CancellationToken cancellationToken)
@@ -714,17 +347,11 @@ public sealed class LinkService(
{
using var nodeLease = await AcquireNodeLocksAsync(
[candidate.SourceNodeId, candidate.TargetNodeId], cancellationToken);
- var rule = ToRule(candidate);
- var selected = await store.GetEffectiveLinkAsync(rule, cancellationToken);
- if (selected is null)
- {
- continue;
- }
- var gate = await AcquireLinkGateAsync(selected.Id, cancellationToken);
+ var gate = await AcquireLinkGateAsync(candidate.Id, cancellationToken);
try
{
- var current = await store.GetEffectiveLinkAsync(rule, cancellationToken);
- if (current is not null)
+ var current = await GetCurrentEffectiveAsync(candidate.Id, cancellationToken);
+ if (current is not null && IsEligibleForFullReconciliation(current))
{
await store.SetLinkActualStateAsync(
current.Id, "Partial", FirewallUnavailableCode, "system:reconcile", cancellationToken);
@@ -737,29 +364,9 @@ public sealed class LinkService(
}
}
- private async Task CompleteFirewallUnavailablePassAsync(
- IReadOnlyList candidates,
- int examined,
- int converged,
- CancellationToken cancellationToken)
- {
- await MarkFirewallUnavailableAsync(candidates, cancellationToken);
- events.Publish(
- FirewallUnavailableCode,
- "mesh",
- JsonSerializer.Serialize(
- new ControlError(FirewallUnavailableCode), SmmJsonContext.Default.ControlError));
- var failed = examined - converged;
- var failedPolicyIds = candidates
- .Take(failed)
- .Select(static candidate => candidate.Id)
- .ToArray();
- return new LinkFullReconciliationResult(
- examined, converged, failed, 0, true, failedPolicyIds, []);
- }
-
- private static LinkRule ToRule(LinkPolicy link)
- => new(link.SourceNodeId, link.TargetNodeId, link.Protocol, link.Port);
+ private static bool IsEligibleForFullReconciliation(LinkPolicy link)
+ => link.DesiredState == "Active"
+ || (link.DesiredState == "Disabled" && link.ActualState != "Disabled");
private void Publish(string type, LinkPolicy link)
=> events.Publish(
@@ -805,50 +412,15 @@ public sealed class LinkService(
return gate;
}
- private async Task AcquireLinkGatesAsync(
- IEnumerable ids,
+ private async Task VerifyFactualStateAsync(
+ LinkPolicy link,
+ bool expectedConnected,
CancellationToken cancellationToken)
{
- var gates = ids
- .Distinct(StringComparer.Ordinal)
- .Order(StringComparer.Ordinal)
- .Select(id => _reconciliationLocks.GetOrAdd(id, static _ => new SemaphoreSlim(1, 1)))
- .ToArray();
- var acquired = 0;
- try
- {
- foreach (var gate in gates)
- {
- await gate.WaitAsync(cancellationToken);
- acquired++;
- }
- return new LockLease(gates);
- }
- catch
- {
- for (var index = acquired - 1; index >= 0; index--)
- {
- gates[index].Release();
- }
- throw;
- }
- }
-
- private async Task CountExactRulesAsync(
- LinkPolicy link,
- CancellationToken cancellationToken)
- => (await applier.ListRulesAsync(cancellationToken)).Count(rule => rule == ToRule(link));
-
- private async Task VerifyExactFactualCountAsync(
- LinkPolicy link,
- int expectedCount,
- CancellationToken cancellationToken)
- {
- var actualCount = await CountExactRulesAsync(link, cancellationToken);
- if (actualCount != expectedCount)
+ if (await applier.IsConnectedAsync(link, cancellationToken) != expectedConnected)
{
throw new InvalidOperationException(
- $"Factual Link policy count must be exactly {expectedCount} after application, but was {actualCount}.");
+ $"Factual Link policy is {(expectedConnected ? "disabled" : "active")} after application.");
}
}
@@ -866,64 +438,7 @@ public sealed class LinkService(
}
}
}
-
- private sealed class FullReconciliationBatch
- {
- private readonly Dictionary _pending =
- new(StringComparer.Ordinal);
-
- public bool MutationAttempted { get; private set; }
- public IReadOnlyCollection Pending => _pending.Values;
-
- public void MarkMutationAttempted() => MutationAttempted = true;
-
- public void Stage(LinkPolicy link, bool expectedConnected, string actor, bool persisted)
- => _pending[link.Id] = new PendingConvergence(link, expectedConnected, actor, persisted);
-
- public bool Contains(string id) => _pending.ContainsKey(id);
- }
-
- private sealed record PendingConvergence(
- LinkPolicy Link,
- bool ExpectedConnected,
- string Actor,
- bool Persisted);
}
public sealed record LinkExpirationResult(int Disabled, int Failed);
-public sealed record LinkFullReconciliationResult
-{
- public LinkFullReconciliationResult(
- int examined,
- int converged,
- int failed,
- int deferred,
- bool firewallUnavailable,
- IReadOnlyList failedPolicyIds,
- IReadOnlyList deferredPolicyIds)
- {
- if (converged + failed + deferred != examined)
- {
- throw new InvalidOperationException(
- $"Full Link reconciliation classification invariant failed: examined={examined}, "
- + $"converged={converged}, failed={failed}, deferred={deferred}; "
- + $"failed IDs=[{string.Join(',', failedPolicyIds)}], "
- + $"deferred IDs=[{string.Join(',', deferredPolicyIds)}].");
- }
- Examined = examined;
- Converged = converged;
- Failed = failed;
- Deferred = deferred;
- FirewallUnavailable = firewallUnavailable;
- FailedPolicyIds = failedPolicyIds;
- DeferredPolicyIds = deferredPolicyIds;
- }
-
- public int Examined { get; }
- public int Converged { get; }
- public int Failed { get; }
- public int Deferred { get; }
- public bool FirewallUnavailable { get; }
- public IReadOnlyList FailedPolicyIds { get; }
- public IReadOnlyList DeferredPolicyIds { get; }
-}
+public sealed record LinkFullReconciliationResult(int Examined, int Failed, bool FirewallUnavailable);
diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs
index 86d62db..9ee3a22 100644
--- a/src/ServerMonitorManager.Control/Program.cs
+++ b/src/ServerMonitorManager.Control/Program.cs
@@ -44,7 +44,6 @@ builder.Services.AddOptions()
&& options.MetricRetentionHours is >= 24 and <= 8760
&& options.IdempotencyRetentionHours is >= 1 and <= 720
&& options.AuditRetentionDays is >= 1 and <= 3650
- && options.LinkRetentionDays is >= 1 and <= 3650
&& options.MaintenanceIntervalMinutes is >= 1 and <= 1440
&& options.LinkExpirationPollSeconds is >= 1 and <= 300
&& options.LinkReconciliationSeconds is >= 30 and <= 3600
diff --git a/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj b/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj
index 40b6087..7d80e8b 100644
--- a/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj
+++ b/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj
@@ -4,15 +4,14 @@
enable
enable
true
- true
0.1.0
ochenstarik-smm-control
-
-
-
+
+
+
diff --git a/src/ServerMonitorManager.Control/appsettings.json b/src/ServerMonitorManager.Control/appsettings.json
index 83afb4d..d395405 100644
--- a/src/ServerMonitorManager.Control/appsettings.json
+++ b/src/ServerMonitorManager.Control/appsettings.json
@@ -8,7 +8,6 @@
"MetricRetentionHours": 168,
"IdempotencyRetentionHours": 24,
"AuditRetentionDays": 90,
- "LinkRetentionDays": 90,
"MaintenanceIntervalMinutes": 15,
"LinkExpirationPollSeconds": 15,
"LinkReconciliationSeconds": 300,
diff --git a/src/ServerMonitorManager.Control/packages.lock.json b/src/ServerMonitorManager.Control/packages.lock.json
new file mode 100644
index 0000000..f4bfc72
--- /dev/null
+++ b/src/ServerMonitorManager.Control/packages.lock.json
@@ -0,0 +1,90 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Microsoft.AspNetCore.Authentication.Certificate": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "e2WGVp2QrCHZqhmLMt82nj3AHWyKtAsl3Gu959ruP2SXdlpjwYhhYuCnTxHAqViiiM6croqvu3KMXbBqzfBLvQ=="
+ },
+ "Microsoft.Data.Sqlite": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "58JwZ39lCvRXHfV5O6RQfbHEDu8ZsXI7weRoVRR4jrGRxxkS+qyeuGrN2xD+9VYEICApYr0iTSL6PuU+DdsvPg==",
+ "dependencies": {
+ "Microsoft.Data.Sqlite.Core": "10.0.10",
+ "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
+ "SQLitePCLRaw.core": "2.1.11"
+ }
+ },
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ },
+ "SQLitePCLRaw.bundle_e_sqlite3": {
+ "type": "Direct",
+ "requested": "[2.1.12, )",
+ "resolved": "2.1.12",
+ "contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==",
+ "dependencies": {
+ "SQLitePCLRaw.lib.e_sqlite3": "2.1.12",
+ "SQLitePCLRaw.provider.e_sqlite3": "2.1.12"
+ }
+ },
+ "Microsoft.Data.Sqlite.Core": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==",
+ "dependencies": {
+ "SQLitePCLRaw.core": "2.1.11"
+ }
+ },
+ "SQLitePCLRaw.core": {
+ "type": "Transitive",
+ "resolved": "2.1.12",
+ "contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg=="
+ },
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.12",
+ "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
+ },
+ "SQLitePCLRaw.provider.e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.12",
+ "contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==",
+ "dependencies": {
+ "SQLitePCLRaw.core": "2.1.12"
+ }
+ },
+ "servermonitormanager.core": {
+ "type": "Project"
+ }
+ },
+ "net10.0/linux-arm64": {
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.12",
+ "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
+ }
+ },
+ "net10.0/linux-x64": {
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.12",
+ "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
+ }
+ },
+ "net10.0/win-x64": {
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.12",
+ "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ServerMonitorManager.Core/packages.lock.json b/src/ServerMonitorManager.Core/packages.lock.json
new file mode 100644
index 0000000..cf4d330
--- /dev/null
+++ b/src/ServerMonitorManager.Core/packages.lock.json
@@ -0,0 +1,16 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ }
+ },
+ "net10.0/linux-arm64": {},
+ "net10.0/linux-x64": {},
+ "net10.0/win-x64": {}
+ }
+}
\ No newline at end of file
diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
index 9d9bb7c..8b953ce 100644
--- a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
+++ b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
@@ -1059,7 +1059,6 @@ public sealed partial class MainPage : Page
.GroupBy(link => new { link.SourceNodeId, link.TargetNodeId, link.Protocol, link.Port })
.Select(group => group.MaxBy(link => link.Version)!)
.Any(link => link.LastError == MeshLinkViewModel.FirewallUnavailableErrorCode));
- _linksPage?.RefreshFilter();
var activeLinks = MeshLinks.Count(link => link.ActualState == "Active");
ActiveLinksValueText.Text = activeLinks.ToString(CultureInfo.InvariantCulture);
@@ -1266,14 +1265,10 @@ public sealed partial class MainPage : Page
await RefreshMeshAsync(showSuccess: false);
ShowInfo(
enable ? "Control Link создан" : "Control Link отключён",
- link.LastError == MeshLinkViewModel.NodeNotActivatedErrorCode
- ? $"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · ожидает активации Node в Mesh"
- : $"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · {link.ActualState} v{link.Version}",
- link.LastError == MeshLinkViewModel.NodeNotActivatedErrorCode
- ? InfoBarSeverity.Informational
- : link.ActualState is "Failed" or "Partial"
- ? InfoBarSeverity.Warning
- : InfoBarSeverity.Success);
+ $"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · {link.ActualState} v{link.Version}",
+ link.ActualState is "Failed" or "Partial"
+ ? InfoBarSeverity.Warning
+ : InfoBarSeverity.Success);
return;
}
diff --git a/src/ServerMonitorManager.Desktop/MeshModels.cs b/src/ServerMonitorManager.Desktop/MeshModels.cs
index dcf0dd5..79446d2 100644
--- a/src/ServerMonitorManager.Desktop/MeshModels.cs
+++ b/src/ServerMonitorManager.Desktop/MeshModels.cs
@@ -20,7 +20,6 @@ public sealed class MeshNodeViewModel
public sealed class MeshLinkViewModel
{
public const string FirewallUnavailableErrorCode = "mesh.firewall-unavailable";
- public const string NodeNotActivatedErrorCode = "mesh.node-not-activated";
public MeshLinkViewModel(
string source,
@@ -64,13 +63,9 @@ public sealed class MeshLinkViewModel
public string? LastError { get; set; }
public bool HasDrift => !string.Equals(DesiredState, ActualState, StringComparison.Ordinal);
public string DesiredStatusText => $"Желаемое состояние: {DesiredState}";
- public string ActualStatusText => LastError == NodeNotActivatedErrorCode
- ? "Фактическое состояние: ожидает активации Node в Mesh"
- : $"Фактическое состояние: {ActualState}";
- public string DriftText => LastError == NodeNotActivatedErrorCode
- ? "Ожидание: активируйте Node в Mesh — это не ошибка политики"
- : HasDrift ? "Расхождение: требуется сверка политики" : "Расхождение: нет";
- public string ErrorText => LastError is FirewallUnavailableErrorCode or NodeNotActivatedErrorCode ? string.Empty
+ public string ActualStatusText => $"Фактическое состояние: {ActualState}";
+ public string DriftText => HasDrift ? "Расхождение: требуется сверка политики" : "Расхождение: нет";
+ public string ErrorText => LastError == FirewallUnavailableErrorCode ? string.Empty
: string.IsNullOrWhiteSpace(LastError) ? "Ошибка: нет" : $"Ошибка: {LastError}";
public string VersionText => $"Версия политики: {Version}";
public string ExpirationText => ExpiresUnix == 0
diff --git a/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml b/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml
index 4370c18..f7a3415 100644
--- a/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml
+++ b/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml
@@ -77,15 +77,8 @@
-
-
-
-
-
-
-
-
-
+
+
diff --git a/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs b/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs
index d2e26a2..4eb545f 100644
--- a/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs
+++ b/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs
@@ -12,34 +12,11 @@ public sealed partial class LinksPage : Page
{
_host = host;
InitializeComponent();
- RefreshFilter();
}
public ObservableCollection Nodes => _host.MeshNodes;
- public ObservableCollection DisplayedLinks { get; } = [];
-
- internal void RefreshFilter()
- {
- var selectedId = (LinksList.SelectedItem as MeshLinkViewModel)?.Id;
- var effective = _host.MeshLinks
- .GroupBy(link => new { link.Source, link.Target, link.Protocol, link.Port })
- .Select(group => group.MaxBy(link => link.Version)!)
- .ToArray();
- var source = ShowHistoryToggle.IsOn
- ? _host.MeshLinks
- : effective.Where(link => link.DesiredState == "Active" || link.HasDrift);
- DisplayedLinks.Clear();
- foreach (var link in source)
- {
- DisplayedLinks.Add(link);
- }
- LinksCountText.Text = $"Показано политик: {DisplayedLinks.Count} · фактически Active: {DisplayedLinks.Count(link => link.ActualState == "Active")} · с расхождением: {DisplayedLinks.Count(link => link.HasDrift)}";
- if (selectedId is not null)
- {
- LinksList.SelectedItem = DisplayedLinks.FirstOrDefault(link => link.Id == selectedId);
- }
- }
+ public ObservableCollection Links => _host.MeshLinks;
internal void SetFirewallUnavailable(bool unavailable)
=> FirewallUnavailableInfo.IsOpen = unavailable;
@@ -47,9 +24,6 @@ public sealed partial class LinksPage : Page
private async void RefreshButton_Click(object sender, RoutedEventArgs e)
=> await _host.RefreshLinksFromPageAsync();
- private void ShowHistoryToggle_Toggled(object sender, RoutedEventArgs e)
- => RefreshFilter();
-
private async void ConnectButton_Click(object sender, RoutedEventArgs e)
=> await ChangeLinkAsync(enable: true);
diff --git a/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj b/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj
index a4b5213..052a4a5 100644
--- a/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj
+++ b/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj
@@ -54,10 +54,10 @@
-->
-
-
-
-
+
+
+
+