Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06c4ecf8f3 | |||
| 95c3a4aed6 | |||
| 00d5ff7764 | |||
| 6bad659c12 | |||
| f368fec6eb | |||
| 9e863ebf64 | |||
| ebb5eb617c | |||
| 7fb85059fb | |||
| cc5793f935 | |||
| 07004cd75f | |||
| cf14836d48 | |||
| 609dad7cf1 |
+29
-5
@@ -14,16 +14,34 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install .NET SDK
|
- name: Install .NET SDK
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 10.0.301
|
dotnet-version: 10.0.301
|
||||||
|
|
||||||
- name: Restore locked dependencies
|
- name: Restore locked dependencies
|
||||||
run: dotnet restore Rendezvous.slnx --locked-mode
|
run: dotnet restore Rendezvous.slnx --locked-mode
|
||||||
|
|
||||||
|
- name: Verify dependency licenses and reviewed transport pin
|
||||||
|
run: python3 eng/release_artifacts.py policy --root .
|
||||||
|
|
||||||
|
- name: Reject vulnerable direct or transitive packages
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
dotnet package list --project Rendezvous.slnx \
|
||||||
|
--vulnerable --include-transitive --no-restore --format json \
|
||||||
|
>"${RUNNER_TEMP}/nuget-vulnerabilities.json"
|
||||||
|
python3 eng/release_artifacts.py audit \
|
||||||
|
--input "${RUNNER_TEMP}/nuget-vulnerabilities.json"
|
||||||
|
|
||||||
|
- name: Enforce compatibility version bumps
|
||||||
|
run: ./scripts/check-compatibility.sh origin/main
|
||||||
|
|
||||||
- name: Verify formatting and analyzers
|
- name: Verify formatting and analyzers
|
||||||
run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore
|
run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore
|
||||||
|
|
||||||
@@ -36,6 +54,9 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: dotnet test Rendezvous.slnx --configuration Release --no-build
|
run: dotnet test Rendezvous.slnx --configuration Release --no-build
|
||||||
|
|
||||||
|
- name: Run quick capacity and resilience gate
|
||||||
|
run: ./scripts/run-capacity-gate.sh
|
||||||
|
|
||||||
- name: Test privileged Linux namespace topology when available
|
- name: Test privileged Linux namespace topology when available
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -95,10 +116,10 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install .NET SDK
|
- name: Install .NET SDK
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 10.0.301
|
dotnet-version: 10.0.301
|
||||||
|
|
||||||
@@ -124,7 +145,10 @@ jobs:
|
|||||||
chmod 0444 "$secret"
|
chmod 0444 "$secret"
|
||||||
export RENDEZVOUS_UID=1654
|
export RENDEZVOUS_UID=1654
|
||||||
export RENDEZVOUS_GID=1654
|
export RENDEZVOUS_GID=1654
|
||||||
docker compose -f "$compose_file" up --build --detach
|
export SOURCE_REVISION_ID="$GITHUB_SHA"
|
||||||
|
docker compose -f "$compose_file" build \
|
||||||
|
--build-arg SOURCE_REVISION_ID="$SOURCE_REVISION_ID"
|
||||||
|
docker compose -f "$compose_file" up --no-build --detach
|
||||||
container_id="$(docker compose -f "$compose_file" ps -q rendezvous)"
|
container_id="$(docker compose -f "$compose_file" ps -q rendezvous)"
|
||||||
test -n "$container_id"
|
test -n "$container_id"
|
||||||
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654"
|
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654"
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
name: immutable-release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ gitea.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- name: Check out immutable tag
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install pinned .NET SDK
|
||||||
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.301
|
||||||
|
|
||||||
|
- name: Install pinned Buildx and BuildKit
|
||||||
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
version: v0.35.0
|
||||||
|
install: true
|
||||||
|
driver-opts: image=moby/buildkit:v0.25.2@sha256:0f63d66f8d2de0bd16438284831a3e9ee6ca7cd57b6eb3ed6e38a7a456590fa7
|
||||||
|
|
||||||
|
- name: Validate tag and produce reproducible artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
./scripts/check-release-tag.sh "$GITHUB_REF_NAME"
|
||||||
|
previous_tag="$(git tag --merged HEAD^ --list 'v*.*.*' --sort=-version:refname | sed -n '1p')"
|
||||||
|
if [[ -n "$previous_tag" ]]; then
|
||||||
|
./scripts/check-compatibility.sh "$previous_tag"
|
||||||
|
elif [[ -n "$(git tag --list 'v*.*.*' | sed -n '1p')" ]]; then
|
||||||
|
echo "No prior release tag is an ancestor of $GITHUB_REF_NAME." >&2
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
./scripts/check-compatibility.sh __initial_release_without_base__
|
||||||
|
fi
|
||||||
|
release_builder="rendezvous-release-builder:${GITHUB_SHA}"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--file eng/release-builder.Dockerfile \
|
||||||
|
--target release-builder \
|
||||||
|
--load \
|
||||||
|
--tag "$release_builder" .
|
||||||
|
mkdir -p "${RUNNER_TEMP}/release-home" "${RUNNER_TEMP}/nuget"
|
||||||
|
docker run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--env HOME="${RUNNER_TEMP}/release-home" \
|
||||||
|
--env NUGET_PACKAGES="${RUNNER_TEMP}/nuget" \
|
||||||
|
--volume "$GITHUB_WORKSPACE:/source" \
|
||||||
|
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
|
||||||
|
--workdir /source \
|
||||||
|
"$release_builder" \
|
||||||
|
./scripts/build-release.sh "$version" "${RUNNER_TEMP}/release/$version"
|
||||||
|
./scripts/verify-real-consumers.sh "$version" "${RUNNER_TEMP}/release/$version"
|
||||||
|
echo "RENDEZVOUS_VERSION=$version" >>"$GITHUB_ENV"
|
||||||
|
echo "RENDEZVOUS_RELEASE_DIR=${RUNNER_TEMP}/release/$version" >>"$GITHUB_ENV"
|
||||||
|
echo "RENDEZVOUS_RELEASE_BUILDER=$release_builder" >>"$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build exact container candidate
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
|
||||||
|
common=(
|
||||||
|
--no-cache
|
||||||
|
--pull=false
|
||||||
|
--provenance=false
|
||||||
|
--platform linux/amd64
|
||||||
|
--build-arg SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH"
|
||||||
|
--build-arg SOURCE_REVISION_ID="$GITHUB_SHA"
|
||||||
|
)
|
||||||
|
release_tag="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
|
||||||
|
image_one="${RUNNER_TEMP}/rendezvous-image-1.tar"
|
||||||
|
image_two="${RUNNER_TEMP}/rendezvous-image-2.tar"
|
||||||
|
docker buildx build "${common[@]}" --tag "$release_tag" \
|
||||||
|
--output "type=docker,dest=$image_one,rewrite-timestamp=true" .
|
||||||
|
docker buildx build "${common[@]}" --tag "$release_tag" \
|
||||||
|
--output "type=docker,dest=$image_two,rewrite-timestamp=true" .
|
||||||
|
cmp --silent "$image_one" "$image_two"
|
||||||
|
docker load --input "$image_one"
|
||||||
|
candidate_id="$(docker image inspect --format '{{.Id}}' "$release_tag")"
|
||||||
|
buildkit_version="$(docker buildx inspect --bootstrap | sed -n 's/.*BuildKit version: *//p' | sed -n '1p')"
|
||||||
|
docker run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--volume "$GITHUB_WORKSPACE:/source" \
|
||||||
|
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
|
||||||
|
--workdir /source \
|
||||||
|
"$RENDEZVOUS_RELEASE_BUILDER" \
|
||||||
|
python3 eng/release_artifacts.py record-container-build \
|
||||||
|
--provenance "$RENDEZVOUS_RELEASE_DIR/release-provenance.json" \
|
||||||
|
--buildx-version "$(docker buildx version)" \
|
||||||
|
--buildkit-version "$buildkit_version" \
|
||||||
|
--image-id "$candidate_id"
|
||||||
|
|
||||||
|
- name: Stage HTTP registration, browse, and authenticated UDP traversal
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
secret="deploy/compose/secrets/signing-key"
|
||||||
|
cleanup() {
|
||||||
|
RENDEZVOUS_UID=1654 RENDEZVOUS_GID=1654 RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}" \
|
||||||
|
docker compose -f deploy/compose/compose.yaml down --volumes >/dev/null 2>&1 || true
|
||||||
|
rm -f "$secret"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
install -d -m 0700 deploy/compose/secrets
|
||||||
|
openssl rand -out "$secret" 32
|
||||||
|
chmod 0444 "$secret"
|
||||||
|
export RENDEZVOUS_UID=1654
|
||||||
|
export RENDEZVOUS_GID=1654
|
||||||
|
export RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
|
||||||
|
docker compose -f deploy/compose/compose.yaml up --detach --no-build
|
||||||
|
for attempt in {1..100}; do
|
||||||
|
curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1 && break
|
||||||
|
if (( attempt == 100 )); then
|
||||||
|
docker compose -f deploy/compose/compose.yaml logs rendezvous
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
./scripts/smoke-deployment.sh
|
||||||
|
|
||||||
|
- name: Scan candidate for high and critical vulnerabilities
|
||||||
|
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
|
||||||
|
with:
|
||||||
|
image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }}
|
||||||
|
version: v0.69.3
|
||||||
|
format: table
|
||||||
|
exit-code: "1"
|
||||||
|
ignore-unfixed: false
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
|
||||||
|
- name: Generate container SPDX inventory
|
||||||
|
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
|
||||||
|
with:
|
||||||
|
image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }}
|
||||||
|
version: v0.69.3
|
||||||
|
format: spdx-json
|
||||||
|
output: ${{ env.RENDEZVOUS_RELEASE_DIR }}/FinalFactory.Rendezvous.Container.${{ env.RENDEZVOUS_VERSION }}.spdx.json
|
||||||
|
|
||||||
|
- name: Finalize checksums over the publish-ready candidate
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source_date_epoch="$(git show -s --format=%ct HEAD)"
|
||||||
|
docker run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--volume "$GITHUB_WORKSPACE:/source" \
|
||||||
|
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
|
||||||
|
--workdir /source \
|
||||||
|
"$RENDEZVOUS_RELEASE_BUILDER" \
|
||||||
|
bash -c 'python3 eng/release_artifacts.py normalize-container-sbom \
|
||||||
|
--file "$1/FinalFactory.Rendezvous.Container.$2.spdx.json" \
|
||||||
|
--version "$2" \
|
||||||
|
--commit "$3" \
|
||||||
|
--source-date-epoch "$4" \
|
||||||
|
&& ./scripts/finalize-release-candidate.sh "$2" "$1"' \
|
||||||
|
_ "$RENDEZVOUS_RELEASE_DIR" "$RENDEZVOUS_VERSION" "$GITHUB_SHA" "$source_date_epoch"
|
||||||
|
|
||||||
|
- name: Preserve verified candidate artifacts
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: rendezvous-${{ env.RENDEZVOUS_VERSION }}
|
||||||
|
path: ${{ env.RENDEZVOUS_RELEASE_DIR }}
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Install pinned signing client
|
||||||
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
with:
|
||||||
|
cosign-release: v3.0.6
|
||||||
|
|
||||||
|
- name: Publish once, sign, attest, and create release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
RENDEZVOUS_RELEASE_USERNAME: ${{ secrets.RELEASE_USERNAME }}
|
||||||
|
RENDEZVOUS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||||
|
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||||
|
run: ./scripts/publish-release.sh "$RENDEZVOUS_VERSION" "$RENDEZVOUS_RELEASE_DIR"
|
||||||
@@ -7,5 +7,8 @@ TestResults/
|
|||||||
*.user
|
*.user
|
||||||
*.userosscache
|
*.userosscache
|
||||||
deploy/compose/.smoke.env
|
deploy/compose/.smoke.env
|
||||||
|
artifacts/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
deploy/compose/secrets/*
|
deploy/compose/secrets/*
|
||||||
!deploy/compose/secrets/.gitignore
|
!deploy/compose/secrets/.gitignore
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable Rendezvous release changes are recorded here. Versions follow
|
||||||
|
Semantic Versioning; HTTP, UDP, and connection-ticket format compatibility is
|
||||||
|
tracked separately and called out for every release.
|
||||||
|
|
||||||
|
## 1.0.0 - 2026-07-16
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
- Initial Client and Contracts package major: 1.
|
||||||
|
- HTTP contract: v1; UDP mediation contract: v1; connection-ticket format: v1.
|
||||||
|
- Server accepts Client 1.0.0 through the latest compatible 1.x release.
|
||||||
|
- Client traversal is pinned to LiteNetLib 2.1.4; LiteNetLib 1.x is unsupported.
|
||||||
|
|
||||||
|
### Security and configuration
|
||||||
|
|
||||||
|
- Packages contain no reusable credentials or environment configuration.
|
||||||
|
- Production server startup requires provisioned signing keys and the hardened
|
||||||
|
single-active deployment configuration.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- This is the first packaged release; no prior package or wire migration exists.
|
||||||
|
- Consumers must pin both Rendezvous packages to the same exact version.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
<Import Project="eng/Versions.props" />
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<AnalysisLevel>latest-recommended</AnalysisLevel>
|
<AnalysisLevel>latest-recommended</AnalysisLevel>
|
||||||
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
|
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
|
||||||
@@ -8,8 +9,35 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>$(RendezvousVersion)</Version>
|
||||||
|
<PackageVersion Condition="'$(PackageVersion)' == ''">$(RendezvousVersion)</PackageVersion>
|
||||||
|
<AssemblyVersion>$(RendezvousMajorVersion).0.0.0</AssemblyVersion>
|
||||||
|
<FileVersion>$(RendezvousMajorVersion).$(RendezvousMinorVersion).$(RendezvousPatchVersion).0</FileVersion>
|
||||||
|
<Authors>Final Factory</Authors>
|
||||||
|
<Company>Final Factory</Company>
|
||||||
|
<RepositoryUrl>https://git.finalfactory.de/HeiKyu/Rendezvous</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PackageProjectUrl>https://git.finalfactory.de/HeiKyu/Rendezvous</PackageProjectUrl>
|
||||||
|
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||||
|
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||||
|
<EnableSourceLink>true</EnableSourceLink>
|
||||||
|
<IncludeSymbols>true</IncludeSymbols>
|
||||||
|
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||||
|
<PackageReleaseNotes>See CHANGELOG.md in the package and repository.</PackageReleaseNotes>
|
||||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||||
<RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
|
<RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
|
||||||
|
<NuGetAudit>true</NuGetAudit>
|
||||||
|
<NuGetAuditMode>all</NuGetAuditMode>
|
||||||
|
<NuGetAuditLevel>moderate</NuGetAuditLevel>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Target Name="ConfigureGiteaSourceLink"
|
||||||
|
BeforeTargets="_GenerateSourceLinkFile"
|
||||||
|
DependsOnTargets="InitializeSourceControlInformation">
|
||||||
|
<ItemGroup>
|
||||||
|
<SourceRoot Update="@(SourceRoot)"
|
||||||
|
SourceLinkUrl="$(RepositoryUrl)/raw/commit/$(SourceRevisionId)/*" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="LiteNetLib" Version="2.1.4" />
|
<PackageVersion Include="LiteNetLib" Version="[$(LiteNetLibVersion)]" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||||
|
|||||||
+6
-1
@@ -1,8 +1,10 @@
|
|||||||
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS build
|
||||||
|
ARG SOURCE_REVISION_ID
|
||||||
|
|
||||||
WORKDIR /source
|
WORKDIR /source
|
||||||
COPY Directory.Build.props Directory.Packages.props NuGet.config global.json Rendezvous.slnx ./
|
COPY Directory.Build.props Directory.Packages.props NuGet.config global.json Rendezvous.slnx ./
|
||||||
|
COPY eng/Versions.props eng/Versions.props
|
||||||
COPY src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj src/FinalFactory.Rendezvous.Contracts/packages.lock.json src/FinalFactory.Rendezvous.Contracts/
|
COPY src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj src/FinalFactory.Rendezvous.Contracts/packages.lock.json src/FinalFactory.Rendezvous.Contracts/
|
||||||
COPY src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj src/FinalFactory.Rendezvous.Server/packages.lock.json src/FinalFactory.Rendezvous.Server/
|
COPY src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj src/FinalFactory.Rendezvous.Server/packages.lock.json src/FinalFactory.Rendezvous.Server/
|
||||||
RUN dotnet restore src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj --locked-mode
|
RUN dotnet restore src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj --locked-mode
|
||||||
@@ -14,7 +16,10 @@ RUN dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Se
|
|||||||
--no-restore \
|
--no-restore \
|
||||||
--output /out \
|
--output /out \
|
||||||
/p:UseAppHost=false \
|
/p:UseAppHost=false \
|
||||||
/p:OpenApiGenerateDocuments=false
|
/p:OpenApiGenerateDocuments=false \
|
||||||
|
/p:ContinuousIntegrationBuild=true \
|
||||||
|
/p:RepositoryCommit="$SOURCE_REVISION_ID" \
|
||||||
|
/p:SourceRevisionId="$SOURCE_REVISION_ID"
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0.9-noble-chiseled@sha256:f820c4fbfb8bb204c3bbe05c69d48cd039cd0e67aa8f13ac1cec168819b90643 AS runtime
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0.9-noble-chiseled@sha256:f820c4fbfb8bb204c3bbe05c69d48cd039cd0e67aa8f13ac1cec168819b90643 AS runtime
|
||||||
|
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ Rendezvous is under active roadmap development. The versioned contracts,
|
|||||||
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
|
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
|
||||||
SDK coordination, typed connection outcomes, thin public-SDK diagnostic client,
|
SDK coordination, typed connection outcomes, thin public-SDK diagnostic client,
|
||||||
deterministic NAT topology harness, hostile-input controls,
|
deterministic NAT topology harness, hostile-input controls,
|
||||||
observability/operator surface, and secure single-active Linux deployment are
|
observability/operator surface, secure single-active Linux deployment, and
|
||||||
implemented. Capacity, resilience, packaging, and final production-readiness
|
numeric capacity/resilience gates, and reproducible signed release pipeline are
|
||||||
gates remain in progress;
|
implemented. Consumer pilots and final production-readiness gates remain in progress;
|
||||||
participating games must not treat the current repository as a finished production
|
participating games must not treat the current repository as a finished production
|
||||||
service until those gates land.
|
service until those gates land.
|
||||||
|
|
||||||
@@ -100,14 +100,35 @@ defined in [hostile-input and overload protection](docs/security/abuse-protectio
|
|||||||
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
|
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
|
||||||
operator controls are defined in the
|
operator controls are defined in the
|
||||||
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
|
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
|
||||||
|
Concrete detect/contain/recover/verify procedures are in the
|
||||||
|
[incident and change runbooks](docs/operations/incident-runbooks.md).
|
||||||
The pinned non-root container, production topology, graceful drain, Linux
|
The pinned non-root container, production topology, graceful drain, Linux
|
||||||
hardening, smoke procedure, and recovery lifecycle are documented in
|
hardening, smoke procedure, and recovery lifecycle are documented in
|
||||||
[secure single-active Linux deployment](docs/deployment/linux.md).
|
[secure single-active Linux deployment](docs/deployment/linux.md).
|
||||||
|
The numeric core-state candidate profile, public launch objectives, accelerated
|
||||||
|
soak, resilience matrix, and single-active scaling decision are recorded in
|
||||||
|
[capacity and resilience gates](docs/operations/capacity-and-resilience.md).
|
||||||
|
Release versions, compatibility windows, immutable artifact construction,
|
||||||
|
signing, staged promotion, rollback, and migration are defined in
|
||||||
|
[releases and compatibility](docs/releases/README.md).
|
||||||
The scriptable host/browser/join diagnostic and its stable automation contract are
|
The scriptable host/browser/join diagnostic and its stable automation contract are
|
||||||
documented in the [TestClient integration guide](docs/integration/test-client.md).
|
documented in the [TestClient integration guide](docs/integration/test-client.md).
|
||||||
|
Optional bounded SSE deltas, reconnect/reset semantics, proxy requirements, and
|
||||||
|
polling fallback are documented in
|
||||||
|
[live session-list updates](docs/integration/live-session-updates.md).
|
||||||
|
The package, gameplay-socket, host-admission, provisioning, metadata, key rotation,
|
||||||
|
versioning, and secure rollout seams are in the
|
||||||
|
[game integration guide](docs/integration/sdk-seams.md).
|
||||||
The always-on three-party scenarios, optional Linux namespace topology, and
|
The always-on three-party scenarios, optional Linux namespace topology, and
|
||||||
simulation limits are documented in the
|
simulation limits are documented in the
|
||||||
[deterministic topology harness](docs/integration/topology-harness.md).
|
[deterministic topology harness](docs/integration/topology-harness.md).
|
||||||
|
The current consumer evidence and remaining external gates are tracked in the
|
||||||
|
[SpaceGame consumer pilot](docs/integration/spacegame-pilot.md) and independent
|
||||||
|
[Unscouted consumer pilot](docs/integration/unscouted-pilot.md).
|
||||||
|
The fail-closed launch decision, redacted evidence matrix, and two-machine
|
||||||
|
external-network procedure are in
|
||||||
|
[production readiness and real-network canary](docs/operations/production-readiness.md).
|
||||||
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<Project Path="src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj" />
|
<Project Path="src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/FinalFactory.Rendezvous.Capacity/FinalFactory.Rendezvous.Capacity.csproj" />
|
||||||
<Project Path="tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj" />
|
<Project Path="tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"AllowedHosts": "localhost;127.0.0.1",
|
"AllowedHosts": "localhost;127.0.0.1;rendezvous",
|
||||||
"Rendezvous": {
|
"Rendezvous": {
|
||||||
"Deployment": {
|
"Deployment": {
|
||||||
"PublicHttpBaseUrl": "https://localhost/",
|
"PublicHttpBaseUrl": "https://localhost/",
|
||||||
@@ -32,6 +32,16 @@
|
|||||||
"NotBefore": "2026-01-01T00:00:00Z",
|
"NotBefore": "2026-01-01T00:00:00Z",
|
||||||
"SignUntil": "2100-01-01T00:00:00Z",
|
"SignUntil": "2100-01-01T00:00:00Z",
|
||||||
"VerifyUntil": "2100-01-02T00:00:00Z"
|
"VerifyUntil": "2100-01-02T00:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"KeyId": "local-smoke-unscouted-1",
|
||||||
|
"SecretReference": "file:/run/secrets/rendezvous-signing-key",
|
||||||
|
"CredentialKinds": ["DedicatedPublisher"],
|
||||||
|
"GameId": "unscouted",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"NotBefore": "2026-01-01T00:00:00Z",
|
||||||
|
"SignUntil": "2100-01-01T00:00:00Z",
|
||||||
|
"VerifyUntil": "2100-01-02T00:00:00Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Games": [
|
"Games": [
|
||||||
@@ -39,18 +49,41 @@
|
|||||||
"GameId": "space-game",
|
"GameId": "space-game",
|
||||||
"EnvironmentId": "smoke",
|
"EnvironmentId": "smoke",
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
|
"ProtocolVersions": [1, 2],
|
||||||
|
"Regions": ["local"],
|
||||||
|
"VisibilityModes": ["Public"],
|
||||||
|
"PublisherTrustModes": ["ManagedDedicated"],
|
||||||
|
"MetadataValueMaxBytes": {
|
||||||
|
"mode": 32
|
||||||
|
},
|
||||||
|
"RequiredMetadataKeys": [],
|
||||||
|
"MetadataMaxBytes": 512,
|
||||||
|
"MetadataMaxKeys": 1,
|
||||||
|
"MaxListingsPerPrincipal": 10,
|
||||||
|
"MaxAnonymousListingsPerAddress": 0,
|
||||||
|
"MaxActiveJoinAttempts": 100,
|
||||||
|
"FallbackPolicy": "DedicatedEndpointAllowed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GameId": "unscouted",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"Enabled": true,
|
||||||
"ProtocolVersions": [1],
|
"ProtocolVersions": [1],
|
||||||
"Regions": ["local"],
|
"Regions": ["local"],
|
||||||
"VisibilityModes": ["Public"],
|
"VisibilityModes": ["Public"],
|
||||||
"PublisherTrustModes": ["ManagedDedicated"],
|
"PublisherTrustModes": ["ManagedDedicated"],
|
||||||
"MetadataValueMaxBytes": {},
|
"MetadataValueMaxBytes": {
|
||||||
"RequiredMetadataKeys": [],
|
"mode": 32,
|
||||||
|
"world": 64,
|
||||||
|
"mods": 64
|
||||||
|
},
|
||||||
|
"RequiredMetadataKeys": ["mode", "world", "mods"],
|
||||||
"MetadataMaxBytes": 512,
|
"MetadataMaxBytes": 512,
|
||||||
"MetadataMaxKeys": 0,
|
"MetadataMaxKeys": 3,
|
||||||
"MaxListingsPerPrincipal": 10,
|
"MaxListingsPerPrincipal": 10,
|
||||||
"MaxAnonymousListingsPerAddress": 0,
|
"MaxAnonymousListingsPerAddress": 0,
|
||||||
"MaxActiveJoinAttempts": 100,
|
"MaxActiveJoinAttempts": 100,
|
||||||
"FallbackPolicy": "Disabled"
|
"FallbackPolicy": "DedicatedEndpointAllowed"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: rendezvous-local
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
rendezvous:
|
rendezvous:
|
||||||
image: finalfactory/rendezvous:local
|
image: "${RENDEZVOUS_IMAGE:-finalfactory/rendezvous:local}"
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ LockPersonality=true
|
|||||||
SystemCallArchitectures=native
|
SystemCallArchitectures=native
|
||||||
UMask=0077
|
UMask=0077
|
||||||
LimitNOFILE=4096
|
LimitNOFILE=4096
|
||||||
MemoryMax=512M
|
CPUQuota=200%
|
||||||
|
MemoryMax=2G
|
||||||
TasksMax=128
|
TasksMax=128
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
+290
-1
@@ -1177,6 +1177,159 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v1/sessions/stream": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Sessions"
|
||||||
|
],
|
||||||
|
"operationId": "StreamSessions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "contractVersion",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gameId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "environmentId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "protocolVersion",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "regionId",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "excludeFull",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "streamCursor",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Last-Event-ID",
|
||||||
|
"in": "header",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"X-Rendezvous-Correlation-ID": {
|
||||||
|
"description": "Safe request correlation identifier generated by the service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"text/event-stream": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SessionStreamEvent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"headers": {
|
||||||
|
"X-Rendezvous-Correlation-ID": {
|
||||||
|
"description": "Safe request correlation identifier generated by the service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests",
|
||||||
|
"headers": {
|
||||||
|
"X-Rendezvous-Correlation-ID": {
|
||||||
|
"description": "Safe request correlation identifier generated by the service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Retry-After": {
|
||||||
|
"description": "Whole seconds before the caller should retry (1-60).",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
|
"headers": {
|
||||||
|
"X-Rendezvous-Correlation-ID": {
|
||||||
|
"description": "Safe request correlation identifier generated by the service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v1/sessions/{listingId}/join-attempts": {
|
"/v1/sessions/{listingId}/join-attempts": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2657,7 +2810,8 @@
|
|||||||
"BrowseSessionsResponse": {
|
"BrowseSessionsResponse": {
|
||||||
"required": [
|
"required": [
|
||||||
"contractVersion",
|
"contractVersion",
|
||||||
"items"
|
"items",
|
||||||
|
"streamCursor"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2676,6 +2830,9 @@
|
|||||||
"null",
|
"null",
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"streamCursor": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2931,6 +3088,59 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"OperatorCompatibilityResponse": {
|
||||||
|
"required": [
|
||||||
|
"serverVersion",
|
||||||
|
"minimumClientVersion",
|
||||||
|
"maximumClientMajorVersion",
|
||||||
|
"httpContractVersions",
|
||||||
|
"udpContractVersions",
|
||||||
|
"connectionTicketFormatVersions",
|
||||||
|
"liteNetLibMajorVersion",
|
||||||
|
"gameplayProtocolCompatibility"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"serverVersion": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minimumClientVersion": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maximumClientMajorVersion": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"httpContractVersions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"udpContractVersions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectionTicketFormatVersions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"liteNetLibMajorVersion": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"gameplayProtocolCompatibility": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"OperatorReadinessResponse": {
|
"OperatorReadinessResponse": {
|
||||||
"required": [
|
"required": [
|
||||||
"httpListener",
|
"httpListener",
|
||||||
@@ -3009,6 +3219,7 @@
|
|||||||
"OperatorStatusResponse": {
|
"OperatorStatusResponse": {
|
||||||
"required": [
|
"required": [
|
||||||
"status",
|
"status",
|
||||||
|
"compatibility",
|
||||||
"readiness",
|
"readiness",
|
||||||
"store",
|
"store",
|
||||||
"tenants",
|
"tenants",
|
||||||
@@ -3020,6 +3231,9 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"compatibility": {
|
||||||
|
"$ref": "#/components/schemas/OperatorCompatibilityResponse"
|
||||||
|
},
|
||||||
"readiness": {
|
"readiness": {
|
||||||
"$ref": "#/components/schemas/OperatorReadinessResponse"
|
"$ref": "#/components/schemas/OperatorReadinessResponse"
|
||||||
},
|
},
|
||||||
@@ -3488,6 +3702,54 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
},
|
},
|
||||||
|
"SessionStreamEvent": {
|
||||||
|
"required": [
|
||||||
|
"contractVersion",
|
||||||
|
"kind",
|
||||||
|
"cursor"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"contractVersion": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"$ref": "#/components/schemas/SessionStreamEventKind"
|
||||||
|
},
|
||||||
|
"cursor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SessionListing"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"listingId": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SessionListingId"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SessionStreamEventKind": {
|
||||||
|
"enum": [
|
||||||
|
"sessionUpsert",
|
||||||
|
"sessionRemove",
|
||||||
|
"reset",
|
||||||
|
"keepalive"
|
||||||
|
]
|
||||||
|
},
|
||||||
"UpdateSessionRequest": {
|
"UpdateSessionRequest": {
|
||||||
"required": [
|
"required": [
|
||||||
"contractVersion",
|
"contractVersion",
|
||||||
@@ -3506,6 +3768,33 @@
|
|||||||
"leaseToken": {
|
"leaseToken": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"regionId": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RegionId"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protocolVersion": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"integer"
|
||||||
|
],
|
||||||
|
"format": "uint32"
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ListingVisibility"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"buildVersion": {
|
"buildVersion": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,9 +129,18 @@ until the owner records:
|
|||||||
- which games may enable anonymous unlisted player hosting;
|
- which games may enable anonymous unlisted player hosting;
|
||||||
- deployment regions, data-processing jurisdiction, and approval of the stated
|
- deployment regions, data-processing jurisdiction, and approval of the stated
|
||||||
30-day audit/13-month aggregate retention periods;
|
30-day audit/13-month aggregate retention periods;
|
||||||
- the per-game dedicated fallback endpoint policy;
|
- the per-game dedicated fallback endpoint policy.
|
||||||
- the measured supported profile and whether the 99.5% single-active objective
|
|
||||||
is sufficient or shared-state/high-availability work must be brought forward.
|
Issue #18 measured and ratified the original 2-vCPU/2-GiB, 25,000-listing,
|
||||||
|
10,000-attempt core-state candidate profile and retained the 99.5% single-active
|
||||||
|
topology. It does not claim that core measurements prove public HTTP/UDP SLOs.
|
||||||
|
The versioned evidence, RTO, failure domains, and explicit signals that trigger
|
||||||
|
shared-state/high-availability work are recorded in the
|
||||||
|
[capacity and resilience gate](../operations/capacity-and-resilience.md). The
|
||||||
|
real-network canary in #23 must confirm that the proposed regional launch load
|
||||||
|
fits this profile and validate the public SLOs; it may lower the launch cap but
|
||||||
|
may not silently enable a
|
||||||
|
second active instance.
|
||||||
|
|
||||||
These are configuration and launch decisions, not permission to weaken the
|
These are configuration and launch decisions, not permission to weaken the
|
||||||
tenant, replay, endpoint-verification, or secret-handling controls.
|
tenant, replay, endpoint-verification, or secret-handling controls.
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ The public .NET types live in `FinalFactory.Rendezvous.Contracts`, target
|
|||||||
vectors and a public-API snapshot make accidental wire or source compatibility
|
vectors and a public-API snapshot make accidental wire or source compatibility
|
||||||
changes fail the normal test gate.
|
changes fail the normal test gate.
|
||||||
|
|
||||||
Any incompatible change requires a new contract version. Additive JSON fields
|
Readers ignore unknown JSON members, but the release gate deliberately treats
|
||||||
may be introduced within v1 because v1 readers ignore unknown object members.
|
any accepted OpenAPI or golden JSON surface drift as a contract-version change.
|
||||||
|
That conservative policy makes additive and incompatible published changes
|
||||||
|
equally visible to consumers instead of relying on an undocumented minor shape.
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ test "$RENDEZVOUS_UID" -ne 0
|
|||||||
docker compose -f deploy/compose/compose.yaml up --build --detach
|
docker compose -f deploy/compose/compose.yaml up --build --detach
|
||||||
```
|
```
|
||||||
|
|
||||||
`deploy/compose/appsettings.Production.json` is an isolated loopback smoke
|
`deploy/compose/appsettings.Production.json` is a local/private-bridge smoke
|
||||||
profile, not an Internet template: it deliberately opts into private advertised
|
profile, not an Internet template: TCP is published only on host loopback, the
|
||||||
endpoints and has no TLS proxy. Its random key is ignored by Git and must be
|
explicit `rendezvous` host name serves isolated clients on the Compose network,
|
||||||
|
and the profile deliberately opts into private advertised endpoints without a
|
||||||
|
TLS proxy. Its random key is ignored by Git and must be
|
||||||
deleted after use. Its deliberately long key window only keeps this disposable
|
deleted after use. Its deliberately long key window only keeps this disposable
|
||||||
local fixture usable; production keys require short, reviewed rotation windows.
|
local fixture usable; production keys require short, reviewed rotation windows.
|
||||||
Production configuration must use its real public names and must leave
|
Production configuration must use its real public names and must leave
|
||||||
@@ -118,8 +120,11 @@ Keep the host clock synchronized with authenticated NTP. Credential and key
|
|||||||
windows use wall time; lease, timeout, drain, and rate-limit deadlines use a
|
windows use wall time; lease, timeout, drain, and rate-limit deadlines use a
|
||||||
monotonic clock. Alert on clock synchronization loss before rotating keys.
|
monotonic clock. Alert on clock synchronization loss before rotating keys.
|
||||||
|
|
||||||
Start with the Compose limits (one CPU, 512 MiB, 128 PIDs, 4096 descriptors),
|
The checked-in Compose limits (one CPU and 512 MiB) are for its isolated smoke
|
||||||
measure real traffic, then change the limits and the server budgets together.
|
profile, not a production capacity claim. The measured core-state candidate
|
||||||
|
uses 2 vCPU and 2 GiB with the same 128-PID/4096-descriptor ceilings; see
|
||||||
|
the [capacity and resilience gate](../operations/capacity-and-resilience.md).
|
||||||
|
Measure real traffic, then change resource limits and server budgets together.
|
||||||
Memory pressure or CPU throttling must not extend orchestrator termination past
|
Memory pressure or CPU throttling must not extend orchestrator termination past
|
||||||
`DrainDeadlineSeconds` plus five seconds.
|
`DrainDeadlineSeconds` plus five seconds.
|
||||||
|
|
||||||
@@ -157,8 +162,8 @@ sudo systemctl enable --now rendezvous.service
|
|||||||
Create the dedicated `rendezvous` user without a login shell. Keep
|
Create the dedicated `rendezvous` user without a login shell. Keep
|
||||||
`/opt/rendezvous` and `/etc/rendezvous` root-owned and non-writable by that user;
|
`/opt/rendezvous` and `/etc/rendezvous` root-owned and non-writable by that user;
|
||||||
install each required key with `root:rendezvous` ownership and mode `0440`. The
|
install each required key with `root:rendezvous` ownership and mode `0440`. The
|
||||||
unit applies the same resource, filesystem, privilege, network-family, and
|
unit applies the measured 2-vCPU/2-GiB core-state candidate profile plus the
|
||||||
shutdown constraints as Compose.
|
same filesystem, privilege, network-family, and shutdown hardening as Compose.
|
||||||
|
|
||||||
## HTTP and UDP smoke
|
## HTTP and UDP smoke
|
||||||
|
|
||||||
@@ -174,6 +179,9 @@ dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
|
|||||||
|
|
||||||
For the local Compose profile, the script derives a ten-minute diagnostic
|
For the local Compose profile, the script derives a ten-minute diagnostic
|
||||||
publisher credential from the ignored local key without printing either secret.
|
publisher credential from the ignored local key without printing either secret.
|
||||||
|
The fixed-scope helper used by the smoke can also support the manual
|
||||||
|
[TestClient local flow](../integration/test-client.md); it is deliberately not a
|
||||||
|
production issuer.
|
||||||
For production, do not copy the signing key to the smoke host. Instead inject a
|
For production, do not copy the signing key to the smoke host. Instead inject a
|
||||||
short-lived, region-scoped credential through
|
short-lived, region-scoped credential through
|
||||||
`RENDEZVOUS_PUBLISHER_CREDENTIAL`, and set the external endpoints:
|
`RENDEZVOUS_PUBLISHER_CREDENTIAL`, and set the external endpoints:
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"evidenceVersion": "v2",
|
||||||
|
"generatedAt": "2026-07-16T20:28:43.2873744+00:00",
|
||||||
|
"profile": "candidate",
|
||||||
|
"runtime": {
|
||||||
|
"framework": ".NET 10.0.9",
|
||||||
|
"operatingSystem": "CachyOS",
|
||||||
|
"kernel": "Unix 7.1.3.2",
|
||||||
|
"architecture": "X64",
|
||||||
|
"cpuModel": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||||
|
"processorCount": 2,
|
||||||
|
"cpuAffinity": "0,1",
|
||||||
|
"cpuQuota": "not-enforced",
|
||||||
|
"memoryLimit": "not-enforced",
|
||||||
|
"garbageCollector": "workstation",
|
||||||
|
"commitSha": "00d5ff776408e7d80ce6648953e62a7233aca35c",
|
||||||
|
"treeState": "clean",
|
||||||
|
"command": "RENDEZVOUS_CAPACITY_PROFILE=candidate RENDEZVOUS_CAPACITY_CPUSET=0,1 ./scripts/run-capacity-gate.sh",
|
||||||
|
"imageDigest": "not-containerized",
|
||||||
|
"workloadSeed": "fixed-sequences-random-identifiers",
|
||||||
|
"capacityPhaseAverageCpuPercent": 55.52666859166872,
|
||||||
|
"peakWorkingSetBytes": 176758784,
|
||||||
|
"managedBytesAfterCleanup": 35608984
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"visibleListings": 25000,
|
||||||
|
"activeJoinAttempts": 10000,
|
||||||
|
"coreControlOperationsPerSecond": 200,
|
||||||
|
"coreMediationOperationsPerSecond": 2000,
|
||||||
|
"coreControlP95Milliseconds": 200,
|
||||||
|
"coreMediationP95Milliseconds": 100,
|
||||||
|
"maximumAverageCpuPercent": 70,
|
||||||
|
"maximumWorkingSetBytes": 1610612736,
|
||||||
|
"soakCycles": 1000,
|
||||||
|
"soakDurationSeconds": 300
|
||||||
|
},
|
||||||
|
"measurements": [
|
||||||
|
{
|
||||||
|
"operation": "registration-and-presence",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0029,
|
||||||
|
"p95Milliseconds": 0.0046,
|
||||||
|
"p99Milliseconds": 0.0055,
|
||||||
|
"operationsPerSecond": 287918.9220315559,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "lease-renewal",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0004,
|
||||||
|
"p95Milliseconds": 0.0007,
|
||||||
|
"p99Milliseconds": 0.0019,
|
||||||
|
"operationsPerSecond": 1076426.264800861,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "visible-session-browse",
|
||||||
|
"samples": 250,
|
||||||
|
"p50Milliseconds": 1.0232,
|
||||||
|
"p95Milliseconds": 3.6083,
|
||||||
|
"p99Milliseconds": 4.2925,
|
||||||
|
"operationsPerSecond": 650.0325926341947,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "join-attempt-issuance",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0028,
|
||||||
|
"p95Milliseconds": 0.0042,
|
||||||
|
"p99Milliseconds": 0.0052,
|
||||||
|
"operationsPerSecond": 135253.93927098127,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "simultaneous-punch-pairing",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0039,
|
||||||
|
"p95Milliseconds": 0.0069,
|
||||||
|
"p99Milliseconds": 0.0087,
|
||||||
|
"operationsPerSecond": 110619.46902654869,
|
||||||
|
"minimumOperationsPerSecond": 2000,
|
||||||
|
"budgetMilliseconds": 100,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "principal-revocation",
|
||||||
|
"samples": 50,
|
||||||
|
"p50Milliseconds": 0.495,
|
||||||
|
"p95Milliseconds": 0.6508,
|
||||||
|
"p99Milliseconds": 11.011,
|
||||||
|
"operationsPerSecond": 1393.258301729591,
|
||||||
|
"minimumOperationsPerSecond": 50,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "telemetry-recording",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0001,
|
||||||
|
"p95Milliseconds": 0.0001,
|
||||||
|
"p99Milliseconds": 0.0001,
|
||||||
|
"operationsPerSecond": 1479289.9408284025,
|
||||||
|
"minimumOperationsPerSecond": 10000,
|
||||||
|
"budgetMilliseconds": 1,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "coincident-listing-attempt-expiry",
|
||||||
|
"samples": 1,
|
||||||
|
"p50Milliseconds": 27.7056,
|
||||||
|
"p95Milliseconds": 27.7056,
|
||||||
|
"p99Milliseconds": 27.7056,
|
||||||
|
"operationsPerSecond": 36.093525543388026,
|
||||||
|
"minimumOperationsPerSecond": 0,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": {
|
||||||
|
"peakListings": 25000,
|
||||||
|
"peakAttempts": 10000,
|
||||||
|
"peakReplayMarkers": 0,
|
||||||
|
"finalListings": 0,
|
||||||
|
"finalAttempts": 0,
|
||||||
|
"finalReplayMarkers": 0,
|
||||||
|
"expiryChurn": 94906,
|
||||||
|
"maintenanceSweeps": 36307,
|
||||||
|
"soakCyclesCompleted": 77547145,
|
||||||
|
"soakDurationSeconds": 300.0000041,
|
||||||
|
"soakPeakScheduledExpiryEntries": 7,
|
||||||
|
"soakManagedGrowthBytes": -263432,
|
||||||
|
"soakHandleGrowth": 2,
|
||||||
|
"restartStartedEmpty": true,
|
||||||
|
"overloadWasTyped": true,
|
||||||
|
"recoverySucceeded": true
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"recordedAt": "2026-07-16",
|
||||||
|
"issue": 21,
|
||||||
|
"consumerIssue": "Kyuubi/SpaceGame#3",
|
||||||
|
"result": "checkpoint-pass-with-external-gates",
|
||||||
|
"rendezvousBaseCommit": "ebb5eb617c0bbb170418afab396b68584b7f992e",
|
||||||
|
"consumerCommit": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
|
||||||
|
"consumerIssueComment": 11469,
|
||||||
|
"packages": {
|
||||||
|
"FinalFactory.Rendezvous.Client": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "local-candidate",
|
||||||
|
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||||
|
"sha256": "fb156cf48b49f75c244dd25ea7cc4aa9fc6fab0a878393bb7efd5d9b131d0395"
|
||||||
|
},
|
||||||
|
"FinalFactory.Rendezvous.Contracts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "local-candidate",
|
||||||
|
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||||
|
"sha256": "a82ba986d3905d599096d1d8ce8f32cd4feb104abfca37b0f65e0d2ef3df9a6f"
|
||||||
|
},
|
||||||
|
"LiteNetLib": {
|
||||||
|
"version": "2.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"localRun": {
|
||||||
|
"processes": ["Rendezvous", "Godot SpaceGame host", "two sequential Godot SpaceGame clients"],
|
||||||
|
"typedOutcome": "Connected",
|
||||||
|
"gameAdmission": "Accepted",
|
||||||
|
"directGameplay": true,
|
||||||
|
"authenticatedSessions": 2,
|
||||||
|
"directInputs": 2,
|
||||||
|
"directSnapshots": 2,
|
||||||
|
"lifecyclePackets": 4,
|
||||||
|
"gameplayTransport": "caller-owned-litenetlib",
|
||||||
|
"rendezvousGameplayPayloadPath": "none",
|
||||||
|
"hostLeaseRenewed": true,
|
||||||
|
"reconnected": true,
|
||||||
|
"deregistered": true
|
||||||
|
},
|
||||||
|
"linuxRun": {
|
||||||
|
"runtime": "Godot 4.7 .NET Linux x86_64",
|
||||||
|
"freshExport": true,
|
||||||
|
"sourceDirty": false,
|
||||||
|
"optimized": true,
|
||||||
|
"dedicatedHostNamespace": "docker",
|
||||||
|
"remoteClientNamespace": "docker",
|
||||||
|
"topology": "private-bridge",
|
||||||
|
"directGameplay": true,
|
||||||
|
"fallback": "PunchTimedOut to explicit Docker-gateway endpoint, then Accepted game admission and direct gameplay",
|
||||||
|
"artifactHashes": "SpaceGame issue #3 comment 11469"
|
||||||
|
},
|
||||||
|
"negativePaths": {
|
||||||
|
"incompatibleProtocol": "proven",
|
||||||
|
"staleHostPresence": "proven",
|
||||||
|
"punchTimeout": "proven",
|
||||||
|
"invalidAdmission": "integration-proven",
|
||||||
|
"capacity": "regression-tested",
|
||||||
|
"fallbackConnection": "godot-and-isolated-linux-proven",
|
||||||
|
"reconnect": "proven"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"debugBuild": "passed",
|
||||||
|
"releaseBuild": "passed",
|
||||||
|
"debugTests": { "passed": 31, "failed": 0 },
|
||||||
|
"releaseTests": { "passed": 31, "failed": 0 },
|
||||||
|
"exportRelease": "optimized-without-debug-symbols",
|
||||||
|
"format": "passed",
|
||||||
|
"shellcheck": "passed",
|
||||||
|
"godotReconnectHarness": "passed",
|
||||||
|
"godotFallbackHarness": "passed",
|
||||||
|
"linuxContainerHarness": "passed-clean-source",
|
||||||
|
"failureMatrix": "passed",
|
||||||
|
"adversarialReview": "passed-after-fixes"
|
||||||
|
},
|
||||||
|
"openGates": [
|
||||||
|
"public-package-restore",
|
||||||
|
"representative-external-nat"
|
||||||
|
],
|
||||||
|
"relatedSpaceGameGates": [
|
||||||
|
"production-enet-replacement",
|
||||||
|
"capacity-profiles-64-and-128",
|
||||||
|
"sigterm-drain-save"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"recordedAt": "2026-07-16",
|
||||||
|
"issue": 22,
|
||||||
|
"consumerIssue": "HeiKyu/Unscouted#459",
|
||||||
|
"result": "checkpoint-pass-with-external-gates",
|
||||||
|
"rendezvousConfigurationCommit": "f368fec6eb4344a6042974f58f888cf0f1ac8e8e",
|
||||||
|
"consumerImplementationCommit": "1e5886aa7f1e44689b4c75e32693eb7b19fd72d7",
|
||||||
|
"consumerEvidenceCommit": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
|
||||||
|
"consumerIssueComment": 11499,
|
||||||
|
"packages": {
|
||||||
|
"FinalFactory.Rendezvous.Client": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "local-candidate",
|
||||||
|
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||||
|
"sha256": "fb156cf48b49f75c244dd25ea7cc4aa9fc6fab0a878393bb7efd5d9b131d0395"
|
||||||
|
},
|
||||||
|
"FinalFactory.Rendezvous.Contracts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "local-candidate",
|
||||||
|
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||||
|
"sha256": "a82ba986d3905d599096d1d8ce8f32cd4feb104abfca37b0f65e0d2ef3df9a6f"
|
||||||
|
},
|
||||||
|
"LiteNetLib": {
|
||||||
|
"version": "2.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"gameId": "unscouted",
|
||||||
|
"environmentId": "smoke",
|
||||||
|
"regionId": "local",
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"publisherTrust": "ManagedDedicated",
|
||||||
|
"fallbackPolicy": "DedicatedEndpointAllowed",
|
||||||
|
"metadataKeys": ["mode", "world", "mods"],
|
||||||
|
"metadataMaxKeys": 3,
|
||||||
|
"metadataMaxBytes": 512
|
||||||
|
},
|
||||||
|
"godotRun": {
|
||||||
|
"runtime": "Godot 4.7 .NET Linux x86_64",
|
||||||
|
"processes": [
|
||||||
|
"Rendezvous hardened Compose service",
|
||||||
|
"Godot Unscouted host",
|
||||||
|
"Godot incompatible-protocol client",
|
||||||
|
"Godot direct client",
|
||||||
|
"Godot fallback client"
|
||||||
|
],
|
||||||
|
"gameplayTransport": "unscouted-litenetlib",
|
||||||
|
"rendezvousGameplayPayloadPath": "none",
|
||||||
|
"directGameplay": true,
|
||||||
|
"fallbackGameplay": true,
|
||||||
|
"authenticatedSessions": 2,
|
||||||
|
"gameplayExchanges": 2,
|
||||||
|
"hostLeaseRenewed": true,
|
||||||
|
"deregistered": true,
|
||||||
|
"playerIdentityOwner": "unscouted",
|
||||||
|
"canonicalGameStateOwner": "unscouted"
|
||||||
|
},
|
||||||
|
"negativePaths": {
|
||||||
|
"incompatibleProtocol": "proven-no-compatible-listing",
|
||||||
|
"wrongGame": "proven-exact-NotFound",
|
||||||
|
"wrongEnvironment": "proven-exact-NotFound",
|
||||||
|
"punchTimeout": "proven-typed-failure-then-game-owned-fallback",
|
||||||
|
"unexpectedMetadata": "consumer-regression-tested"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"rendezvousDebugTests": { "passed": 299, "failed": 0 },
|
||||||
|
"rendezvousReleaseTests": { "passed": 299, "failed": 0 },
|
||||||
|
"consumerDebugTests": { "passed": 3310, "skipped": 15, "failed": 0 },
|
||||||
|
"consumerReleaseTests": { "passed": 3310, "skipped": 15, "failed": 0 },
|
||||||
|
"consumerGdUnitTests": { "passed": 360, "skipped": 0, "failed": 0 },
|
||||||
|
"consumerExport": "not-applicable-no-export-presets",
|
||||||
|
"format": "passed",
|
||||||
|
"shellcheck": "passed",
|
||||||
|
"godotPilot": "passed",
|
||||||
|
"adversarialReview": "passed-after-fixes"
|
||||||
|
},
|
||||||
|
"openGates": [
|
||||||
|
"public-package-restore",
|
||||||
|
"representative-external-nat"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "rendezvous-production-readiness",
|
||||||
|
"evaluatedCommit": "00d5ff776408e7d80ce6648953e62a7233aca35c",
|
||||||
|
"decision": "not-ready",
|
||||||
|
"localGates": [
|
||||||
|
{
|
||||||
|
"id": "immutable-release-artifacts",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"note": "Clean candidate packages and server archive are byte reproducible and fully verified."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "debug-and-release-verification",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"note": "All 300 tests pass in Debug and Release; the Release build has zero warnings and errors."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "real-consumer-pilots",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"note": "Pinned real projects restore the candidate and both game launch pilots pass direct traffic."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "candidate-capacity-resilience",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/capacity/v2/candidate-2cpu.json",
|
||||||
|
"note": "The clean two-CPU five-minute candidate passes all budgets with zero retained state."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "production-process-recovery",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"note": "All selected restart, drain, socket release, overload, and recovery tests pass."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "security-privacy-observability",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"note": "The complete security, privacy, health, audit, telemetry, and release suite passes."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"externalGates": [
|
||||||
|
{
|
||||||
|
"id": "public-package-empty-cache-restore",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "The public registry does not currently resolve version 1.0.0."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "signed-publication",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/releases/README.md",
|
||||||
|
"note": "Protected release credentials and immutable tag publication are required."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "source-preserving-udp-ingress",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "The public ingress path needs packet-level source and reply validation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "same-lan-direct-canary",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "Requires two independently operated game clients."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "home-nat-direct-canary",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "Requires distinct residential networks."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "restrictive-cgnat-typed-failure",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "Requires a known restrictive carrier topology."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "firewall-blocked-udp-typed-failure",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "Requires an independently controlled firewall rule."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ipv6-direct-canary",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "Requires two IPv6-capable external clients and public ingress."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "public-rate-shaped-capacity",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/capacity-and-resilience.md",
|
||||||
|
"note": "The full public HTTP and UDP traffic mix has not been measured."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "one-hour-candidate-endurance",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/capacity-and-resilience.md",
|
||||||
|
"note": "A production-shaped one-hour candidate run is required."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "alert-delivery",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/incident-runbooks.md",
|
||||||
|
"note": "A real alert sink must observe trigger and recovery notifications."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cold-standby-rollback-drill",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/capacity-and-resilience.md",
|
||||||
|
"note": "The deployment must demonstrate the host-visible recovery objective."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "documentation-only-runbook-exercise",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/incident-runbooks.md",
|
||||||
|
"note": "An independent operator must execute the runbooks using only the docs."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "rendezvous-local-release-candidate",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"sourceCommit": "00d5ff776408e7d80ce6648953e62a7233aca35c",
|
||||||
|
"treeState": "clean",
|
||||||
|
"result": "pass",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "FinalFactory.Rendezvous.Client.1.0.0.nupkg",
|
||||||
|
"sha256": "f2a4b9727b5faeba284ddcb7fc575c7495f1e29b763faababa7cd71444dc2950"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FinalFactory.Rendezvous.Contracts.1.0.0.nupkg",
|
||||||
|
"sha256": "92317f153911ebf7b8ea04cd3206ec2a17f882cb4eddb26aa8ab094ffdb06627"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FinalFactory.Rendezvous.Server.1.0.0.linux-x64.tar.gz",
|
||||||
|
"sha256": "0dab8cfc696b4a55d6ffba46286c9e532df2c943ed8fd6347fb528516156b3ba"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"lockedRestore": "pass",
|
||||||
|
"reportedVulnerabilities": 0,
|
||||||
|
"format": "pass",
|
||||||
|
"releaseBuildWarnings": 0,
|
||||||
|
"releaseBuildErrors": 0,
|
||||||
|
"debugTestsPassed": 300,
|
||||||
|
"debugTestsFailed": 0,
|
||||||
|
"releaseTestsPassed": 300,
|
||||||
|
"releaseTestsFailed": 0,
|
||||||
|
"selectedProductionFaultTestsPassed": 17,
|
||||||
|
"byteReproduciblePackages": "pass",
|
||||||
|
"byteReproducibleServerArchive": "pass",
|
||||||
|
"sbomChecksumsAndProvenance": "pass",
|
||||||
|
"candidateConsumerFixtures": "pass",
|
||||||
|
"realConsumerRestores": "pass"
|
||||||
|
},
|
||||||
|
"consumers": [
|
||||||
|
{
|
||||||
|
"name": "SpaceGame",
|
||||||
|
"revision": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
|
||||||
|
"candidateRestore": "pass",
|
||||||
|
"directTrafficPilot": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Unscouted",
|
||||||
|
"revision": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
|
||||||
|
"candidateRestore": "pass",
|
||||||
|
"directTrafficPilot": "pass"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limitations": {
|
||||||
|
"publicRegistryRestore": "pending",
|
||||||
|
"signedPublication": "pending",
|
||||||
|
"externalNetworkCanaries": "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Live session-list updates
|
||||||
|
|
||||||
|
Tracking: #26
|
||||||
|
|
||||||
|
Live updates are an optional acceleration for an open server browser. The
|
||||||
|
bounded `GET /v1/sessions` snapshot remains the source of truth, and join
|
||||||
|
authorization still revalidates current capacity, presence, policy, and
|
||||||
|
compatibility. A displayed player count is advisory, never an admission promise.
|
||||||
|
|
||||||
|
## Snapshot, stream, reset
|
||||||
|
|
||||||
|
Every `BrowseSessionsResponse` includes `streamCursor` in addition to its normal
|
||||||
|
pagination cursor. Connect to `GET /v1/sessions/stream` with the same game,
|
||||||
|
environment, protocol, optional region, and `excludeFull` filter. Send the most
|
||||||
|
recent stream cursor as `Last-Event-ID`.
|
||||||
|
|
||||||
|
| SSE event | Contract kind | UI action |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `session_upsert` | `sessionUpsert` | Add or replace the complete public projection by listing ID. |
|
||||||
|
| `session_remove` | `sessionRemove` | Remove the listing ID. |
|
||||||
|
| `reset` | `reset` | Discard local state, fetch a fresh snapshot, then reconnect with its cursor. |
|
||||||
|
| `keepalive` | `keepalive` | Preserve the cursor and connection; do not change UI state. |
|
||||||
|
|
||||||
|
Each SSE `id` equals the opaque cursor inside its JSON event. Cursors are signed,
|
||||||
|
short-lived, monotonically ordered, and bound to the complete filter. A missing,
|
||||||
|
expired, corrupted, foreign, future, or replay-gapped cursor produces `reset`
|
||||||
|
instead of a potentially incomplete view. Do not parse or retain it as a stable
|
||||||
|
identifier.
|
||||||
|
|
||||||
|
Updates cover creation after fresh UDP presence, public-field/capacity changes,
|
||||||
|
presence staleness and recovery, lease expiry, deregistration, operator or
|
||||||
|
principal revocation, and visibility/region/protocol changes. Events contain the
|
||||||
|
same bounded public `SessionListing` as snapshots. They never contain raw peer
|
||||||
|
endpoints, lease tokens, punch capabilities, tickets, publisher subjects, or
|
||||||
|
internal store identifiers.
|
||||||
|
|
||||||
|
## SDK and polling fallback
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
BrowseSessionsRequest filter = new()
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ExcludeFull = true,
|
||||||
|
};
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> snapshot =
|
||||||
|
await browser.BrowseAsync(filter, cancellationToken);
|
||||||
|
|
||||||
|
await foreach (RendezvousClientResult<SessionStreamEvent> update in
|
||||||
|
browser.StreamAsync(filter, snapshot.Value!.StreamCursor, cancellationToken))
|
||||||
|
{
|
||||||
|
if (!update.IsSuccess)
|
||||||
|
{
|
||||||
|
// Switch to bounded polling with jittered backoff.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Apply upsert/remove by listing ID. On reset, discard and browse again.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Cancellation or enumerator disposal closes the response and releases the server
|
||||||
|
subscription. A normal connection-duration close is a reconnect signal: use the
|
||||||
|
last applied event cursor. Repeated failures, unsupported platform HTTP stacks,
|
||||||
|
and restrictive proxies fall back to snapshots with exponential jittered
|
||||||
|
backoff, a capped interval, and `Retry-After`. Never open parallel streams to
|
||||||
|
compensate for a slow UI.
|
||||||
|
|
||||||
|
## TestClient
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||||
|
--configuration Release --no-build -- \
|
||||||
|
watch --service https://rendezvous.example.invalid/ \
|
||||||
|
--game space-game --environment production --region eu-central --protocol 7 \
|
||||||
|
--run-seconds 60 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
`watch.snapshot`, `watch.session-upsert`, `watch.session-remove`,
|
||||||
|
`watch.keepalive`, and `watch.reconnect` are stable diagnostics. Add
|
||||||
|
`--exercise-reset --script` to corrupt the snapshot cursor deliberately and
|
||||||
|
verify a typed reset plus snapshot refresh. Use `--exercise-reconnect --script`
|
||||||
|
while producing one update to close the first stream deliberately, reconnect
|
||||||
|
from its prior cursor, and verify that the same ordered event is replayed.
|
||||||
|
Polished list diffing, selection retention, animation, and accessibility remain
|
||||||
|
in each game.
|
||||||
|
|
||||||
|
## Bounds and slow consumers
|
||||||
|
|
||||||
|
The v1 journal retains at most 4,096 public-only changes. It admits at most 256
|
||||||
|
subscribers total and 64 per tenant, reads at most 128 changes per batch,
|
||||||
|
waits a configurable 50 milliseconds after a live change and coalesces the
|
||||||
|
resulting batch to the final change per listing, sends a keepalive every 15
|
||||||
|
seconds, and closes a connection after five minutes. A consumer behind the
|
||||||
|
replay window receives `reset`; it never acquires an unbounded queue.
|
||||||
|
|
||||||
|
Normal optional-work concurrency and per-source/tenant rate controls apply for
|
||||||
|
the stream lifetime. Exhaustion returns typed HTTP `429` before streaming.
|
||||||
|
Shutdown cancels streams; reconnect only after readiness returns and expect a
|
||||||
|
reset after a single-active restart because listings and replay are ephemeral.
|
||||||
|
|
||||||
|
## Reverse proxy
|
||||||
|
|
||||||
|
- Disable response buffering (`X-Accel-Buffering: no` is also emitted),
|
||||||
|
compression, transformation, and caching for `text/event-stream`.
|
||||||
|
- Preserve `Last-Event-ID`; set upstream/read timeouts above the 15-second
|
||||||
|
keepalive and around six minutes for the five-minute connection ceiling.
|
||||||
|
- Flush events promptly and use HTTP/2 only when streaming semantics survive.
|
||||||
|
- Preserve the source-IP trust boundary and abuse controls; do not add a bypass.
|
||||||
|
|
||||||
|
Verify the deployed proxy with an idle keepalive, update, reconnect, invalid
|
||||||
|
cursor reset, slow reader, and graceful shutdown. An in-process pass does not
|
||||||
|
prove that a production proxy is non-buffering.
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# Game integration seams
|
||||||
|
|
||||||
|
Tracking: #20
|
||||||
|
|
||||||
|
Use the [TestClient start-to-finish guide](test-client.md) before integrating a
|
||||||
|
game. It proves the service and network path without engine or game code. This
|
||||||
|
page documents only the seams that the diagnostic cannot choose for a game:
|
||||||
|
package/version policy, ownership of the gameplay socket, host admission,
|
||||||
|
metadata, credential custody, and deployment compatibility.
|
||||||
|
|
||||||
|
## Packages and compatibility
|
||||||
|
|
||||||
|
Consume `FinalFactory.Rendezvous.Client` and
|
||||||
|
`FinalFactory.Rendezvous.Contracts` from the approved Gitea NuGet source and pin
|
||||||
|
both to the same exact released version. Do not use a floating version range.
|
||||||
|
The current release matrix is machine-readable in
|
||||||
|
[`compatibility.json`](../releases/compatibility.json); the same window is
|
||||||
|
available from authenticated `GET /v1/operator/status`.
|
||||||
|
|
||||||
|
The authoritative package feed is
|
||||||
|
`https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json`. Add it to the
|
||||||
|
consumer's `NuGet.config` and map only Rendezvous packages to it; retain the
|
||||||
|
consumer's existing NuGet.org mapping for other dependencies:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<packageSources>
|
||||||
|
<add key="FinalFactory" value="https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
<packageSourceMapping>
|
||||||
|
<packageSource key="FinalFactory">
|
||||||
|
<package pattern="FinalFactory.Rendezvous.*" />
|
||||||
|
</packageSource>
|
||||||
|
<packageSource key="nuget.org">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
|
</packageSourceMapping>
|
||||||
|
```
|
||||||
|
|
||||||
|
When the feed is anonymously readable, no reader credential is needed. If
|
||||||
|
registry policy requires authentication, use the platform's NuGet credential
|
||||||
|
provider or a protected per-user/CI NuGet configuration populated by the secret
|
||||||
|
manager. Never put a registry token in the project file, repository,
|
||||||
|
package-source URL, or `dotnet` command argument.
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FinalFactory.Rendezvous.Client" Version="1.0.0" />
|
||||||
|
<PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="1.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `dotnet restore`, then `dotnet list package --include-transitive` and verify
|
||||||
|
that Client and Contracts resolve to the same exact version and LiteNetLib to the
|
||||||
|
release matrix version before compiling the game.
|
||||||
|
|
||||||
|
Release 1.0.0 targets `netstandard2.1`, requires LiteNetLib `2.1.4`, speaks HTTP,
|
||||||
|
UDP, and connection-ticket contract version `1`, and requires an exact
|
||||||
|
tenant-configured gameplay protocol match. A package patch does not silently
|
||||||
|
change a wire version. Follow the [release and migration policy](../releases/README.md)
|
||||||
|
when changing any dimension, and validate the generated
|
||||||
|
[OpenAPI v1 document](../api/rendezvous-v1.json) rather than hand-building HTTP.
|
||||||
|
|
||||||
|
## One caller-owned gameplay socket
|
||||||
|
|
||||||
|
Create the game's LiteNetLib manager through `RendezvousNetListener`; do not open
|
||||||
|
a separate NAT socket. The game owns start, stop, and disposal. A coordinator
|
||||||
|
owns polling while it is active, so call its `Poll()` once from the game/network
|
||||||
|
thread and do not also call `NetManager.PollEvents()` during that period.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager gameplayNetwork = networkEvents.CreateManager();
|
||||||
|
gameplayNetwork.ChannelsCount = 3; // set the game's required count before Start
|
||||||
|
if (!gameplayNetwork.Start(gameplayPort))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Gameplay UDP socket could not start.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
gameplayNetwork,
|
||||||
|
networkEvents,
|
||||||
|
mediatorEndPoint,
|
||||||
|
publishedSession,
|
||||||
|
joinClient);
|
||||||
|
|
||||||
|
host.Poll(); // call each game frame while this coordinator owns polling
|
||||||
|
```
|
||||||
|
|
||||||
|
LiteNetLib defaults to one QoS channel. Set `ChannelsCount` before `Start` when
|
||||||
|
the game protocol uses additional channels; both peers must configure the same
|
||||||
|
count. Rendezvous does not choose, remap, or reserve a gameplay channel.
|
||||||
|
|
||||||
|
Register normal game callbacks on `networkEvents.GameplayEvents`. Rendezvous
|
||||||
|
reserves only its authenticated direct requests and forwards other callbacks.
|
||||||
|
The same socket sends host presence, punches through the mediator, establishes
|
||||||
|
the peer, and then carries gameplay. A NAT introduction is not success; accept a
|
||||||
|
peer only after the coordinator reports the typed `Connected` outcome.
|
||||||
|
|
||||||
|
`Poll()` does not fetch new invitations. Schedule
|
||||||
|
`RefreshJoinAttemptsAsync` repeatedly for the entire hosting lifetime using a
|
||||||
|
bounded caller-owned timer (the diagnostic uses 250 ms), never allow two refreshes
|
||||||
|
to overlap, and inspect each typed result. The refresh performs HTTP work and
|
||||||
|
queues a snapshot; it does not call the LiteNetLib manager. Continue calling
|
||||||
|
`Poll()` on the manager's owning thread so the queued snapshot, presence traffic,
|
||||||
|
and callbacks are processed. Run the lease maintainer concurrently and cancel
|
||||||
|
both loops before disposing the coordinator.
|
||||||
|
|
||||||
|
For example, start one sequential refresh loop when hosting begins and await it
|
||||||
|
during shutdown:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
static async Task RefreshInvitationsAsync(
|
||||||
|
RendezvousHostCoordinator host,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using PeriodicTimer timer = new(TimeSpan.FromMilliseconds(250));
|
||||||
|
do
|
||||||
|
{
|
||||||
|
RendezvousClientResult<int> result =
|
||||||
|
await host.RefreshJoinAttemptsAsync(cancellationToken);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
ObserveBoundedHostRefreshFailure(result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (await timer.WaitForNextTickAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On the joining side, create an attempt through `RendezvousJoinClient`, then give
|
||||||
|
the issued attempt to `RendezvousClientCoordinator` using the same manager and
|
||||||
|
listener. Cancellation, outcome reporting, bounded deadlines, fallback, and
|
||||||
|
lease-maintainer examples are in the packaged
|
||||||
|
[`FinalFactory.Rendezvous.Client` README](../../src/FinalFactory.Rendezvous.Client/README.md).
|
||||||
|
|
||||||
|
## Host admission remains game-owned
|
||||||
|
|
||||||
|
The coordinator privately validates and consumes the signed one-time connection
|
||||||
|
ticket before accepting the LiteNetLib transport request. Do not create a second
|
||||||
|
`ConnectionTicketValidator` beside it: the coordinator deliberately does not
|
||||||
|
expose the expected or presented ticket. A connected transport proves only that
|
||||||
|
Rendezvous authorized one attempt; it does not prove player identity,
|
||||||
|
entitlement, capacity, ban status, or gameplay compatibility.
|
||||||
|
|
||||||
|
Treat `AttemptCompleted` with a successful outcome and non-null `Peer` as the
|
||||||
|
start of game-owned admission. Keep that peer outside authoritative gameplay
|
||||||
|
until the game's normal authentication and admission exchange succeeds; disconnect
|
||||||
|
it on rejection or timeout:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
host.AttemptCompleted += (_, completed) =>
|
||||||
|
{
|
||||||
|
if (!completed.Outcome.IsSuccess || completed.Peer is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BeginBoundedGameAuthentication(
|
||||||
|
completed.Peer,
|
||||||
|
onAccepted: AdmitToAuthoritativeGameplay,
|
||||||
|
onRejected: peer => peer.Disconnect());
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Revoke an attempt when the game cancels it. Never log a ticket or capability. A
|
||||||
|
successful Rendezvous check must not bypass the game's authentication or
|
||||||
|
authoritative server rules. `ConnectionTicketValidator` is a lower-level
|
||||||
|
primitive for a custom transport integration that owns the complete request
|
||||||
|
acceptance path; it is not an extra gate for `RendezvousHostCoordinator`.
|
||||||
|
|
||||||
|
## Provision each game and environment
|
||||||
|
|
||||||
|
Provision game/environment scope before issuing credentials. The policy fixes
|
||||||
|
enabled regions, exact gameplay protocols, visibility and publisher trust modes,
|
||||||
|
metadata schema and byte budgets, quotas, and whether a dedicated fallback may
|
||||||
|
be published. Unknown or disabled scope fails closed. Follow
|
||||||
|
[game provisioning and signing-key lifecycle](../security/provisioning.md) for
|
||||||
|
the complete schema, principal kinds, secret providers, overlap, and revocation.
|
||||||
|
|
||||||
|
Dedicated publisher credentials belong only on trusted hosting infrastructure.
|
||||||
|
Never ship one in a player build, repository, image layer, appsettings file, URL,
|
||||||
|
argument, log, crash report, or analytics event. Issue a short-lived credential
|
||||||
|
scoped to one game/environment and its allowed regions from the trusted
|
||||||
|
deployment boundary. Player-host grants are issued to an authenticated player
|
||||||
|
session at runtime and are never embedded in the build. Player-host grants,
|
||||||
|
dedicated publishers, anonymous unlisted hosts, and operators are separate
|
||||||
|
principal kinds; do not interchange them.
|
||||||
|
|
||||||
|
Rotate signing keys with an overlap:
|
||||||
|
|
||||||
|
1. install a new authorized key inside its `NotBefore`/`SignUntil` window;
|
||||||
|
2. begin issuing with it while the old key remains verify-only;
|
||||||
|
3. wait at least the maximum credential lifetime plus allowed clock skew;
|
||||||
|
4. retire the old verifier after `VerifyUntil` and preserve custody records.
|
||||||
|
|
||||||
|
A suspected compromise is not routine rotation: stop issuance, revoke the exact
|
||||||
|
key through the protected operator route, remove or replace it in provisioning,
|
||||||
|
invalidate affected credentials, and follow the
|
||||||
|
[key-compromise runbook](../operations/incident-runbooks.md#signing-key-or-issuer-compromise).
|
||||||
|
|
||||||
|
## Metadata is public and policy-owned
|
||||||
|
|
||||||
|
Treat listing metadata as untrusted public input. Define a small allowlist in
|
||||||
|
each provisioned game's `MetadataValueMaxBytes`, set `RequiredMetadataKeys`, and
|
||||||
|
keep `MetadataMaxKeys` and `MetadataMaxBytes` to the smallest useful values. Values
|
||||||
|
must be display data only—for example a bounded map or ruleset identifier. Never
|
||||||
|
publish player identity, free-form chat, secrets, access tokens, internal
|
||||||
|
addresses, world state, or data needed for authoritative gameplay.
|
||||||
|
|
||||||
|
The platform contract caps metadata at 32 keys, 256 UTF-8 bytes per value, and
|
||||||
|
4096 encoded bytes total; tenant policy can and should be smaller. Build version
|
||||||
|
and display name are separately bounded public fields. Games must escape metadata
|
||||||
|
for their UI and must not infer trust from a listing being present.
|
||||||
|
|
||||||
|
## Local, staging, and production path
|
||||||
|
|
||||||
|
Use the checked-in Compose profile only for the local TestClient guide. For a
|
||||||
|
real environment:
|
||||||
|
|
||||||
|
1. provision the game/environment policy and externally held signing keys;
|
||||||
|
2. deploy one active service behind the source-preserving HTTPS/UDP topology in
|
||||||
|
[secure single-active Linux deployment](../deployment/linux.md);
|
||||||
|
3. install matching exact package versions in the game and set its service and
|
||||||
|
mediator endpoints through environment-specific configuration;
|
||||||
|
4. pass the TestClient health/publish/browse/punch/direct-traffic smoke using a
|
||||||
|
short-lived diagnostic credential;
|
||||||
|
5. run the topology harness and representative consumer-network trials; and
|
||||||
|
6. monitor typed outcomes and bounded metrics before broad rollout.
|
||||||
|
|
||||||
|
Rendezvous v1 has no relay, account system, matchmaking engine, server-browser
|
||||||
|
UI, gameplay authority, or durable session database. A game owns player-facing
|
||||||
|
recovery and an explicit fallback. Do not describe direct traversal as guaranteed.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# SpaceGame consumer pilot
|
||||||
|
|
||||||
|
Tracking: Rendezvous #21 and SpaceGame #3.
|
||||||
|
|
||||||
|
The current SpaceGame checkpoint proves that the v1 client boundary establishes
|
||||||
|
authenticated direct LiteNetLib traffic without taking ownership of the game's
|
||||||
|
protocol, admission, player identity, entity identity, capacity, lifecycle, or
|
||||||
|
gameplay payloads. Real Godot processes, reconnect, an explicit dedicated
|
||||||
|
fallback, and a fresh Linux export now pass. The public package restore and a
|
||||||
|
representative external NAT/CGNAT canary remain required before #21 can close.
|
||||||
|
|
||||||
|
## Pinned checkpoint
|
||||||
|
|
||||||
|
| Input | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Rendezvous compatibility source | `ebb5eb617c0bbb170418afab396b68584b7f992e` plus the current #21 configuration/evidence changes |
|
||||||
|
| Rendezvous package source | `07004cd75fe172aa5dfdb3edda22fc280a4c4477` |
|
||||||
|
| SpaceGame source | `f3f5bc29810c362656cd7143bec1ddc2cfaf9f22` |
|
||||||
|
| Client package | `FinalFactory.Rendezvous.Client` `1.0.0` |
|
||||||
|
| Contracts package | `FinalFactory.Rendezvous.Contracts` `1.0.0` |
|
||||||
|
| LiteNetLib | `2.1.4` |
|
||||||
|
| HTTP, UDP, ticket contracts | `1` |
|
||||||
|
| SpaceGame gameplay protocol | `2` |
|
||||||
|
|
||||||
|
At the checkpoint date, the Final Factory Gitea NuGet service was reachable but
|
||||||
|
both `FinalFactory.Rendezvous.*` `1.0.0` registrations returned HTTP 404. The run
|
||||||
|
therefore restored locally built candidate packages with the hashes recorded in
|
||||||
|
[`spacegame.json`](../evidence/consumers/spacegame.json). This proves candidate
|
||||||
|
compatibility, not immutable registry publication. The release package restore
|
||||||
|
must be repeated from the public feed.
|
||||||
|
|
||||||
|
## Proven local path
|
||||||
|
|
||||||
|
The SpaceGame host and client each create one caller-owned `NetManager`, set its
|
||||||
|
three gameplay QoS channels before `Start`, and give the same manager and
|
||||||
|
`RendezvousNetListener` to the coordinator. Rendezvous authenticates discovery,
|
||||||
|
join authorization, host presence, mediation, and connection outcome reporting.
|
||||||
|
After traversal, SpaceGame performs a separate audience-bound admission exchange
|
||||||
|
on its own reliable command channel. A trusted game-auth boundary mints the
|
||||||
|
opaque assertion; the player process never receives the signing key.
|
||||||
|
|
||||||
|
The authoritative host rejects expired, replayed, incorrectly signed,
|
||||||
|
wrong-listing, duplicate-player, over-capacity, identity-mismatched,
|
||||||
|
out-of-sequence, and over-rate traffic. It assigns a canonical game entity ID
|
||||||
|
only after admission. The player ID, entity ID, listing ID, join-attempt ID, and
|
||||||
|
LiteNetLib peer ID remain distinct values.
|
||||||
|
|
||||||
|
The bounded real-process harnesses observed:
|
||||||
|
|
||||||
|
- host publication and lease maintenance;
|
||||||
|
- browser compatibility filtering and join authorization;
|
||||||
|
- typed traversal outcome `Connected`;
|
||||||
|
- successful audience-bound game admission;
|
||||||
|
- reliable ordered frame-definition and spawn lifecycle records, reliable
|
||||||
|
ordered input, and sequenced state snapshots on the caller-owned gameplay
|
||||||
|
socket;
|
||||||
|
- disconnect and a new authenticated session for the same durable player while
|
||||||
|
LiteNetLib peers and canonical entity IDs change;
|
||||||
|
- immediate host lease renewal and successful host deregistration;
|
||||||
|
- a fresh optimized Linux export running the host and client in distinct
|
||||||
|
hardened container namespaces; and
|
||||||
|
- a forced punch timeout that connects the isolated client to an explicitly
|
||||||
|
advertised, non-loopback Docker-gateway fallback and repeats game admission.
|
||||||
|
|
||||||
|
Rendezvous exposes no gameplay relay API; all lifecycle, command, and snapshot
|
||||||
|
bytes are sent by SpaceGame through its caller-owned `NetManager`. Both Debug
|
||||||
|
and Release builds passed. Both Debug and Release test runs passed 31 tests with
|
||||||
|
zero failures. ExportRelease is optimized with debug symbols removed. The
|
||||||
|
focused formatter, shell checker, fresh-export provenance gate, clean
|
||||||
|
candidate-package restore, and adversarial branch review also passed.
|
||||||
|
|
||||||
|
## Failure evidence
|
||||||
|
|
||||||
|
| Path | Evidence | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Incompatible protocol | protocol `999` returns no compatible listing and starts no traversal | Proven |
|
||||||
|
| Stale/no host presence | typed `NoHostPresence/RendezvousService/HostPresence/Mediation` | Proven |
|
||||||
|
| Traversal timeout | non-listening mediator produces typed `PunchTimedOut/LocalTraversal/NatTraversal/NatTraversal` | Proven |
|
||||||
|
| Rejected game admission | invalid signature denies gameplay in the process matrix; wrong audience, expiry, and replay are regression-tested | Proven |
|
||||||
|
| Capacity and duplicate player | game-owned roster rejects both and publishes current capacity | Regression-tested |
|
||||||
|
| Configured fallback | typed `PunchTimedOut`, explicit non-loopback endpoint, same game admission, direct gameplay | Proven locally and across Linux namespaces |
|
||||||
|
| Disconnect | host observes zero active players and final admitted count zero | Proven |
|
||||||
|
| Reconnect | same durable player enters a second authenticated session with new peer/entity IDs | Proven |
|
||||||
|
|
||||||
|
## Rendezvous-side compatibility fixes
|
||||||
|
|
||||||
|
The pilot found generic integration gaps and keeps their fixes in this
|
||||||
|
repository:
|
||||||
|
|
||||||
|
- the local production-shaped smoke tenant accepts SpaceGame gameplay protocol
|
||||||
|
`2` and the bounded `mode` metadata key;
|
||||||
|
- the Compose smoke tenant explicitly allows its private-network service name
|
||||||
|
and enables only the dedicated-endpoint fallback policy;
|
||||||
|
- SDK guidance requires games using multiple LiteNetLib QoS channels to set
|
||||||
|
`ChannelsCount` before `Start` and states that Rendezvous reserves no gameplay
|
||||||
|
channel; and
|
||||||
|
- the local credential helper rejects any signing-key file with group or other
|
||||||
|
permissions, in addition to its ownership, symlink, and hard-link checks.
|
||||||
|
|
||||||
|
Documentation contract tests cover these generic requirements.
|
||||||
|
|
||||||
|
## Remaining acceptance gates
|
||||||
|
|
||||||
|
Do not mark #21 passed until both remaining external gates have direct evidence:
|
||||||
|
|
||||||
|
1. restore the exact immutable `1.0.0` packages from the public Gitea feed; and
|
||||||
|
2. run representative external NAT/CGNAT canaries and record the network
|
||||||
|
topology and typed outcome.
|
||||||
|
|
||||||
|
SpaceGame #3 remains open independently for the production ENet replacement,
|
||||||
|
64/128-player profiles, and SIGTERM/drain/save evidence. The consumer pilot
|
||||||
|
does not claim those broader game-migration gates.
|
||||||
+206
-68
@@ -1,97 +1,235 @@
|
|||||||
# Diagnostic TestClient integration guide
|
# Start-to-finish TestClient guide
|
||||||
|
|
||||||
Tracking: #25
|
Tracking: #20, #25
|
||||||
|
|
||||||
`FinalFactory.Rendezvous.TestClient` is the smallest supported public-SDK consumer.
|
`FinalFactory.Rendezvous.TestClient` is the supported executable proof that a
|
||||||
It exists for integration development, CI smoke checks, deployment verification,
|
consumer can publish, browse, authorize, punch, connect, exchange direct traffic,
|
||||||
and operator diagnosis. It is intentionally not a production game client, game
|
and diagnose a failure using only the public Client and Contracts packages. It is
|
||||||
server, matchmaking UI, or relay.
|
intentionally thin: a polished server browser and player-facing connection UI
|
||||||
|
belong in each game repository.
|
||||||
|
|
||||||
The automated scenario matrix, privileged Linux namespace run, and topology
|
> **Traversal boundary:** Rendezvous v1 is not a relay and cannot guarantee a
|
||||||
limitations are documented in the [deterministic topology harness](topology-harness.md).
|
> connection through symmetric NAT, carrier-grade NAT, restrictive firewalls,
|
||||||
|
> VPNs, or platform policy. It provides no accounts, social system, skill-based
|
||||||
|
> matchmaking, gameplay server, gameplay authority, or gameplay transport.
|
||||||
|
|
||||||
## Prerequisites
|
The automated scenario matrix, privileged Linux namespace run, and simulation
|
||||||
|
limits are in the [deterministic topology harness](topology-harness.md). The
|
||||||
|
[SDK seam guide](sdk-seams.md) covers the few integration details that this
|
||||||
|
executable cannot show.
|
||||||
|
|
||||||
Start a configured Rendezvous service and note both its HTTP base URL and UDP
|
## First local connection from a clean checkout
|
||||||
mediator endpoint. The host needs a tenant-scoped publisher credential from the
|
|
||||||
deployment secret boundary. Put it in an environment variable and pass only that
|
Prerequisites are the pinned .NET SDK, Docker with Compose, OpenSSL, Python 3,
|
||||||
variable's name when the default is unsuitable:
|
`curl`, and `jq`. Run these commands from the repository root. The generated key
|
||||||
|
and credential are disposable local fixtures, not production provisioning.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<deployment-supplied value>'
|
install -d -m 0700 deploy/compose/secrets
|
||||||
|
umask 077
|
||||||
|
openssl rand -out deploy/compose/secrets/signing-key 32
|
||||||
|
export RENDEZVOUS_UID="$(id -u)"
|
||||||
|
export RENDEZVOUS_GID="$(id -g)"
|
||||||
|
test "$RENDEZVOUS_UID" -ne 0
|
||||||
|
docker compose -f deploy/compose/compose.yaml up --build --detach
|
||||||
|
ready=false
|
||||||
|
for attempt in {1..45}; do
|
||||||
|
if curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null; then
|
||||||
|
ready=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
test "$ready" = true
|
||||||
|
curl --fail http://127.0.0.1:8080/health/live
|
||||||
|
curl --fail http://127.0.0.1:8080/health/ready
|
||||||
|
dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
|
||||||
```
|
```
|
||||||
|
|
||||||
Never put the credential in a command argument, URL, checked-in configuration,
|
Only the host terminal needs a publisher credential. Disable shell tracing before
|
||||||
shell trace, or captured test fixture. The development server's signing material
|
capturing it; the helper prints the credential on stdout so command substitution
|
||||||
is process-ephemeral; credentials from a prior development process are invalid.
|
can place it directly in the environment without writing it to disk.
|
||||||
|
|
||||||
## Manual three-terminal flow
|
|
||||||
|
|
||||||
Start the host:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
set +x
|
||||||
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$(./scripts/mint-local-publisher-credential.sh)"
|
||||||
--game space-game --environment development --region local --protocol 1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Browse from another terminal:
|
The helper accepts no arguments, reads the ignored `0600` local Compose key, and
|
||||||
|
mints only `space-game` / `smoke` / `local` / protocol `1` for ten minutes. It is
|
||||||
|
not a reusable issuer or an example for production. Never put the result in a
|
||||||
|
command argument, URL, shell history, log, screenshot, support ticket, captured
|
||||||
|
fixture, or source file.
|
||||||
|
|
||||||
|
In terminal 1, publish a host. It stays alive for at most 60 seconds and exits
|
||||||
|
after a joining peer completes the authenticated echo exchange:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||||
browse --service http://127.0.0.1:5000/ \
|
--configuration Release --no-build -- \
|
||||||
--game space-game --environment development --region local --protocol 1
|
host --service http://127.0.0.1:8080/ --mediator 127.0.0.1:9050 \
|
||||||
|
--game space-game --environment smoke --region local --protocol 1 \
|
||||||
|
--display-name "Local diagnostic" --timeout-seconds 60 --run-seconds 60 \
|
||||||
|
--exit-after-echo
|
||||||
```
|
```
|
||||||
|
|
||||||
Join from a third terminal. Omit `--listing` for an interactive choice:
|
Copy the public listing ID printed by the host, or discover it from terminal 2:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||||
join --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
|
--configuration Release --no-build -- \
|
||||||
--game space-game --environment development --region local --protocol 1 \
|
browse --service http://127.0.0.1:8080/ \
|
||||||
--listing 00000000-0000-0000-0000-000000000000
|
--game space-game --environment smoke --region local --protocol 1
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace the sample UUID with the public listing ID printed by host or browse.
|
In terminal 3, either omit `--listing` and select interactively, or provide the
|
||||||
Host and join each create one caller-owned LiteNetLib manager. That same socket
|
copied ID for deterministic selection:
|
||||||
sends presence/punch traffic, establishes the authenticated direct connection,
|
|
||||||
and carries the ping/echo/ack/completion payload. The final completion confirms
|
|
||||||
that the host received the reliable acknowledgement; none of this traffic passes through the HTTP
|
|
||||||
service or UDP mediator.
|
|
||||||
|
|
||||||
## CI and deployment smoke flow
|
```bash
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||||
|
--configuration Release --no-build -- \
|
||||||
|
join --service http://127.0.0.1:8080/ --mediator 127.0.0.1:9050 \
|
||||||
|
--game space-game --environment smoke --region local --protocol 1 \
|
||||||
|
--listing REPLACE_WITH_LISTING_UUID --timeout-seconds 30
|
||||||
|
```
|
||||||
|
|
||||||
Use `--script --json`, set `--listing` when deterministic selection matters, and
|
Success means the joiner prints `join.connected` and verified direct traffic,
|
||||||
check the documented process exit code. `--timeout-seconds` bounds each startup,
|
and the host prints verified direct traffic before deregistering. The host and
|
||||||
traversal, or direct-traffic stage; a script host also uses it as its total runtime
|
joiner each create one caller-owned LiteNetLib manager. The same UDP socket sends
|
||||||
unless `--run-seconds` is explicit. A host can add `--exit-after-echo` so it
|
presence and punch traffic, accepts the authenticated peer, and carries the
|
||||||
terminates after the joining peer acknowledges direct traffic and receives the
|
ping/echo/ack/completion payload; direct traffic does not pass through the HTTP
|
||||||
host's completion confirmation. Every wait is
|
service or mediator.
|
||||||
bounded by coordinator state and `--timeout-seconds`; no orchestration should use
|
|
||||||
an unbounded sleep.
|
|
||||||
|
|
||||||
The normal test suite contains a real process gate that starts the built Server,
|
Clean up secrets and the disposable service when finished:
|
||||||
host TestClient, and join TestClient, waits for readiness and versioned events,
|
|
||||||
and verifies direct traffic, cleanup, JSON shape, and secret canaries. Process
|
|
||||||
trees are force-terminated in the test cleanup path if normal shutdown fails.
|
|
||||||
|
|
||||||
Useful success events are:
|
```bash
|
||||||
|
unset RENDEZVOUS_PUBLISHER_CREDENTIAL
|
||||||
|
docker compose -f deploy/compose/compose.yaml down
|
||||||
|
rm deploy/compose/secrets/signing-key
|
||||||
|
```
|
||||||
|
|
||||||
- `host.registered`, `host.ready`, `host.direct-traffic`, and `host.deregistered`;
|
## Script and JSON automation
|
||||||
- `browse.completed` and `browse.session`; and
|
|
||||||
- `join.connected`, `join.direct-traffic`, and `join.outcome-report`.
|
|
||||||
|
|
||||||
Failure events preserve stable typed phases and outcomes. When a terminal outcome
|
`--script` forbids prompts and selects the first compatible listing unless
|
||||||
contains a configured dedicated endpoint, `join.fallback` reports `available`
|
`--listing UUID` fixes the choice. `--json` emits one JSON object per line with
|
||||||
with endpoint type `dedicated`; no raw address is printed and no fallback is
|
`version: 1`. New optional properties may be added, but event names and exit
|
||||||
started implicitly.
|
codes are stable automation contracts. Informational events use stdout and
|
||||||
|
failures use stderr.
|
||||||
|
|
||||||
## What the proof does and does not establish
|
Successful direct-connection and direct-traffic events include the coarse
|
||||||
|
`addressFamily` value `ipv4` or `ipv6`. They never include the peer address.
|
||||||
|
|
||||||
The deterministic loopback test proves the complete service/host/client protocol,
|
The deployment smoke performs the full health, publish, join, mediation, direct
|
||||||
ticket admission, and peer-to-peer payload path. Loopback is not evidence that all
|
traffic, outcome-report, and cleanup flow using bounded waits:
|
||||||
consumer routers, carrier-grade NATs, symmetric NATs, firewalls, VPNs, IPv6 paths,
|
|
||||||
or platform policies permit hole punching. Same-LAN, separated observed endpoints,
|
```bash
|
||||||
network namespaces/containers, mediator restart, and adverse topology coverage
|
dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
|
||||||
belong to the topology harness tracked by #14. Production rollout still requires
|
./scripts/smoke-deployment.sh
|
||||||
tests from representative networks and a game-owned fallback policy.
|
```
|
||||||
|
|
||||||
|
For custom automation, capture JSON and preserve the process status separately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set +e
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||||
|
--configuration Release --no-build -- \
|
||||||
|
browse --service http://127.0.0.1:8080/ \
|
||||||
|
--game space-game --environment smoke --region local --protocol 1 \
|
||||||
|
--script --json >browse.jsonl
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
jq -e 'select(.version == 1 and .event == "browse.completed")' browse.jsonl
|
||||||
|
test "$status" -eq 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Never use an unbounded sleep to orchestrate processes. Wait for versioned events
|
||||||
|
such as `host.ready` and apply a deadline. Useful success events are
|
||||||
|
`host.registered`, `host.ready`, `host.direct-traffic`, `host.deregistered`,
|
||||||
|
`browse.completed`, `browse.session`, `join.connected`, `join.direct-traffic`,
|
||||||
|
`join.outcome-report`, `watch.snapshot`, `watch.session-upsert`,
|
||||||
|
`watch.session-remove`, `watch.reset`, `watch.reconnect`, and `watch.complete`.
|
||||||
|
|
||||||
|
For a bounded live-directory diagnostic, use `watch --run-seconds 60`. Add
|
||||||
|
`--exercise-reset --script` to prove fail-closed cursor recovery, or
|
||||||
|
`--exercise-reconnect --script` while changing one listing to prove ordered
|
||||||
|
`Last-Event-ID` replay after a deliberate disconnect. The full event and proxy
|
||||||
|
contract is in [live session-list updates](live-session-updates.md).
|
||||||
|
|
||||||
|
| Exit | Meaning |
|
||||||
|
| ---: | --- |
|
||||||
|
| `0` | Requested diagnostic flow completed successfully |
|
||||||
|
| `2` | Invalid command or options |
|
||||||
|
| `3` | Missing or invalid local configuration |
|
||||||
|
| `10` | HTTP, registration, browser, lease, or socket failure |
|
||||||
|
| `11` | No compatible session was available or selected |
|
||||||
|
| `12` | Authorization or traversal reached a typed terminal failure |
|
||||||
|
| `13` | Direct connection succeeded but the direct traffic proof failed |
|
||||||
|
| `130` | Caller cancellation or Ctrl+C |
|
||||||
|
|
||||||
|
## Observe a safe failure
|
||||||
|
|
||||||
|
Run this after the protocol-1 browse in terminal 2 and before the terminal-3
|
||||||
|
join (or restart terminal 1 first). The preceding browse proves that one
|
||||||
|
protocol-1 host is present. Now browse for deliberately incompatible protocol
|
||||||
|
`999`. The command emits a successful directory response with
|
||||||
|
`browse.completed`, `count: 0`, then exits `11` to distinguish compatibility
|
||||||
|
from a service outage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set +e
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||||
|
--configuration Release --no-build -- \
|
||||||
|
browse --service http://127.0.0.1:8080/ \
|
||||||
|
--game space-game --environment smoke --region local --protocol 999 \
|
||||||
|
--script --json >incompatible.jsonl
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
jq -e 'select(.event == "browse.completed" and .phase == "directory" and .count == 0)' \
|
||||||
|
incompatible.jsonl
|
||||||
|
test "$status" -eq 11
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a diagnostic failure drill, not a bypass: unknown tenant scope and
|
||||||
|
protocols still fail closed, and the local helper cannot mint a credential for
|
||||||
|
them.
|
||||||
|
|
||||||
|
## Diagnose by phase, not by guesswork
|
||||||
|
|
||||||
|
Start with the exit code, then the last versioned event and its `phase`, `status`,
|
||||||
|
and typed `outcome`. Endpoint categories may be
|
||||||
|
reported as `loopback`, `private`, or `public`; raw endpoints, credentials,
|
||||||
|
capabilities, metadata, and player identities are never emitted.
|
||||||
|
|
||||||
|
| Symptom or last event | Distinction | Check next |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `host.configuration`, exit `3` | Local credential variable is missing or malformed before any request | Confirm the named environment variable exists, tracing is off, and the credential has not expired |
|
||||||
|
| `host.registration`, exit `10` | Publisher authentication, tenant policy, metadata, quota, or HTTP failure | Use the typed status; compare credential scope with game/environment/region and the provisioned policy, then correlate protected server telemetry by operation and time |
|
||||||
|
| `browse.sessions`, exit `10` | Directory request failed | Check HTTP reachability, `/health/ready`, rate limiting, and contract compatibility |
|
||||||
|
| `browse.completed` count `0`, or `join.selection` empty, exit `11` | Healthy directory but no compatible visible listing | Match game, environment, region, and exact gameplay protocol; then confirm a host lease is still active |
|
||||||
|
| Exact `join.selection` failure, exit `10` | Listing disappeared, is hidden, or scope no longer matches | Browse again; do not retry an old listing ID forever |
|
||||||
|
| `join.authorization`, exit `12` | Service rejected the attempt before NAT traversal | Inspect typed category/outcome for policy, capacity, stale host, or active-attempt limits |
|
||||||
|
| `join.punch` / `join.traversal`, exit `12` | Mediation or NAT traversal did not establish a peer | Confirm UDP endpoint/reply path, host presence, clocks, firewall/NAT behavior, and topology; use a game-owned fallback if policy supplies one |
|
||||||
|
| `join.direct-connect`, exit `12` | Introduction occurred but authenticated direct admission failed | Confirm host is polling the same socket, the one-time ticket is current, and game admission did not reject capacity, identity, or bans |
|
||||||
|
| `join.connected` followed by exit `13` | Peer connected but the direct gameplay-like echo did not finish | Inspect the peer lifecycle and caller polling; this is not an HTTP/directory failure |
|
||||||
|
|
||||||
|
Stopping a host without deregistration may leave its listing visible only until
|
||||||
|
the bounded lease expires. During that window, a join can produce a typed stale
|
||||||
|
host or traversal outcome; it must not be interpreted as a healthy host. Restarting
|
||||||
|
the single-active service intentionally loses all ephemeral listings and attempts,
|
||||||
|
so hosts re-register and clients browse again.
|
||||||
|
|
||||||
|
If a terminal outcome reports an authoritative dedicated fallback,
|
||||||
|
`join.fallback` exposes only availability and endpoint type. TestClient never
|
||||||
|
connects to it automatically. The game owns the decision, authentication, and
|
||||||
|
connection policy. If no fallback is present, Rendezvous v1 offers no relay.
|
||||||
|
|
||||||
|
## Production use
|
||||||
|
|
||||||
|
Do not copy a production signing key to a diagnostic host. Supply a short-lived,
|
||||||
|
least-scope publisher credential from the deployment secret boundary and set the
|
||||||
|
external service, mediator, and matching scope variables described in the
|
||||||
|
[secure Linux deployment smoke](../deployment/linux.md#http-and-udp-smoke).
|
||||||
|
Run representative external-network tests; loopback success is not NAT coverage.
|
||||||
|
Use the redacting, bounded
|
||||||
|
[real-network canary procedure](../operations/production-readiness.md) for formal
|
||||||
|
production evidence rather than committing raw TestClient JSON.
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Unscouted consumer pilot
|
||||||
|
|
||||||
|
Tracking: Rendezvous #22 and Unscouted #459.
|
||||||
|
|
||||||
|
The current checkpoint independently proves that the v1 contracts are not
|
||||||
|
shaped only around SpaceGame. A real Godot Unscouted host and clients consume
|
||||||
|
the same Client and Contracts package surface, use one caller-owned LiteNetLib
|
||||||
|
socket for NAT callbacks and gameplay, perform Unscouted's own keypair
|
||||||
|
authentication and host admission, exchange gameplay, and exercise a
|
||||||
|
game-owned fallback. The public package restore and representative external
|
||||||
|
NAT/CGNAT canary remain required before #22 can close.
|
||||||
|
|
||||||
|
## Pinned checkpoint
|
||||||
|
|
||||||
|
| Input | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Rendezvous configuration source | `f368fec6eb4344a6042974f58f888cf0f1ac8e8e` |
|
||||||
|
| Rendezvous package source | `07004cd75fe172aa5dfdb3edda22fc280a4c4477` |
|
||||||
|
| Unscouted implementation | `1e5886aa7f1e44689b4c75e32693eb7b19fd72d7` |
|
||||||
|
| Unscouted evidence | `f0574a7de82aadff6495ca5657dfc19cf7c2f67c` |
|
||||||
|
| Client package | `FinalFactory.Rendezvous.Client` `1.0.0` |
|
||||||
|
| Contracts package | `FinalFactory.Rendezvous.Contracts` `1.0.0` |
|
||||||
|
| LiteNetLib | `2.1.4` |
|
||||||
|
| Godot | `4.7.stable.mono.arch_linux.5b4e0cb0f` |
|
||||||
|
| Game / environment / region | `unscouted` / `smoke` / `local` |
|
||||||
|
| Rendezvous and gameplay protocol | `1` |
|
||||||
|
|
||||||
|
The exact package hashes are recorded in
|
||||||
|
[`unscouted.json`](../evidence/consumers/unscouted.json). A clean restore into an
|
||||||
|
empty package directory using only the consumer's checked-in `NuGet.config`
|
||||||
|
returns `NU1101` for both packages. The verified local run used those exact
|
||||||
|
candidate package files from the existing cache. This proves compatibility,
|
||||||
|
not immutable registry publication.
|
||||||
|
|
||||||
|
## Game-neutral service boundary
|
||||||
|
|
||||||
|
Rendezvous #22 adds provisioning data, not an Unscouted branch in the server or
|
||||||
|
SDK. The local production-shaped tenant permits protocol `1`, region `local`,
|
||||||
|
public managed-dedicated listings, and the three bounded presentation keys
|
||||||
|
`mode`, `world`, and `mods`. The short-lived credential helper accepts only the
|
||||||
|
explicitly provisioned `space-game` and `unscouted` scopes and selects a
|
||||||
|
distinct game-scoped signing-key ID and subject.
|
||||||
|
|
||||||
|
The consumer rejects any metadata key outside its three-key presentation
|
||||||
|
schema and neutralizes control/BBCode characters before display. Rendezvous
|
||||||
|
never receives Unscouted player keys or resolved identities, colony authority,
|
||||||
|
simulation or persistence state, fog/interest state, or gameplay packets.
|
||||||
|
|
||||||
|
## Proven real Godot path
|
||||||
|
|
||||||
|
The normal `NetLaunch` argument path recognizes `--rendezvous-pilot` and opens a
|
||||||
|
dedicated scene. That scene uses Unscouted's real `LiteNetLibTransport`,
|
||||||
|
`GameServer`, `GameClient`, `ServerAuthenticator`, and `ClientAuthenticator`.
|
||||||
|
It is not a copied SDK adapter.
|
||||||
|
|
||||||
|
One bounded run against the hardened Compose service started a host plus:
|
||||||
|
|
||||||
|
- a protocol-`999` client that found no compatible listing;
|
||||||
|
- a direct client that received an authorized introduction, completed
|
||||||
|
same-socket traversal, passed Unscouted keypair admission, and exchanged an
|
||||||
|
Unscouted gameplay ping/pong; and
|
||||||
|
- a client pointed at a non-listening mediator that received a typed traversal
|
||||||
|
failure, applied the fallback decision in Unscouted code, repeated admission,
|
||||||
|
and exchanged the same gameplay ping/pong through the ordinary game
|
||||||
|
transport.
|
||||||
|
|
||||||
|
The direct client also proved that both a `space-game` join request and a
|
||||||
|
`production` environment join request return exact `NotFound` results for the
|
||||||
|
Unscouted listing. The host renewed its lease, admitted two independently
|
||||||
|
authenticated sessions, completed two gameplay exchanges, and deregistered the
|
||||||
|
listing on shutdown.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Rendezvous Debug and Release: 299 tests passed in each configuration, zero
|
||||||
|
failures.
|
||||||
|
- Unscouted Debug and Release: non-incremental builds passed; 3,310 tests passed
|
||||||
|
with 15 intentional skips in each configuration.
|
||||||
|
- Unscouted gdUnit/Godot: 360 tests passed, zero skipped or failed. The harness
|
||||||
|
fix in Unscouted #461 keeps compilation headless and leaves the open editor's
|
||||||
|
build tree unchanged.
|
||||||
|
- The final Godot pilot, ShellCheck, JSON/whitespace checks, formatting gate,
|
||||||
|
and adversarial branch review passed.
|
||||||
|
- Export is not applicable because the Unscouted checkout has no
|
||||||
|
`export_presets.cfg`; both C# configurations and the actual Godot entry point
|
||||||
|
were exercised.
|
||||||
|
|
||||||
|
## Remaining acceptance gates
|
||||||
|
|
||||||
|
Do not mark #22 passed until both external gates have direct evidence:
|
||||||
|
|
||||||
|
1. publish or expose the exact immutable `1.0.0` packages on the configured
|
||||||
|
Gitea feed and repeat the empty-cache consumer restore; and
|
||||||
|
2. run the same Godot host/client path across representative residential,
|
||||||
|
CGNAT, and IPv6/multi-host networks, recording the topology and typed
|
||||||
|
direct/fallback outcome.
|
||||||
|
|
||||||
|
The loopback run proves the real process, socket, authentication, and gameplay
|
||||||
|
shape. It does not claim production Internet traversal coverage.
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Capacity, resilience, and availability gate
|
||||||
|
|
||||||
|
Tracking: #18
|
||||||
|
|
||||||
|
This gate turns the v1 budgets in ADR 0003 into a repeatable release decision.
|
||||||
|
It does not turn Rendezvous into a horizontally scalable service: v1 remains one
|
||||||
|
active process with bounded in-memory state. A second process may be a cold
|
||||||
|
standby, but it must not accept traffic until the first process has stopped and
|
||||||
|
released the public HTTP and UDP endpoints.
|
||||||
|
|
||||||
|
## Launch envelope and approved core-state profile
|
||||||
|
|
||||||
|
The approved core-state profile is one Linux process limited to 2 vCPU and
|
||||||
|
2 GiB RAM. Public HTTP/UDP numbers are launch objectives that require the #23
|
||||||
|
real-network canary before they become a supported service claim:
|
||||||
|
|
||||||
|
| Dimension | Value | Evidence status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Visible listings | 25,000 | Enforced and measured here |
|
||||||
|
| Active join attempts | 10,000 | Enforced and measured here |
|
||||||
|
| Core control path | 200 operations/second; p95 at most 200 ms | Measured here |
|
||||||
|
| Core mediation path | 2,000 pairings/second; p95 at most 100 ms | Measured here |
|
||||||
|
| Sustained HTTP demand | 200 requests/second | #23 launch objective; not yet a supported claim |
|
||||||
|
| Sustained UDP demand | 2,000 datagrams/second | #23 launch objective; not yet a supported claim |
|
||||||
|
| Public HTTP/UDP latency | p95 at most 200 ms / 100 ms | #23 launch objective; not yet a supported claim |
|
||||||
|
| Capacity-phase average CPU / peak working memory | below 70% / below 1.5 GiB | Measured for the core candidate |
|
||||||
|
| Valid in-profile monthly availability | 99.5%, excluding announced maintenance | Operational objective |
|
||||||
|
| Process-ready RTO / host-visible recovery | 15 seconds / 90 seconds | 15 seconds automated; 90-second deployment drill required |
|
||||||
|
|
||||||
|
The proposed public-network mix is 20% registration/update, 30% lease-critical
|
||||||
|
renew/delete, 30% browse, and 20% join authorization for HTTP. The UDP mix is
|
||||||
|
60% authenticated host-presence refresh, 30% attempt contributions, and 10%
|
||||||
|
invalid or duplicate traffic that must be dropped early. A deployment may use a
|
||||||
|
lower per-game profile, but must not claim a higher one without new versioned
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
The capacity harness fills the complete state ceilings, then measures
|
||||||
|
registration plus presence, renewal, a 100-item compatible browse, join
|
||||||
|
issuance, simultaneous two-peer pairing, principal revocation, and telemetry.
|
||||||
|
It applies 200/100 ms guardrails and minimum 200 control / 2,000 mediation
|
||||||
|
operations per second to the core hot path. Those measurements deliberately
|
||||||
|
exclude Kestrel, LiteNetLib, TLS, JSON, socket scheduling, and the documented
|
||||||
|
mixed traffic shape. The #23 real-network canary must exercise those layers,
|
||||||
|
rate-shape the mix, record errors and shedding, and meet the public objectives
|
||||||
|
before launch; a core result is not a public-network latency or throughput claim.
|
||||||
|
|
||||||
|
## Reproduce the evidence
|
||||||
|
|
||||||
|
Every push runs the quick profile and the selected fault matrix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run-capacity-gate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the production candidate on an otherwise idle Linux host and restrict the
|
||||||
|
runtime to two logical CPUs. The default candidate includes a five-minute,
|
||||||
|
high-intensity expiry soak; use 3,600 seconds for a release-candidate endurance
|
||||||
|
run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RENDEZVOUS_CAPACITY_PROFILE=candidate
|
||||||
|
export RENDEZVOUS_CAPACITY_CPUSET=0,1
|
||||||
|
export RENDEZVOUS_CAPACITY_OUTPUT="$PWD/artifacts/capacity/candidate.json"
|
||||||
|
./scripts/run-capacity-gate.sh
|
||||||
|
|
||||||
|
# Release-candidate endurance override:
|
||||||
|
dotnet run --project tests/FinalFactory.Rendezvous.Capacity \
|
||||||
|
--configuration Release --no-build -- \
|
||||||
|
--profile candidate --soak-seconds 3600 \
|
||||||
|
--output artifacts/capacity/candidate-endurance.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The machine must have at least 2 GiB available to the process. For formal
|
||||||
|
deployment evidence, run inside the same cgroup/container shape as production.
|
||||||
|
The v2 JSON embeds the commit and tree state, command, image context, CPU model,
|
||||||
|
kernel, affinity, cgroup quota/limit, collector mode, and workload seed. Supply
|
||||||
|
`RENDEZVOUS_EVIDENCE_IMAGE_DIGEST` when running a release image. Do not compare
|
||||||
|
results collected under a debugger,
|
||||||
|
concurrent build, thermal throttling, or oversubscribed CI host.
|
||||||
|
|
||||||
|
The checked-in baseline is
|
||||||
|
[`candidate-2cpu.json`](../evidence/capacity/v2/candidate-2cpu.json). It was
|
||||||
|
produced on .NET 10.0.9/Linux x64 with CPU affinity restricted to two logical
|
||||||
|
CPUs. It filled 25,000 listings and 10,000 attempts, peaked at about 169 MiB,
|
||||||
|
and cleared all active/retained state. The five-minute baseline supersedes any
|
||||||
|
earlier local probe when its timestamp and target duration differ.
|
||||||
|
|
||||||
|
## Soak and bounded-state interpretation
|
||||||
|
|
||||||
|
Each soak cycle creates a listing, repeatedly renews its lease and refreshes
|
||||||
|
presence, creates a join attempt, replay marker, and retained outcome, checks
|
||||||
|
that scheduled expiry entries remain proportional to live keys, then advances
|
||||||
|
the injected monotonic clock beyond all
|
||||||
|
deadlines, and verifies that listings, attempts, replay, idempotency, and outcome
|
||||||
|
state return to zero. The candidate also measures managed-memory and process
|
||||||
|
handle deltas after full collection. Failure is any retained state, more than
|
||||||
|
64 MiB retained managed memory, more than eight retained handles, a working set
|
||||||
|
above 1.5 GiB, an untyped capacity result, or failure to admit work after expiry.
|
||||||
|
|
||||||
|
This accelerated soak intentionally executes far more state lifecycle/cleanup
|
||||||
|
events than wall-clock traffic would permit. It catches stale deadline-queue
|
||||||
|
entries, cache growth, replay/idempotency retention, and cleanup cost. Because
|
||||||
|
it does not open Kestrel/LiteNetLib connections, its process-handle delta is only
|
||||||
|
a harness guard and is not evidence of transport stability by itself. The
|
||||||
|
selected production-process gate adds a ten-second real HTTP/UDP transport soak,
|
||||||
|
samples child-process handles and RSS, asserts bounded growth, then verifies a
|
||||||
|
clean SIGTERM and socket release. #23 must extend that into the full rate-shaped
|
||||||
|
multi-client canary while sampling queues, managed memory, and state
|
||||||
|
cardinalities. A one-hour core override remains required before tagging a
|
||||||
|
production release.
|
||||||
|
|
||||||
|
## Fault and recovery matrix
|
||||||
|
|
||||||
|
`run-capacity-gate.sh` runs these deterministic production paths before the
|
||||||
|
numeric profile:
|
||||||
|
|
||||||
|
| Fault | Required result |
|
||||||
|
| --- | --- |
|
||||||
|
| HTTP/UDP overload and tracker exhaustion | Typed HTTP `429`/`CapacityExceeded`, silent UDP drop, bounded tracker keys, recovery after the window |
|
||||||
|
| Optional traffic saturation | Lease-critical renew/update/delete capacity remains available |
|
||||||
|
| Store/dependency unavailable | Readiness fails; new authorization returns typed `ServiceUnavailable`; liveness remains independent |
|
||||||
|
| Graceful drain/SIGTERM | New work returns `Draining`; existing pairing may finish; process exits 0 and releases TCP/UDP before the deadline |
|
||||||
|
| Hard restart | In-flight state is lost; SDK reports typed `ServiceUnavailable`; a host re-registers, rebinds presence, and becomes the only browser-visible replacement |
|
||||||
|
| UDP listener bind/restart | Readiness stays false without the required listener; rebinding the advertised port restores native LiteNetLib pairing |
|
||||||
|
| Wall-clock jump/skew | Monotonic lease/attempt authority is neither shortened nor extended; credential skew remains capped at 30 seconds |
|
||||||
|
| Signing-secret rotation | New key signs, overlap verifies, retired/revoked key rejects, missing material fails startup |
|
||||||
|
| Principal revocation | Listing, presence, attempts, and outcome paths are removed atomically within the latency budget |
|
||||||
|
|
||||||
|
No external database exists in v1, so “dependency/store failure” means the
|
||||||
|
process-local atomic store is marked unavailable or a required listener/key is
|
||||||
|
unready. The service fails closed rather than pretending a degraded writable
|
||||||
|
mode exists.
|
||||||
|
|
||||||
|
## Bandwidth and amplification
|
||||||
|
|
||||||
|
- Accepted application datagrams are at most 1,200 bytes.
|
||||||
|
- Malformed, oversized, unauthenticated, stale, replayed, wrong-role, and
|
||||||
|
rate-limited traffic receives zero response bytes.
|
||||||
|
- A completing authenticated contribution produces at most one introduction to
|
||||||
|
each observed peer, and the combined response is at most 2.0 times that
|
||||||
|
contribution's bytes.
|
||||||
|
- The frozen-envelope and native LiteNetLib socket tests measure this on the real
|
||||||
|
UDP listener; the hostile corpus and allocation gate exercise 10,000+ inputs
|
||||||
|
without input-sized logs, tasks, or queues.
|
||||||
|
|
||||||
|
Bandwidth planning must therefore reserve ingress for the configured 2,000
|
||||||
|
datagrams/second plus edge overhead and egress for a worst-case verified 2.0
|
||||||
|
amplification. Actual successful pairs normally use two contributions and two
|
||||||
|
introductions; normal gameplay leaves Rendezvous entirely.
|
||||||
|
|
||||||
|
## Availability decision
|
||||||
|
|
||||||
|
Single-active remains the v1 topology. The measured core profile proves bounded
|
||||||
|
state and substantial core-path headroom, while public launch capacity remains
|
||||||
|
conditional on #23. The service has a bounded stop-before-start restart path.
|
||||||
|
Its failure domain is deliberately
|
||||||
|
one process/node/public UDP endpoint: node, kernel, host network, DNS/TLS edge,
|
||||||
|
secret configuration, or operator error can remove all readiness until the cold
|
||||||
|
replacement owns the same source-preserving endpoint.
|
||||||
|
|
||||||
|
The 99.5% objective permits about 216 minutes of unannounced downtime in a
|
||||||
|
30-day month. Operations must target process readiness within 15 seconds and
|
||||||
|
host-visible re-registration within 90 seconds, page when no ready instance
|
||||||
|
exists, and include detection plus recovery in the monthly budget. The current
|
||||||
|
in-process test validates typed downtime, same-port HTTP restart, fresh
|
||||||
|
registration, presence rebinding, and browser visibility in under five seconds;
|
||||||
|
the production-process test separately validates graceful termination, TCP/UDP
|
||||||
|
release, replacement startup on the same endpoints, UDP readiness, and the
|
||||||
|
15-second process-ready RTO. Cold-standby activation policy and the 90-second
|
||||||
|
operator-to-host recovery objective still require a deployment drill before
|
||||||
|
release. Rollout and rollback use the
|
||||||
|
deployment runbook's drain, stop, socket-release, start, smoke sequence; never
|
||||||
|
overlap old and new active processes.
|
||||||
|
|
||||||
|
Bring shared TTL/CAS state and deterministic mediator routing forward before
|
||||||
|
enabling two active instances if any of these occurs:
|
||||||
|
|
||||||
|
- one node cannot sustain 150% of the measured 30-day peak while meeting SLOs;
|
||||||
|
- CPU stays above 70%, memory above 75%, attempt depth above 70%, or limiter
|
||||||
|
drops/latency remain elevated after abusive traffic is excluded;
|
||||||
|
- the availability target rises above 99.5% or planned maintenance must preserve
|
||||||
|
listings; or
|
||||||
|
- one region requires multiple simultaneously active mediator endpoints.
|
||||||
|
|
||||||
|
Rendezvous makes no multi-instance claim today, so a two-node atomic-pairing
|
||||||
|
test is intentionally not applicable. It becomes a hard release gate with the
|
||||||
|
shared-state/routing implementation; until then `SingleActiveInstance=false`
|
||||||
|
fails production startup. Multi-region and relay remain separate evidence-driven
|
||||||
|
decisions.
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
# Incident and change runbooks
|
||||||
|
|
||||||
|
Tracking: #20
|
||||||
|
|
||||||
|
These runbooks supplement the [signal and operator reference](observability-and-operator-runbook.md).
|
||||||
|
Every procedure has four explicit gates: detect, contain, recover, and verify.
|
||||||
|
Record timestamps, the release digest, bounded aggregates, audit fingerprints,
|
||||||
|
and `X-Rendezvous-Correlation-ID` values. Never copy credentials, capabilities,
|
||||||
|
connection tickets, signing material, player identity, raw IP addresses,
|
||||||
|
endpoints, listing metadata, or full request bodies into an incident record.
|
||||||
|
|
||||||
|
Operator routes must be reachable only from an allowed management source. Use a
|
||||||
|
short-lived, least-permission operator credential minted outside Rendezvous.
|
||||||
|
Pass it to an approved operator client through protected stdin or a secret agent,
|
||||||
|
not a URL, command argument, environment-wide process launcher, shell trace, or
|
||||||
|
ticket. All request shapes and responses are defined by the generated
|
||||||
|
[OpenAPI v1 document](../api/rendezvous-v1.json).
|
||||||
|
|
||||||
|
Before an incident, keep these protected records available without depending on
|
||||||
|
the affected service: current and previous image digests, matching configuration,
|
||||||
|
key IDs and lifecycle windows (not raw key values), the game owner/on-call map,
|
||||||
|
capacity baselines, collector destinations, and a separately authorized
|
||||||
|
break-glass operator key. Test management-source allowlisting and credential
|
||||||
|
permissions at least once per release.
|
||||||
|
|
||||||
|
Use the exact versioned action shapes below. Confirmation fields deliberately
|
||||||
|
repeat the target so a stale UI selection or copy error fails closed. Responses
|
||||||
|
do not echo targets.
|
||||||
|
|
||||||
|
| Operation | JSON body |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /v1/operator/listings/revoke` | `{"listingId":"<uuid>","confirmListingId":"<same uuid>"}` |
|
||||||
|
| `POST /v1/operator/principals/revoke` | `{"subject":"<exact subject>","confirmSubject":"<same subject>","lifetimeSeconds":60}` |
|
||||||
|
| `POST /v1/operator/keys/revoke` | `{"keyId":"<key id>","confirmKeyId":"<same key id>"}` |
|
||||||
|
| `POST /v1/operator/drain` | `{"confirmation":"DRAIN"}` |
|
||||||
|
|
||||||
|
## Abuse or authentication spike
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Alert on a baseline-relative increase in `rendezvous.limiter.drops`, HTTP/UDP
|
||||||
|
request rate, `rendezvous.operator.authentication` rejected/forbidden results,
|
||||||
|
registration requests by authentication status, queue depth, or p95/p99 latency.
|
||||||
|
- Check `/health/live`, `/health/ready`, `rendezvous.store.available`, and
|
||||||
|
authenticated `GET /v1/operator/status`. Separate public-source rejection,
|
||||||
|
publisher credential failure, operator probing, and ordinary capacity growth.
|
||||||
|
- Use only bounded operation/result dimensions and correlation IDs. Do not group
|
||||||
|
by raw address, token, subject, listing ID, or metadata.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- Preserve the dedicated operator partition. Do not raise public limits during
|
||||||
|
an active spike. Apply source-preserving edge rate controls only when their
|
||||||
|
collateral effect is understood and UDP source address/port remains intact.
|
||||||
|
- For one abusive session, call `POST /v1/operator/listings/revoke` with identical
|
||||||
|
`listingId` and `confirmListingId`. For a confirmed publisher subject, call
|
||||||
|
`POST /v1/operator/principals/revoke` with identical `subject` and
|
||||||
|
`confirmSubject` and a 1–600 second lifetime.
|
||||||
|
- Revoke a signing key only when compromise evidence implicates that issuer;
|
||||||
|
broad key revocation invalidates every credential signed by it. Drain only if
|
||||||
|
the process itself must be isolated.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Correct the source integration, edge rule, leaked principal grant, or tenant
|
||||||
|
budget under change control. Let a bounded principal revocation expire only
|
||||||
|
after the owner confirms remediation; a repeated shorter revocation never
|
||||||
|
shortens the original deadline.
|
||||||
|
- Restore normal limits gradually. If saturation caused state churn, allow leases
|
||||||
|
and attempts to expire naturally rather than deleting arbitrary state.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Require limiter drops, authentication result ratios, queue depth, latency, and
|
||||||
|
direct-connect outcomes to return to the same-region baseline for the agreed
|
||||||
|
observation window.
|
||||||
|
- Confirm readiness stayed healthy or recovered, operator audit contains the
|
||||||
|
intended action/result fingerprint, revoked resources cannot create new work,
|
||||||
|
and unaffected tenants can still publish, browse, and connect.
|
||||||
|
|
||||||
|
## Signing key or issuer compromise
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Treat secret-manager access alerts, unexpected issuance, credentials outside
|
||||||
|
the expected region/kind, a signing-key expiry alarm, or unexplained publisher
|
||||||
|
authentication growth as compromise until disproved.
|
||||||
|
- Identify the non-secret key ID, allowed credential kinds, game/environment
|
||||||
|
binding, `NotBefore`, `SignUntil`, and `VerifyUntil`. Do not retrieve or paste
|
||||||
|
raw material merely to compare it.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- Stop the affected external issuer and deny further access to its secret.
|
||||||
|
- From a separate uncompromised break-glass operator key with `RotateKeys`, call
|
||||||
|
`POST /v1/operator/keys/revoke` with identical `keyId` and `confirmKeyId`.
|
||||||
|
Runtime revocation is immediate but process-local.
|
||||||
|
- Remove or mark the key revoked in authoritative provisioning before any
|
||||||
|
restart. Revoke affected principals/listings when narrower evidence supports
|
||||||
|
it. Do not drain automatically unless the running instance cannot be trusted.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Generate replacement material in the approved secret boundary, use a new key
|
||||||
|
ID, bind it to the exact credential kind and tenant, and deploy configuration
|
||||||
|
referencing the secret—never the secret value.
|
||||||
|
- Resume issuance with short lifetimes. Reissue only to authenticated workloads.
|
||||||
|
When confidentiality is lost, do not use normal overlap to keep compromised
|
||||||
|
credentials valid; document the intentional invalidation window.
|
||||||
|
- Rotate any release, registry, or operator credential exposed by the same
|
||||||
|
incident through its owning system; Rendezvous key revocation cannot revoke
|
||||||
|
unrelated systems.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Confirm `GET /v1/operator/status` shows the compromised key revoked and the
|
||||||
|
replacement signing, old credentials fail, new exact-scope credentials work,
|
||||||
|
and the result survives a controlled restart from updated provisioning.
|
||||||
|
- Pass TestClient registration, browse, authenticated mediation, and direct
|
||||||
|
traffic with the replacement; monitor authentication and audit results through
|
||||||
|
at least the maximum newly issued credential lifetime.
|
||||||
|
|
||||||
|
## Targeted listing or publisher revocation
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Validate the abuse report against game-owned records and bounded Rendezvous
|
||||||
|
evidence. Determine whether the target is one listing or an authenticated
|
||||||
|
publisher subject. Do not use display name, metadata, or a raw address as
|
||||||
|
identity.
|
||||||
|
- Confirm current aggregate state through `GET /v1/operator/status` and record
|
||||||
|
the correlation IDs that justified action.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- Revoke one listing with `POST /v1/operator/listings/revoke`; the exact listing
|
||||||
|
UUID must appear in both confirmation fields.
|
||||||
|
- Revoke a publisher with `POST /v1/operator/principals/revoke`; the exact subject
|
||||||
|
must appear in both confirmation fields and `lifetimeSeconds` must be 1–600.
|
||||||
|
This removes that principal's active listings and attempts and blocks new ones
|
||||||
|
for the bounded lifetime.
|
||||||
|
- Choose the narrowest action. Do not revoke a tenant key for a single listing.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- The game owner resolves the ban, account, workload, or configuration issue in
|
||||||
|
the authoritative game system. Rendezvous does not own user accounts or bans.
|
||||||
|
- After the original revocation deadline, permit a newly authenticated publisher
|
||||||
|
to register. There is no un-revoke endpoint and no recovery of removed
|
||||||
|
ephemeral listings; the host creates a new listing.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Confirm the old listing is no longer browsable or joinable, the principal
|
||||||
|
cannot publish during its lifetime, and the audit action/result is present
|
||||||
|
without the raw target.
|
||||||
|
- Confirm unrelated publishers in the same tenant and another tenant still pass
|
||||||
|
publish/browse/join/direct-traffic checks.
|
||||||
|
|
||||||
|
## Planned restart or crash recovery
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Planned restart begins with a recorded change and a healthy current baseline.
|
||||||
|
Crash recovery begins when liveness/process state fails or both TCP 8080 and
|
||||||
|
UDP 9050 stop answering. Distinguish dependency/readiness failure from a dead
|
||||||
|
process; liveness deliberately remains healthy for some recoverable failures.
|
||||||
|
- Record active listing/lease/attempt aggregates. They are informational only:
|
||||||
|
v1 has no durable runtime database to restore.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- For a planned stop, call `POST /v1/operator/drain` with confirmation exactly
|
||||||
|
`DRAIN`. Require readiness `503`, liveness `200`, and removal from new traffic.
|
||||||
|
Allow the bounded drain deadline to finish, then send SIGTERM.
|
||||||
|
- Never start a second active instance while the old process owns the advertised
|
||||||
|
HTTP/UDP endpoints. On crash, fence the old process/host and verify both sockets
|
||||||
|
are released before replacement.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Start exactly one instance from the recorded immutable image digest and matching
|
||||||
|
reviewed configuration/key references. A restart intentionally loses listings,
|
||||||
|
observed endpoints, attempts, replay markers, and runtime-only revocations.
|
||||||
|
- Ensure any emergency key revocation is also present in authoritative
|
||||||
|
provisioning. Hosts must re-register; clients must browse and start new
|
||||||
|
attempts. Do not restore stale ephemeral state from logs or backups.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Require live and ready health, UDP bind, store availability, and one active
|
||||||
|
target. Run the full deployment smoke and confirm host re-registration begins.
|
||||||
|
- Verify no pre-restart listing or capability is accepted, runtime revocations
|
||||||
|
that should persist are configuration-backed, and latency/outcomes stabilize.
|
||||||
|
|
||||||
|
## Release rollback
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Trigger rollback from a predeclared objective: readiness loss, failed deployment
|
||||||
|
smoke, contract/package incompatibility, security regression, direct-success
|
||||||
|
regression beyond threshold, or sustained resource regression. Record the new
|
||||||
|
and previous digests and the evidence; do not move a tag.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- Stop promotion and new rollout work. Drain and stop the faulty single active
|
||||||
|
instance, then verify both public sockets are released. Revoke affected keys or
|
||||||
|
principals only when the defect creates an authorization risk.
|
||||||
|
- Preserve logs, artifacts, provenance, signatures, and the faulty release record.
|
||||||
|
Never overwrite or delete an immutable package/image to reuse its version.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Deploy the previous known-good image by digest with its compatible configuration
|
||||||
|
and key set. Do not run old and new concurrently. If configuration changed,
|
||||||
|
apply its reviewed down-migration before starting.
|
||||||
|
- Publish a corrected build under a new SemVer after diagnosis; mark faulty release
|
||||||
|
notes withdrawn when appropriate.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Check the running image digest, live/ready health, one active target, UDP source
|
||||||
|
preservation, and the complete TestClient deployment smoke.
|
||||||
|
- Confirm package/server compatibility from `GET /v1/operator/status`, hosts
|
||||||
|
re-register, and the rollback objective returns to baseline for the observation
|
||||||
|
window.
|
||||||
|
|
||||||
|
## Capacity saturation
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Page when `rendezvous.queue.depth` remains above 90% of the configured attempt
|
||||||
|
limit, lease-critical work is shed, `rendezvous.store.available` is zero, or no
|
||||||
|
ready instance remains. Warn at 70%, sustained `rendezvous.limiter.drops`, or
|
||||||
|
p95 latency above objective.
|
||||||
|
- Compare CPU, memory, file descriptors, UDP errors, expiry churn, HTTP operation
|
||||||
|
rate, and typed connection outcomes with the measured
|
||||||
|
[capacity profile](capacity-and-resilience.md). Distinguish legitimate growth,
|
||||||
|
attack traffic, downstream telemetry pressure, and a regression.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- Preserve lease-critical and operator reserves. Shed new browse/join work with
|
||||||
|
the existing typed `429`/`Retry-After` behavior; do not add an unbounded queue.
|
||||||
|
- Apply per-tenant/source controls at the appropriate trusted boundary. If the
|
||||||
|
process is unstable, drain new work and recover on one replacement rather than
|
||||||
|
adding a second active replica; v1 state is process-local.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Remove the causal load or deploy a tested higher single-instance resource and
|
||||||
|
budget profile. Change CPU/memory and server limits together, using the numeric
|
||||||
|
gate and accelerated soak before production.
|
||||||
|
- Long-term horizontal scaling requires a designed shared directory, replay, and
|
||||||
|
attempt authority. A generic load balancer is not that design.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Re-run the capacity/resilience gate at the chosen profile, then require queue,
|
||||||
|
limiter drops, expiry churn, latency, store health, and direct-success ratio to
|
||||||
|
remain within objectives through the production observation window.
|
||||||
|
- Confirm termination still completes within `DrainDeadlineSeconds + 5` and the
|
||||||
|
public and operator partitions behave independently.
|
||||||
|
|
||||||
|
## Privacy or telemetry incident
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Trigger on any credential, token, capability, player identity, raw IP/endpoint,
|
||||||
|
listing ID, metadata, or caller-reported exact connection duration tied to an
|
||||||
|
event or identity found in logs, metrics, traces, crash reports, support systems,
|
||||||
|
or analytics. Aggregate HTTP/UDP duration histograms with bounded operation tags
|
||||||
|
are expected telemetry. Also trigger when audit data exceeds its approved 30-day
|
||||||
|
retention without an incident hold.
|
||||||
|
- Identify the producing version, sink, access population, retention/replication
|
||||||
|
path, and time window without copying the exposed value into a new system.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- Stop or filter the offending export and restrict access to affected sinks.
|
||||||
|
Preserve the minimum evidence under the incident process; do not take broad
|
||||||
|
diagnostic dumps that amplify exposure.
|
||||||
|
- Revoke exposed reusable credentials/keys through their owning boundary. Listing
|
||||||
|
IDs and endpoints are not authentication secrets, but remove affected listings
|
||||||
|
if continued exposure creates risk. Notify privacy/security owners according to
|
||||||
|
applicable policy and law.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Patch the producer to the allowlisted telemetry model, test canary redaction
|
||||||
|
across logs/metrics/traces/output, and deploy through the immutable release
|
||||||
|
path. Delete or age out affected data from every sink according to approved
|
||||||
|
retention and legal-hold direction.
|
||||||
|
- Replace exposed credentials and re-register hosts when necessary. Do not claim
|
||||||
|
that a service restart deletes copies already exported to collectors.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Search new telemetry using non-secret synthetic canaries and confirm no canary
|
||||||
|
or prohibited field crosses the boundary. Verify audit records contain only
|
||||||
|
fixed fields and fingerprints and that retention/eviction is operating.
|
||||||
|
- Security/privacy owners confirm sink cleanup, access review, notification, and
|
||||||
|
monitoring closure before the incident is resolved.
|
||||||
|
|
||||||
|
## Dependency or base-image upgrade
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Open a reviewed change for an advisory, end-of-support date, pinned-digest
|
||||||
|
refresh, or planned package update. Record affected package/image, current and
|
||||||
|
proposed exact version/digest, advisory severity, exploitability, and required
|
||||||
|
deadline. Never float to `latest` as remediation.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- For an actively exploited critical issue, restrict exposure or stop the service
|
||||||
|
under incident authority while building the fix. Revoking publisher keys does
|
||||||
|
not repair a vulnerable runtime. Otherwise keep the known-good release running
|
||||||
|
while the candidate is tested.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Update the SDK/base-image digest, lock files, license/advisory evidence, SBOM,
|
||||||
|
compatibility matrix, and release notes together. For LiteNetLib or a wire/API
|
||||||
|
change, apply the explicit version/migration policy rather than silently
|
||||||
|
replacing compatible bytes.
|
||||||
|
- Run locked restore, formatting, Debug and Release builds/tests, public contract
|
||||||
|
and package gates, real consumer restores, reproducible artifact/image builds,
|
||||||
|
vulnerability scan, signatures, topology/deployment smoke, and capacity checks
|
||||||
|
proportional to the change. Promote the exact tested digest.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Verify signatures, provenance, checksums, SBOM contents, running digest, and
|
||||||
|
absence of the advisory in the shipped artifact—not merely the build host.
|
||||||
|
- Require live/ready health, TestClient direct traffic, real consumer compatibility,
|
||||||
|
and normal latency/outcomes. Keep the previous digest and compatible config for
|
||||||
|
rollback until the observation window closes.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Observability and operator runbook
|
# Observability and operator reference
|
||||||
|
|
||||||
This runbook defines the production signals and privileged controls for the
|
This runbook defines the production signals and privileged controls for the
|
||||||
Rendezvous service. The service emits `System.Diagnostics.Metrics` instruments
|
Rendezvous service. The service emits `System.Diagnostics.Metrics` instruments
|
||||||
@@ -6,6 +6,10 @@ from the `FinalFactory.Rendezvous` meter and distributed-tracing activities from
|
|||||||
`FinalFactory.Rendezvous.Server`. Connect those sources to the deployment's
|
`FinalFactory.Rendezvous.Server`. Connect those sources to the deployment's
|
||||||
OpenTelemetry or equivalent collector. Do not add identifiers to metric labels.
|
OpenTelemetry or equivalent collector. Do not add identifiers to metric labels.
|
||||||
|
|
||||||
|
Concrete detect/contain/recover/verify procedures for abuse, key compromise,
|
||||||
|
targeted revocation, restart, rollback, saturation, privacy incidents, and
|
||||||
|
dependency upgrades are in the [incident and change runbooks](incident-runbooks.md).
|
||||||
|
|
||||||
## Health and readiness
|
## Health and readiness
|
||||||
|
|
||||||
- `GET /health/live` proves that the HTTP process can answer. It deliberately
|
- `GET /health/live` proves that the HTTP process can answer. It deliberately
|
||||||
@@ -115,7 +119,9 @@ permission denial is audited with actor and target fingerprints.
|
|||||||
Key revocation is process-local in the current single-instance store. Apply the
|
Key revocation is process-local in the current single-instance store. Apply the
|
||||||
same revocation to every instance, then replace configuration before restarting;
|
same revocation to every instance, then replace configuration before restarting;
|
||||||
a restart reconstructs the configured key ring. Principal revocation is bounded
|
a restart reconstructs the configured key ring. Principal revocation is bounded
|
||||||
to ten minutes and removes that principal's active listings and attempts. Use
|
to ten minutes and removes that principal's active listings and attempts. A
|
||||||
|
repeat action may extend an active revocation but never shortens it; wait for its
|
||||||
|
original deadline rather than treating a shorter repeat as an un-revoke. Use
|
||||||
listing revocation for one targeted session and drain before planned shutdown.
|
listing revocation for one targeted session and drain before planned shutdown.
|
||||||
|
|
||||||
## Audit retention and incident handling
|
## Audit retention and incident handling
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Production-readiness decision and real-network canary
|
||||||
|
|
||||||
|
Tracking: #23
|
||||||
|
|
||||||
|
Rendezvous v1 is **not production-ready** until every required gate in
|
||||||
|
[`production-readiness-v1.json`](../evidence/production-readiness-v1.json) is
|
||||||
|
recorded as `pass`. The machine-checkable decision is intentionally fail-closed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check-production-readiness.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit `0` means every required gate is present and passing, exit `3` means the
|
||||||
|
record is valid but at least one gate is pending or failed, and exit `2` means
|
||||||
|
the record itself is malformed or contains identifier-, endpoint-, account-, or
|
||||||
|
credential-shaped data. Editing only the top-level decision cannot make the
|
||||||
|
check pass.
|
||||||
|
|
||||||
|
The checked-in record is an index, not a log archive. It contains one
|
||||||
|
repository-relative evidence reference and a short categorical note per gate.
|
||||||
|
Raw packet captures, client event streams, publisher credentials, public or
|
||||||
|
private network endpoints, listing IDs, and player/account identifiers must not
|
||||||
|
be committed.
|
||||||
|
|
||||||
|
## Required decision matrix
|
||||||
|
|
||||||
|
The local matrix covers immutable artifacts, Debug and Release verification,
|
||||||
|
both real game consumers, the candidate capacity/resilience profile,
|
||||||
|
production-process recovery, and the combined security/privacy/observability
|
||||||
|
gate. These may be reproduced by the project team on a clean candidate commit.
|
||||||
|
|
||||||
|
The external matrix remains distinct because a local namespace, loopback,
|
||||||
|
container bridge, or second process on one machine cannot prove it:
|
||||||
|
|
||||||
|
| Gate | Required evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| Public package empty-cache restore | A clean machine restores the exact Client and Contracts version using only the documented public sources. |
|
||||||
|
| Signed publication | The immutable tag publishes packages, image digest, SBOMs, provenance, checksums, and verifiable signatures through the protected release workflow. |
|
||||||
|
| Source-preserving UDP ingress | Packet capture on the service host proves the mediator observes each peer's real source tuple and replies from the advertised public tuple; no UDP proxy rewrites either direction. |
|
||||||
|
| Same-LAN direct canary | Two independently operated game clients establish authenticated direct LiteNetLib traffic. |
|
||||||
|
| Home-NAT direct canary | Host and joiner on distinct residential networks establish authenticated direct LiteNetLib traffic. |
|
||||||
|
| Restrictive/CGNAT and blocked-UDP canaries | Each bounded join exits `12`, records a typed terminal category, and exposes the game-owned fallback policy without hanging or claiming success. |
|
||||||
|
| IPv6 direct canary | Two external IPv6 clients record authenticated direct traffic and an observed `ipv6` peer address family. |
|
||||||
|
| Public rate-shaped capacity | The documented HTTP/UDP workload mix meets its objectives through TLS, Kestrel, JSON, LiteNetLib, kernel sockets, and public ingress. |
|
||||||
|
| One-hour endurance | The immutable production-shaped candidate completes the one-hour profile without a state, handle, memory, readiness, or latency failure. |
|
||||||
|
| Alert delivery | A real alert sink receives both trigger and recovery notifications for the rehearsed outage. |
|
||||||
|
| Cold-standby rollback | Drain, stop, socket release, replacement start, host re-registration, and rollback meet the process and host-visible recovery objectives. |
|
||||||
|
| Documentation-only exercise | An operator who did not author the runbooks completes key rotation/revocation, outage, restart, re-registration, and rollback using only the checked-in documentation. |
|
||||||
|
|
||||||
|
Failure or missing evidence is blocking. It is never converted into an accepted
|
||||||
|
risk by changing the wording of the readiness note.
|
||||||
|
|
||||||
|
When an external gate passes, add a redacted repository JSON attestation and
|
||||||
|
point that gate's `evidenceRef` to it. The checker requires this exact shape and
|
||||||
|
binds the gate to the evaluated candidate commit. `artifactDigest` is the SHA-256
|
||||||
|
of the protected evidence bundle or public release record, not a peer endpoint,
|
||||||
|
listing identifier, account identifier, or credential:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "rendezvous-external-gate-attestation",
|
||||||
|
"gateId": "replace-with-the-exact-gate-id",
|
||||||
|
"candidateCommit": "replace-with-the-40-character-candidate-commit",
|
||||||
|
"result": "pass",
|
||||||
|
"performedAtUtc": "2026-01-01T00:00:00Z",
|
||||||
|
"artifactDigest": "replace-with-the-64-character-sha256",
|
||||||
|
"evidenceLocation": "protected-operations-record",
|
||||||
|
"reviewerRole": "independent-operator"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed evidence locations are `protected-operations-record` and
|
||||||
|
`public-release-record`. Allowed reviewer roles are `release-operator`,
|
||||||
|
`network-operator`, `security-operator`, and `independent-operator`. The checker
|
||||||
|
rejects a missing file, wrong gate, wrong candidate, malformed digest, naive
|
||||||
|
timestamp, extra fields, or sensitive-data-shaped contents.
|
||||||
|
|
||||||
|
## Prepare one immutable canary build
|
||||||
|
|
||||||
|
Use the exact release candidate on every canary machine. Verify a clean checkout,
|
||||||
|
restore in locked mode, and build the TestClient before changing networks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -z "$(git status --porcelain)"
|
||||||
|
dotnet restore Rendezvous.slnx --locked-mode
|
||||||
|
dotnet build Rendezvous.slnx --configuration Release --no-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep shell tracing disabled. The host receives a short-lived, least-scope
|
||||||
|
publisher credential through `RENDEZVOUS_PUBLISHER_CREDENTIAL`; it must never be
|
||||||
|
put in an argument, coordination file, evidence file, command transcript, or
|
||||||
|
support message. Set the public HTTPS service URL and advertised UDP mediator
|
||||||
|
tuple separately. TestClient rejects credentials embedded in the service URL.
|
||||||
|
|
||||||
|
## Run a success canary across two machines
|
||||||
|
|
||||||
|
On the host machine, choose `same-lan`, `home-nat`, or `ipv6-direct`. The
|
||||||
|
coordination file is mode `0600` and contains only the temporary listing UUID.
|
||||||
|
It is not evidence; transfer it through an approved private channel, then delete
|
||||||
|
both copies.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set +x
|
||||||
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL='supplied-by-the-approved-secret-boundary'
|
||||||
|
export RENDEZVOUS_CANARY_ROLE=host
|
||||||
|
export RENDEZVOUS_CANARY_TOPOLOGY=home-nat
|
||||||
|
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
|
||||||
|
export RENDEZVOUS_CANARY_HTTP_URL='https://service.example.invalid/'
|
||||||
|
export RENDEZVOUS_CANARY_UDP_ENDPOINT='203.0.113.10:9050'
|
||||||
|
export RENDEZVOUS_CANARY_COORDINATION_FILE="$HOME/.local/state/rendezvous-canary-listing"
|
||||||
|
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/home-nat-host.json"
|
||||||
|
./scripts/run-real-network-canary.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The host prints only that it is ready and waits for the authenticated exchange.
|
||||||
|
On the joiner, read the securely transferred UUID without placing it in shell
|
||||||
|
history and run the matching topology:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set +x
|
||||||
|
read -r RENDEZVOUS_CANARY_LISTING_ID < "$HOME/.local/state/rendezvous-canary-listing"
|
||||||
|
export RENDEZVOUS_CANARY_LISTING_ID
|
||||||
|
export RENDEZVOUS_CANARY_ROLE=client-success
|
||||||
|
export RENDEZVOUS_CANARY_TOPOLOGY=home-nat
|
||||||
|
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
|
||||||
|
export RENDEZVOUS_CANARY_HTTP_URL='https://service.example.invalid/'
|
||||||
|
export RENDEZVOUS_CANARY_UDP_ENDPOINT='203.0.113.10:9050'
|
||||||
|
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/home-nat-client.json"
|
||||||
|
./scripts/run-real-network-canary.sh
|
||||||
|
unset RENDEZVOUS_CANARY_LISTING_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
The host summary requires authenticated direct traffic and deregistration. The
|
||||||
|
client summary requires connection, authenticated direct traffic, accepted
|
||||||
|
outcome reporting, and the declared address family observed on the actual peer.
|
||||||
|
The summaries deliberately contain no network tuple or listing identifier.
|
||||||
|
|
||||||
|
For IPv6, set the topology to `ipv6-direct`, the family to `ipv6`, and use the
|
||||||
|
deployment's bracketed IPv6 mediator form. Record unsupported operating systems,
|
||||||
|
console platforms, VPNs, and address families as untested; an IPv4 pass is not
|
||||||
|
evidence for IPv6 or a platform network policy.
|
||||||
|
|
||||||
|
## Run a bounded failure canary
|
||||||
|
|
||||||
|
Start the host from an independently reachable network as above. On the joiner,
|
||||||
|
apply the reviewed firewall rule that blocks the relevant UDP path, or use the
|
||||||
|
known restrictive carrier network, then set `client-expected-failure` and the
|
||||||
|
matching topology:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RENDEZVOUS_CANARY_ROLE=client-expected-failure
|
||||||
|
export RENDEZVOUS_CANARY_TOPOLOGY=firewall-blocked-udp
|
||||||
|
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
|
||||||
|
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/firewall-blocked-client.json"
|
||||||
|
./scripts/run-real-network-canary.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This role passes only when TestClient exits exactly `12`, emits a non-empty typed
|
||||||
|
authorization/traversal outcome, and emits the authoritative fallback category.
|
||||||
|
A timeout without the typed terminal outcome, exit `0`, direct-traffic success,
|
||||||
|
or an unbounded process is a failed canary. Restore the firewall after the drill
|
||||||
|
and verify normal traffic again.
|
||||||
|
|
||||||
|
## Private diagnostics and retention
|
||||||
|
|
||||||
|
The harness creates raw JSON events under a randomly named `0700`-equivalent
|
||||||
|
temporary directory with a process `umask` of `077`. Successful raw events are
|
||||||
|
deleted automatically. On failure they remain in that private directory so the
|
||||||
|
operator can triage locally; do not attach them to an issue before removing
|
||||||
|
listing IDs and reviewing every field. Set `RENDEZVOUS_CANARY_KEEP_RAW=true`
|
||||||
|
only for an approved short-lived diagnostic capture, then delete it manually.
|
||||||
|
|
||||||
|
The sanitized summary contains the commit, clean/dirty tree state, UTC time,
|
||||||
|
role, declared topology, observed address-family gate, aggregate booleans, and
|
||||||
|
the retention policy. Formal evidence requires the default clean-tree check.
|
||||||
|
|
||||||
|
## Public ingress proof
|
||||||
|
|
||||||
|
Success through a public hostname is insufficient proof that UDP source/reply
|
||||||
|
addressing is preserved. During a canary, an authorized operator must capture
|
||||||
|
only packet headers at the service host and verify:
|
||||||
|
|
||||||
|
1. each authenticated contribution reaches the mediator with the external peer
|
||||||
|
source tuple visible to the server;
|
||||||
|
2. introductions are sent from the same advertised public mediator tuple;
|
||||||
|
3. no load balancer, user-space proxy, service mesh, or destination NAT changes
|
||||||
|
the source or reply tuple expected by LiteNetLib; and
|
||||||
|
4. malformed or unauthenticated traffic receives no amplified response.
|
||||||
|
|
||||||
|
Store the approval, capture time window, candidate digest, topology category,
|
||||||
|
and pass/fail result. Do not retain packet payloads or peer tuples in the
|
||||||
|
repository. A failed tuple check blocks release even if one canary happened to
|
||||||
|
connect.
|
||||||
|
|
||||||
|
## Rehearsal and triage
|
||||||
|
|
||||||
|
Run the security, capacity, observability, deployment, rollback, privacy, and
|
||||||
|
incident procedures against the same immutable candidate. The independent
|
||||||
|
operator records which runbook revision they followed, start/end time, observed
|
||||||
|
alerts, recovery time, unexpected decisions, and pass/fail result. Update the
|
||||||
|
documentation and repeat any failed or ambiguous step.
|
||||||
|
|
||||||
|
Before changing the readiness record, reconcile every open roadmap issue as one
|
||||||
|
of: `blocking` with an owner and evidence needed, `accepted-v1` with a bounded
|
||||||
|
documented limitation, or `post-v1` with a filed issue. HA, active-active or
|
||||||
|
multi-region routing, relays, platform authentication, and scale above the
|
||||||
|
single-active v1 envelope are not silently accepted; each needs a traceable
|
||||||
|
post-v1 issue. The current follow-ups are relay decision [#24], HA/multi-region
|
||||||
|
shared state and routing [#28], scale beyond the measured envelope [#29], and
|
||||||
|
platform authentication adapters [#30]. Run the checker after every evidence
|
||||||
|
update. Only its `READY` result may support a production-ready claim.
|
||||||
|
|
||||||
|
[#24]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/24
|
||||||
|
[#28]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/28
|
||||||
|
[#29]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/29
|
||||||
|
[#30]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/30
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Releases and compatibility
|
||||||
|
|
||||||
|
Tracking: #19
|
||||||
|
|
||||||
|
Rendezvous releases are immutable, reproducible, and promoted only after the
|
||||||
|
same candidate has passed package, consumer, server, container, and staging
|
||||||
|
checks. A release consists of matching Client and Contracts NuGet packages, a
|
||||||
|
framework-dependent Linux server archive, a versioned linux/amd64 OCI image,
|
||||||
|
package/runtime and container SPDX inventories, checksums, provenance, release
|
||||||
|
notes, and signatures. No workflow publishes a `latest` tag.
|
||||||
|
|
||||||
|
## Version dimensions
|
||||||
|
|
||||||
|
The central values in `eng/Versions.props` are the authority. Client,
|
||||||
|
Contracts, and Server use SemVer. HTTP, UDP mediation, and connection-ticket
|
||||||
|
formats advance independently so a wire change cannot hide inside a package
|
||||||
|
patch release. The current machine-readable matrix is
|
||||||
|
[`compatibility.json`](compatibility.json); the authenticated operator status
|
||||||
|
endpoint exposes the server's supported window at runtime.
|
||||||
|
|
||||||
|
| Surface | Current | Compatibility rule |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| Client and Contracts | 1.0.0 | Matching exact versions; source/API breaks require a package major bump. |
|
||||||
|
| Server | 1.0.0 | Accepts Client 1.0.0 through compatible 1.x releases. |
|
||||||
|
| HTTP contract | 1 | Frozen OpenAPI, JSON vectors, and public API snapshot. |
|
||||||
|
| UDP mediation | 1 | Frozen codec vectors; incompatible bytes require UDP v2. |
|
||||||
|
| Connection ticket | 1 | A format change requires a new accepted ticket version and migration window. |
|
||||||
|
| LiteNetLib | 2.1.4 | Exact dependency; LiteNetLib 1.x is rejected by package and consumer gates. |
|
||||||
|
| Gameplay protocol | Per tenant | Exact match; Rendezvous does not translate gameplay protocols. |
|
||||||
|
|
||||||
|
`scripts/check-compatibility.sh` compares protected snapshots against the base
|
||||||
|
revision. A changed public API snapshot requires a package major increase; an
|
||||||
|
HTTP or UDP golden surface requires the corresponding contract increase. The
|
||||||
|
normal tests also compare implementation output with the current versioned
|
||||||
|
snapshots. For a deliberate break, add a new versioned contract directory and
|
||||||
|
documentation instead of replacing the prior version's evidence.
|
||||||
|
|
||||||
|
## Candidate build
|
||||||
|
|
||||||
|
From a clean tagged checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check-release-tag.sh v1.0.0
|
||||||
|
./scripts/build-release.sh 1.0.0
|
||||||
|
./scripts/verify-release.sh 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The tag build runs inside the digest-pinned `release-builder` Docker stage,
|
||||||
|
which combines the pinned SDK with a pinned Python runtime. It uses the locked
|
||||||
|
dependency graph, enforces NuGet
|
||||||
|
advisories and approved licenses, runs formatting/build/tests, regenerates the
|
||||||
|
OpenAPI drift check, and packs twice after a clean rebuild. NuGet's random OPC
|
||||||
|
relationship identifiers are canonicalized before comparison; both `.nupkg`
|
||||||
|
and `.snupkg` outputs must then be byte-identical. Package metadata identifies the exact
|
||||||
|
repository commit, portable PDBs carry SourceLink data, and the Linux archive,
|
||||||
|
runtime SBOM timestamp, and checksum ordering are deterministic. Buildx and
|
||||||
|
BuildKit are also pinned for the linux/amd64 OCI build. Provenance records each
|
||||||
|
artifact-producing tool version; an out-of-band rebuild must use the pinned
|
||||||
|
builder and recorded versions rather than treating the runner label as a
|
||||||
|
reproducibility guarantee.
|
||||||
|
All project-authored artifact normalization and checksum updates run inside the
|
||||||
|
same pinned builder; host tools only orchestrate or verify. The separately
|
||||||
|
pinned Trivy and Cosign tools produce the container inventory and signatures.
|
||||||
|
The tag workflow also performs two no-cache image builds with the commit time
|
||||||
|
and revision fixed, disables unsigned builder-generated attestations, and
|
||||||
|
requires identical OCI image IDs before signing the project provenance.
|
||||||
|
|
||||||
|
The local gate builds net8.0 SpaceGame- and Unscouted-shaped API fixtures using
|
||||||
|
only the candidate feed plus NuGet.org; the Unscouted fixture also carries its
|
||||||
|
real direct LiteNetLib 2.1.4 pin. The tag gate separately checks out the exact
|
||||||
|
SpaceGame and Unscouted revisions in `eng/consumer-revisions.json`, injects
|
||||||
|
exact candidate references without modifying those repositories, and restores
|
||||||
|
their real game/network projects. Both paths must resolve the matching Client
|
||||||
|
and Contracts version and LiteNetLib 2.1.4. Updating a consumer revision is a
|
||||||
|
reviewed compatibility change, not a floating-main check.
|
||||||
|
|
||||||
|
## Promotion and publication
|
||||||
|
|
||||||
|
Pushing the matching `vMAJOR.MINOR.PATCH` tag starts the tag-only release
|
||||||
|
workflow. Before any external write it:
|
||||||
|
|
||||||
|
1. builds and verifies the artifact set;
|
||||||
|
2. builds the exact versioned container candidate;
|
||||||
|
3. starts that image with production hardening and a temporary staging key;
|
||||||
|
4. completes HTTP health, registration, browse, authenticated UDP mediation,
|
||||||
|
and direct traffic;
|
||||||
|
5. rejects all high or critical container findings and emits a container SPDX
|
||||||
|
inventory;
|
||||||
|
6. finalizes and verifies checksums over the publish-ready artifact set; and
|
||||||
|
7. confirms the two NuGet versions, container version, and Gitea release do not
|
||||||
|
already exist.
|
||||||
|
|
||||||
|
Publication has no skip-duplicate behavior. Gitea's immutable package versions,
|
||||||
|
the workflow concurrency lock, and the preflight make a successful tag a
|
||||||
|
single publication event. The workflow pushes symbols, publishes only the
|
||||||
|
versioned container tag, records its `sha256` digest in both a digest file and
|
||||||
|
provenance, regenerates the checksum manifest so that digest and public key are
|
||||||
|
covered, signs the checksum manifest and image, attaches signed provenance,
|
||||||
|
verifies the complete published-set schema and all signatures, and creates the
|
||||||
|
Gitea release with exactly those artifacts. The detached checksum signature
|
||||||
|
bundle is the sole envelope excluded from its own signed manifest.
|
||||||
|
The loaded image ID is captured immediately after the byte-reproducible build;
|
||||||
|
publication refuses to push if staging or another process retagged that local
|
||||||
|
name to different bytes.
|
||||||
|
|
||||||
|
The protected `production` environment requires these secrets:
|
||||||
|
|
||||||
|
- `RELEASE_TOKEN`: a dedicated Gitea token limited to this repository and the
|
||||||
|
HeiKyu package registry, with repository and package write access;
|
||||||
|
- `RELEASE_USERNAME`: the dedicated Gitea service-account name that owns the
|
||||||
|
release token;
|
||||||
|
- `COSIGN_PRIVATE_KEY`: the encrypted Cosign release private key; and
|
||||||
|
- `COSIGN_PASSWORD`: its password, stored separately.
|
||||||
|
|
||||||
|
No development signing key, registry credential, or deployable configuration
|
||||||
|
is stored in source or packages. Keep the Cosign public key with operational
|
||||||
|
records. Rotate the release key between releases: retain the old public key for
|
||||||
|
historical verification, install the new encrypted private key and password as
|
||||||
|
one reviewed change, verify a signed non-release blob, and only then retire the
|
||||||
|
old secret. Suspected compromise requires token/key revocation and a new
|
||||||
|
version; never overwrite or delete evidence to reuse a released version.
|
||||||
|
|
||||||
|
## Release notes and migration
|
||||||
|
|
||||||
|
Every dated `CHANGELOG.md` entry must contain Compatibility, Security and
|
||||||
|
configuration, and Migration sections. Before tagging, state the supported
|
||||||
|
Client/server window, all HTTP/UDP/ticket changes, security fixes, required
|
||||||
|
configuration, and operator/consumer migration steps.
|
||||||
|
|
||||||
|
For a protocol migration, first make the server read both old and new versions
|
||||||
|
within an explicit bounded window, publish a Client that writes the new version,
|
||||||
|
verify adoption through bounded telemetry, then remove the old reader only in a
|
||||||
|
subsequent breaking release. Never silently reinterpret old bytes. Consumers
|
||||||
|
pin both Rendezvous packages to one exact version and choose gameplay protocol
|
||||||
|
compatibility per tenant.
|
||||||
|
|
||||||
|
## Rollback and interrupted publication
|
||||||
|
|
||||||
|
Runtime rollback means redeploying the previous known-good image by digest and
|
||||||
|
its matching configuration; it does not move a tag. Packages and release
|
||||||
|
records remain available so already restored clients stay reproducible. If a
|
||||||
|
new release is faulty, revoke affected publisher or signing keys when relevant,
|
||||||
|
mark the release notes as withdrawn, and publish the fix under a new SemVer.
|
||||||
|
|
||||||
|
The registries cannot provide a transaction spanning NuGet, OCI, signatures,
|
||||||
|
and release attachments. If publication stops after its first external write,
|
||||||
|
the preflight intentionally prevents an automatic rerun. An operator must
|
||||||
|
inventory every destination, preserve logs and hashes, complete or withdraw the
|
||||||
|
partial version under change control, and then issue a new version. This avoids
|
||||||
|
turning a partial failure into an untraceable overwrite.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"release": "1.0.0",
|
||||||
|
"server": {
|
||||||
|
"minimumClientVersion": "1.0.0",
|
||||||
|
"maximumClientMajorVersion": 1
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"FinalFactory.Rendezvous.Client": "1.0.0",
|
||||||
|
"FinalFactory.Rendezvous.Contracts": "1.0.0"
|
||||||
|
},
|
||||||
|
"contracts": {
|
||||||
|
"http": [1],
|
||||||
|
"udp": [1],
|
||||||
|
"connectionTicket": [1],
|
||||||
|
"gameplay": "exact-per-tenant"
|
||||||
|
},
|
||||||
|
"transport": {
|
||||||
|
"package": "LiteNetLib",
|
||||||
|
"version": "2.1.4",
|
||||||
|
"major": 2
|
||||||
|
},
|
||||||
|
"consumers": {
|
||||||
|
"SpaceGame": "net8.0",
|
||||||
|
"Unscouted": "net8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<RendezvousVersion>1.0.0</RendezvousVersion>
|
||||||
|
<RendezvousMajorVersion>1</RendezvousMajorVersion>
|
||||||
|
<RendezvousMinorVersion>0</RendezvousMinorVersion>
|
||||||
|
<RendezvousPatchVersion>0</RendezvousPatchVersion>
|
||||||
|
<MinimumClientVersion>1.0.0</MinimumClientVersion>
|
||||||
|
<MaximumClientMajorVersion>1</MaximumClientMajorVersion>
|
||||||
|
<HttpContractVersion>1</HttpContractVersion>
|
||||||
|
<UdpContractVersion>1</UdpContractVersion>
|
||||||
|
<ConnectionTicketFormatVersion>1</ConnectionTicketFormatVersion>
|
||||||
|
<LiteNetLibVersion>2.1.4</LiteNetLibVersion>
|
||||||
|
<LiteNetLibMajorVersion>2</LiteNetLibMajorVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
Executable
+309
@@ -0,0 +1,309 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate the redacted v1 readiness record and emit the release decision."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL_GATES = {
|
||||||
|
"immutable-release-artifacts",
|
||||||
|
"debug-and-release-verification",
|
||||||
|
"real-consumer-pilots",
|
||||||
|
"candidate-capacity-resilience",
|
||||||
|
"production-process-recovery",
|
||||||
|
"security-privacy-observability",
|
||||||
|
}
|
||||||
|
EXTERNAL_GATES = {
|
||||||
|
"public-package-empty-cache-restore",
|
||||||
|
"signed-publication",
|
||||||
|
"source-preserving-udp-ingress",
|
||||||
|
"same-lan-direct-canary",
|
||||||
|
"home-nat-direct-canary",
|
||||||
|
"restrictive-cgnat-typed-failure",
|
||||||
|
"firewall-blocked-udp-typed-failure",
|
||||||
|
"ipv6-direct-canary",
|
||||||
|
"public-rate-shaped-capacity",
|
||||||
|
"one-hour-candidate-endurance",
|
||||||
|
"alert-delivery",
|
||||||
|
"cold-standby-rollback-drill",
|
||||||
|
"documentation-only-runbook-exercise",
|
||||||
|
}
|
||||||
|
STATUSES = {"pass", "pending", "fail"}
|
||||||
|
FORBIDDEN_KEY_PARTS = {
|
||||||
|
"address",
|
||||||
|
"credential",
|
||||||
|
"endpoint",
|
||||||
|
"listingid",
|
||||||
|
"password",
|
||||||
|
"playerid",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"userid",
|
||||||
|
}
|
||||||
|
UUID = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F-]{27,}\b")
|
||||||
|
IPV4 = re.compile(r"(?<![0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9])")
|
||||||
|
IPV6 = re.compile(
|
||||||
|
r"(?i)(?:\b[0-9a-f]{0,4}:[0-9a-f:]*::[0-9a-f:]*\b|\b(?:[0-9a-f]{1,4}:){4,}[0-9a-f:]{1,39}\b)"
|
||||||
|
)
|
||||||
|
COMMIT = re.compile(r"[0-9a-f]{40}")
|
||||||
|
DIGEST = re.compile(r"[0-9a-f]{64}")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRecord(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def reject_sensitive(value: Any, path: str = "$") -> None:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, child in value.items():
|
||||||
|
normalized = re.sub(r"[^a-z0-9]", "", key.lower())
|
||||||
|
if any(part in normalized for part in FORBIDDEN_KEY_PARTS):
|
||||||
|
raise InvalidRecord(f"{path}.{key} uses a forbidden sensitive-data key")
|
||||||
|
reject_sensitive(child, f"{path}.{key}")
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for index, child in enumerate(value):
|
||||||
|
reject_sensitive(child, f"{path}[{index}]")
|
||||||
|
elif isinstance(value, str):
|
||||||
|
if UUID.search(value) or IPV4.search(value) or IPV6.search(value) \
|
||||||
|
or "://" in value or "@" in value:
|
||||||
|
raise InvalidRecord(f"{path} contains endpoint, identifier, or account-shaped data")
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_path(repository_root: pathlib.Path, value: str, path: str) -> pathlib.Path:
|
||||||
|
relative = pathlib.PurePosixPath(value)
|
||||||
|
if relative.is_absolute() or ".." in relative.parts or not value:
|
||||||
|
raise InvalidRecord(f"{path} must be a repository-relative reference")
|
||||||
|
candidate = (repository_root / pathlib.Path(*relative.parts)).resolve()
|
||||||
|
if not candidate.is_relative_to(repository_root.resolve()) or not candidate.is_file():
|
||||||
|
raise InvalidRecord(f"{path} does not resolve to a repository evidence file")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: pathlib.Path, label: str) -> Any:
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as source:
|
||||||
|
return json.load(source)
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise InvalidRecord(f"{label} is not readable JSON: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def validate_gate_set(
|
||||||
|
items: Any,
|
||||||
|
expected: set[str],
|
||||||
|
path: str,
|
||||||
|
repository_root: pathlib.Path,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
if not isinstance(items, list):
|
||||||
|
raise InvalidRecord(f"{path} must be an array")
|
||||||
|
gates: list[dict[str, str]] = []
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
if not isinstance(item, dict) or set(item) != {"id", "status", "evidenceRef", "note"}:
|
||||||
|
raise InvalidRecord(f"{path}[{index}] has an invalid shape")
|
||||||
|
if not all(isinstance(item[key], str) for key in item):
|
||||||
|
raise InvalidRecord(f"{path}[{index}] fields must be strings")
|
||||||
|
if item["status"] not in STATUSES:
|
||||||
|
raise InvalidRecord(f"{path}[{index}] has an invalid status")
|
||||||
|
evidence_path(repository_root, item["evidenceRef"], f"{path}[{index}].evidenceRef")
|
||||||
|
if len(item["note"]) > 240:
|
||||||
|
raise InvalidRecord(f"{path}[{index}].note is too long")
|
||||||
|
gates.append(item)
|
||||||
|
identifiers = [gate["id"] for gate in gates]
|
||||||
|
if len(identifiers) != len(set(identifiers)):
|
||||||
|
raise InvalidRecord(f"{path} contains duplicate gate identifiers")
|
||||||
|
if set(identifiers) != expected:
|
||||||
|
missing = sorted(expected - set(identifiers))
|
||||||
|
extra = sorted(set(identifiers) - expected)
|
||||||
|
raise InvalidRecord(f"{path} gate mismatch; missing={missing}, extra={extra}")
|
||||||
|
return gates
|
||||||
|
|
||||||
|
|
||||||
|
def validate_local_evidence(
|
||||||
|
record: dict[str, Any],
|
||||||
|
gates: list[dict[str, str]],
|
||||||
|
repository_root: pathlib.Path,
|
||||||
|
) -> None:
|
||||||
|
if any(gate["status"] != "pass" for gate in gates):
|
||||||
|
return
|
||||||
|
commit = record["evaluatedCommit"]
|
||||||
|
release_path = evidence_path(
|
||||||
|
repository_root,
|
||||||
|
"docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"local release evidence",
|
||||||
|
)
|
||||||
|
release = load_json(release_path, "local release evidence")
|
||||||
|
if not isinstance(release, dict) or release.get("schemaVersion") != 1 \
|
||||||
|
or release.get("kind") != "rendezvous-local-release-candidate" \
|
||||||
|
or release.get("sourceCommit") != commit \
|
||||||
|
or release.get("treeState") != "clean" \
|
||||||
|
or release.get("result") != "pass":
|
||||||
|
raise InvalidRecord("local release evidence is not a passing clean build of evaluatedCommit")
|
||||||
|
verification = release.get("verification")
|
||||||
|
if not isinstance(verification, dict):
|
||||||
|
raise InvalidRecord("local release evidence has no verification object")
|
||||||
|
exact_passes = {
|
||||||
|
"lockedRestore": "pass",
|
||||||
|
"format": "pass",
|
||||||
|
"byteReproduciblePackages": "pass",
|
||||||
|
"byteReproducibleServerArchive": "pass",
|
||||||
|
"sbomChecksumsAndProvenance": "pass",
|
||||||
|
"candidateConsumerFixtures": "pass",
|
||||||
|
"realConsumerRestores": "pass",
|
||||||
|
}
|
||||||
|
if any(verification.get(key) != value for key, value in exact_passes.items()) \
|
||||||
|
or verification.get("reportedVulnerabilities") != 0 \
|
||||||
|
or verification.get("releaseBuildWarnings") != 0 \
|
||||||
|
or verification.get("releaseBuildErrors") != 0 \
|
||||||
|
or verification.get("debugTestsPassed", 0) < 300 \
|
||||||
|
or verification.get("debugTestsFailed") != 0 \
|
||||||
|
or verification.get("releaseTestsPassed", 0) < 300 \
|
||||||
|
or verification.get("releaseTestsFailed") != 0 \
|
||||||
|
or verification.get("selectedProductionFaultTestsPassed", 0) < 17:
|
||||||
|
raise InvalidRecord("local release evidence does not satisfy every required verification")
|
||||||
|
consumers = release.get("consumers")
|
||||||
|
if not isinstance(consumers, list) or {
|
||||||
|
item.get("name") for item in consumers if isinstance(item, dict)
|
||||||
|
} != {"SpaceGame", "Unscouted"} or any(
|
||||||
|
not isinstance(item, dict)
|
||||||
|
or item.get("candidateRestore") != "pass"
|
||||||
|
or item.get("directTrafficPilot") != "pass"
|
||||||
|
for item in consumers
|
||||||
|
):
|
||||||
|
raise InvalidRecord("local release evidence does not prove both required consumers")
|
||||||
|
|
||||||
|
capacity_path = evidence_path(
|
||||||
|
repository_root,
|
||||||
|
"docs/evidence/capacity/v2/candidate-2cpu.json",
|
||||||
|
"candidate capacity evidence",
|
||||||
|
)
|
||||||
|
capacity = load_json(capacity_path, "candidate capacity evidence")
|
||||||
|
runtime = capacity.get("runtime") if isinstance(capacity, dict) else None
|
||||||
|
state = capacity.get("state") if isinstance(capacity, dict) else None
|
||||||
|
if not isinstance(runtime, dict) or not isinstance(state, dict) \
|
||||||
|
or capacity.get("schemaVersion") != 2 \
|
||||||
|
or capacity.get("profile") != "candidate" \
|
||||||
|
or capacity.get("passed") is not True \
|
||||||
|
or capacity.get("failures") != [] \
|
||||||
|
or runtime.get("commitSha") != commit \
|
||||||
|
or runtime.get("treeState") != "clean" \
|
||||||
|
or runtime.get("processorCount") != 2 \
|
||||||
|
or state.get("soakDurationSeconds", 0) < 300 \
|
||||||
|
or state.get("finalListings") != 0 \
|
||||||
|
or state.get("finalAttempts") != 0 \
|
||||||
|
or state.get("finalReplayMarkers") != 0 \
|
||||||
|
or state.get("restartStartedEmpty") is not True \
|
||||||
|
or state.get("overloadWasTyped") is not True \
|
||||||
|
or state.get("recoverySucceeded") is not True:
|
||||||
|
raise InvalidRecord("candidate capacity evidence does not satisfy the clean evaluated commit")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_external_attestations(
|
||||||
|
record: dict[str, Any],
|
||||||
|
gates: list[dict[str, str]],
|
||||||
|
repository_root: pathlib.Path,
|
||||||
|
) -> None:
|
||||||
|
for gate in gates:
|
||||||
|
if gate["status"] != "pass":
|
||||||
|
continue
|
||||||
|
path = evidence_path(repository_root, gate["evidenceRef"], f"{gate['id']} evidence")
|
||||||
|
attestation = load_json(path, f"{gate['id']} evidence")
|
||||||
|
if not isinstance(attestation, dict) or set(attestation) != {
|
||||||
|
"schemaVersion",
|
||||||
|
"kind",
|
||||||
|
"gateId",
|
||||||
|
"candidateCommit",
|
||||||
|
"result",
|
||||||
|
"performedAtUtc",
|
||||||
|
"artifactDigest",
|
||||||
|
"evidenceLocation",
|
||||||
|
"reviewerRole",
|
||||||
|
}:
|
||||||
|
raise InvalidRecord(f"{gate['id']} requires a complete external-gate attestation")
|
||||||
|
reject_sensitive(attestation, f"external evidence {gate['id']}")
|
||||||
|
if attestation["schemaVersion"] != 1 \
|
||||||
|
or attestation["kind"] != "rendezvous-external-gate-attestation" \
|
||||||
|
or attestation["gateId"] != gate["id"] \
|
||||||
|
or attestation["candidateCommit"] != record["evaluatedCommit"] \
|
||||||
|
or attestation["result"] != "pass" \
|
||||||
|
or not isinstance(attestation["artifactDigest"], str) \
|
||||||
|
or not DIGEST.fullmatch(attestation["artifactDigest"]) \
|
||||||
|
or attestation["evidenceLocation"] not in {
|
||||||
|
"protected-operations-record",
|
||||||
|
"public-release-record",
|
||||||
|
} \
|
||||||
|
or attestation["reviewerRole"] not in {
|
||||||
|
"release-operator",
|
||||||
|
"network-operator",
|
||||||
|
"security-operator",
|
||||||
|
"independent-operator",
|
||||||
|
}:
|
||||||
|
raise InvalidRecord(f"{gate['id']} external attestation does not match the candidate gate")
|
||||||
|
try:
|
||||||
|
performed = datetime.fromisoformat(attestation["performedAtUtc"].replace("Z", "+00:00"))
|
||||||
|
except (AttributeError, ValueError) as error:
|
||||||
|
raise InvalidRecord(f"{gate['id']} has an invalid performedAtUtc") from error
|
||||||
|
if performed.tzinfo is None or performed.utcoffset() != timedelta(0):
|
||||||
|
raise InvalidRecord(f"{gate['id']} performedAtUtc must be UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def validate(record: Any, repository_root: pathlib.Path) -> tuple[bool, list[str]]:
|
||||||
|
if not isinstance(record, dict) or set(record) != {
|
||||||
|
"schemaVersion",
|
||||||
|
"kind",
|
||||||
|
"evaluatedCommit",
|
||||||
|
"decision",
|
||||||
|
"localGates",
|
||||||
|
"externalGates",
|
||||||
|
}:
|
||||||
|
raise InvalidRecord("The top-level readiness record shape is invalid")
|
||||||
|
if record["schemaVersion"] != 1 or record["kind"] != "rendezvous-production-readiness":
|
||||||
|
raise InvalidRecord("The readiness schema identity is invalid")
|
||||||
|
if not isinstance(record["evaluatedCommit"], str) or not COMMIT.fullmatch(record["evaluatedCommit"]):
|
||||||
|
raise InvalidRecord("evaluatedCommit must be a full lowercase Git commit")
|
||||||
|
reject_sensitive(record)
|
||||||
|
local_gates = validate_gate_set(
|
||||||
|
record["localGates"], LOCAL_GATES, "$.localGates", repository_root
|
||||||
|
)
|
||||||
|
external_gates = validate_gate_set(
|
||||||
|
record["externalGates"], EXTERNAL_GATES, "$.externalGates", repository_root
|
||||||
|
)
|
||||||
|
validate_local_evidence(record, local_gates, repository_root)
|
||||||
|
validate_external_attestations(record, external_gates, repository_root)
|
||||||
|
gates = local_gates + external_gates
|
||||||
|
blockers = sorted(gate["id"] for gate in gates if gate["status"] != "pass")
|
||||||
|
ready = not blockers
|
||||||
|
expected_decision = "ready" if ready else "not-ready"
|
||||||
|
if record["decision"] != expected_decision:
|
||||||
|
raise InvalidRecord(
|
||||||
|
f"decision must be {expected_decision!r} for the recorded gate statuses"
|
||||||
|
)
|
||||||
|
return ready, blockers
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("usage: check_production_readiness.py RECORD", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as source:
|
||||||
|
record = json.load(source)
|
||||||
|
ready, blockers = validate(record, pathlib.Path(__file__).resolve().parent.parent)
|
||||||
|
except (OSError, json.JSONDecodeError, InvalidRecord) as error:
|
||||||
|
print(f"INVALID: {error}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not ready:
|
||||||
|
print(f"NOT READY: {len(blockers)} required gate(s) are not passing.")
|
||||||
|
for blocker in blockers:
|
||||||
|
print(f"- {blocker}")
|
||||||
|
return 3
|
||||||
|
print("READY: every required v1 production gate is recorded as passing.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"consumers": [
|
||||||
|
{
|
||||||
|
"name": "SpaceGame",
|
||||||
|
"repository": "https://git.finalfactory.de/Kyuubi/SpaceGame.git",
|
||||||
|
"revision": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
|
||||||
|
"project": "SpaceGame.csproj"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Unscouted",
|
||||||
|
"repository": "https://git.finalfactory.de/HeiKyu/Unscouted.git",
|
||||||
|
"revision": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
|
||||||
|
"project": "Net.Core/Net.Core.csproj"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||||
|
FROM python:3.12.11-slim-bookworm@sha256:c00fc7b44d844b6da22861ec24af43968a5200eac4ec607b4725d585165d6b49 AS release-python
|
||||||
|
FROM ghcr.io/jqlang/jq:1.8.1@sha256:95de8f005ca027686a1ca3b0853e2bb219062438015862816159f3f25a4d4230 AS release-jq
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS release-builder
|
||||||
|
COPY --from=release-python /usr/local/ /usr/local/
|
||||||
|
COPY --from=release-jq /jq /usr/local/bin/jq
|
||||||
|
RUN dotnet --version \
|
||||||
|
&& python3 --version \
|
||||||
|
&& git --version \
|
||||||
|
&& tar --version \
|
||||||
|
&& gzip --version \
|
||||||
|
&& jq --version
|
||||||
|
WORKDIR /source
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"packageRegistry": "https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json",
|
||||||
|
"containerRepository": "git.finalfactory.de/heikyu/rendezvous",
|
||||||
|
"allowedLicenseExpressions": [
|
||||||
|
"Apache-2.0",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"dependencyLicenseOverrides": {
|
||||||
|
"xunit.abstractions/2.0.3": {
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"reason": "Legacy package predates NuGet SPDX metadata; reviewed against the xUnit.net Apache-2.0 license."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publishedPackages": [
|
||||||
|
"FinalFactory.Rendezvous.Client",
|
||||||
|
"FinalFactory.Rendezvous.Contracts"
|
||||||
|
],
|
||||||
|
"forbiddenArtifactNameFragments": [
|
||||||
|
"credential",
|
||||||
|
"password",
|
||||||
|
"private-key",
|
||||||
|
"signing-key"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,823 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_PACKAGE_PREFIX = "finalfactory.rendezvous."
|
||||||
|
CORE_PROPERTIES_PATH = (
|
||||||
|
"package/services/metadata/core-properties/core-properties.psmdcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
raise SystemExit(message)
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: pathlib.Path):
|
||||||
|
with path.open(encoding="utf-8") as stream:
|
||||||
|
return json.load(stream)
|
||||||
|
|
||||||
|
|
||||||
|
def dependency_inventory(root: pathlib.Path):
|
||||||
|
dependencies = {}
|
||||||
|
for lock_path in sorted(root.glob("**/packages.lock.json")):
|
||||||
|
if any(part in {"bin", "obj", "artifacts"} for part in lock_path.parts):
|
||||||
|
continue
|
||||||
|
lock = load_json(lock_path)
|
||||||
|
for framework in lock.get("dependencies", {}).values():
|
||||||
|
for package_id, details in framework.items():
|
||||||
|
resolved = details.get("resolved")
|
||||||
|
if not resolved or package_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
|
||||||
|
continue
|
||||||
|
key = package_id.lower()
|
||||||
|
previous = dependencies.get(key)
|
||||||
|
if previous is not None and previous[1] != resolved:
|
||||||
|
fail(
|
||||||
|
f"Dependency {package_id} resolves to both {previous[1]} and {resolved}."
|
||||||
|
)
|
||||||
|
dependencies[key] = (package_id, resolved)
|
||||||
|
return [dependencies[key] for key in sorted(dependencies)]
|
||||||
|
|
||||||
|
|
||||||
|
def package_license(package_id: str, version: str, overrides):
|
||||||
|
package_root = pathlib.Path(
|
||||||
|
os.environ.get("NUGET_PACKAGES", pathlib.Path.home() / ".nuget" / "packages")
|
||||||
|
)
|
||||||
|
version_dir = package_root / package_id.lower() / version
|
||||||
|
nuspecs = list(version_dir.glob("*.nuspec"))
|
||||||
|
if len(nuspecs) != 1:
|
||||||
|
fail(f"Expected one restored nuspec for {package_id} {version} in {version_dir}.")
|
||||||
|
root = ET.parse(nuspecs[0]).getroot()
|
||||||
|
license_element = root.find(".//{*}license")
|
||||||
|
if license_element is None or license_element.get("type") != "expression":
|
||||||
|
override = overrides.get(f"{package_id.lower()}/{version}")
|
||||||
|
if override is None:
|
||||||
|
fail(f"{package_id} {version} does not declare an SPDX license expression.")
|
||||||
|
return override["license"]
|
||||||
|
expression = (license_element.text or "").strip()
|
||||||
|
if not expression:
|
||||||
|
fail(f"{package_id} {version} has an empty license expression.")
|
||||||
|
return expression
|
||||||
|
|
||||||
|
|
||||||
|
def command_policy(args) -> None:
|
||||||
|
root = pathlib.Path(args.root).resolve()
|
||||||
|
policy = load_json(root / "eng" / "release-policy.json")
|
||||||
|
allowed = set(policy["allowedLicenseExpressions"])
|
||||||
|
inventory = dependency_inventory(root)
|
||||||
|
observed = []
|
||||||
|
for package_id, version in inventory:
|
||||||
|
expression = package_license(
|
||||||
|
package_id, version, policy.get("dependencyLicenseOverrides", {})
|
||||||
|
)
|
||||||
|
if expression not in allowed:
|
||||||
|
fail(
|
||||||
|
f"Dependency {package_id} {version} uses unapproved license {expression}."
|
||||||
|
)
|
||||||
|
observed.append({"id": package_id, "version": version, "license": expression})
|
||||||
|
lite_net_lib = [item for item in observed if item["id"].lower() == "litenetlib"]
|
||||||
|
if lite_net_lib != [{"id": "LiteNetLib", "version": "2.1.4", "license": "MIT"}]:
|
||||||
|
fail(f"LiteNetLib must resolve exactly to the reviewed 2.1.4 release: {lite_net_lib}")
|
||||||
|
print(json.dumps({"dependencies": observed}, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def command_audit(args) -> None:
|
||||||
|
document = load_json(pathlib.Path(args.input))
|
||||||
|
findings = []
|
||||||
|
|
||||||
|
def visit(value):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if value.get("vulnerabilities"):
|
||||||
|
findings.append(value)
|
||||||
|
for child in value.values():
|
||||||
|
visit(child)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for child in value:
|
||||||
|
visit(child)
|
||||||
|
|
||||||
|
visit(document)
|
||||||
|
if findings:
|
||||||
|
fail(f"Locked dependency graph contains known vulnerabilities: {findings}")
|
||||||
|
print("Locked dependency graph has no reported vulnerabilities.")
|
||||||
|
|
||||||
|
|
||||||
|
def release_dependency_graph(root: pathlib.Path, server_deps: pathlib.Path):
|
||||||
|
dependencies = {}
|
||||||
|
edges = set()
|
||||||
|
server_document = load_json(server_deps)
|
||||||
|
for package_key, details in server_document.get("libraries", {}).items():
|
||||||
|
if details.get("type") != "package":
|
||||||
|
continue
|
||||||
|
package_id, version = package_key.rsplit("/", 1)
|
||||||
|
dependencies[package_id.lower()] = (package_id, version)
|
||||||
|
server_target = next(iter(server_document.get("targets", {}).values()), {})
|
||||||
|
for source_key, details in server_target.items():
|
||||||
|
source_id = source_key.rsplit("/", 1)[0]
|
||||||
|
source = (
|
||||||
|
"SPDXRef-Server"
|
||||||
|
if source_id == "FinalFactory.Rendezvous.Server"
|
||||||
|
else source_id.lower()
|
||||||
|
)
|
||||||
|
for dependency_id in details.get("dependencies", {}):
|
||||||
|
target = {
|
||||||
|
"FinalFactory.Rendezvous.Contracts": "SPDXRef-Contracts",
|
||||||
|
"FinalFactory.Rendezvous.Client": "SPDXRef-Client",
|
||||||
|
}.get(dependency_id, dependency_id.lower())
|
||||||
|
edges.add((source, target))
|
||||||
|
|
||||||
|
lock_roots = (
|
||||||
|
("SPDXRef-Client", root / "src/FinalFactory.Rendezvous.Client/packages.lock.json"),
|
||||||
|
(
|
||||||
|
"SPDXRef-Contracts",
|
||||||
|
root / "src/FinalFactory.Rendezvous.Contracts/packages.lock.json",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for root_id, lock_path in lock_roots:
|
||||||
|
lock = load_json(lock_path)
|
||||||
|
for framework in lock.get("dependencies", {}).values():
|
||||||
|
for package_id, details in framework.items():
|
||||||
|
resolved = details.get("resolved")
|
||||||
|
if resolved and not package_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
|
||||||
|
dependencies[package_id.lower()] = (package_id, resolved)
|
||||||
|
if details.get("type") == "Direct":
|
||||||
|
edges.add((root_id, package_id.lower()))
|
||||||
|
source = package_id.lower()
|
||||||
|
for dependency_id in details.get("dependencies", {}):
|
||||||
|
if dependency_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
|
||||||
|
continue
|
||||||
|
edges.add((source, dependency_id.lower()))
|
||||||
|
edges.add(("SPDXRef-Client", "SPDXRef-Contracts"))
|
||||||
|
inventory = [dependencies[key] for key in sorted(dependencies)]
|
||||||
|
return inventory, edges
|
||||||
|
|
||||||
|
|
||||||
|
def command_sbom(args) -> None:
|
||||||
|
root = pathlib.Path(args.root).resolve()
|
||||||
|
policy = load_json(root / "eng" / "release-policy.json")
|
||||||
|
overrides = policy.get("dependencyLicenseOverrides", {})
|
||||||
|
packages = [
|
||||||
|
{
|
||||||
|
"SPDXID": "SPDXRef-Rendezvous",
|
||||||
|
"name": "FinalFactory.Rendezvous",
|
||||||
|
"versionInfo": args.version,
|
||||||
|
"downloadLocation": "NOASSERTION",
|
||||||
|
"filesAnalyzed": False,
|
||||||
|
"licenseConcluded": "NOASSERTION",
|
||||||
|
"licenseDeclared": "NOASSERTION",
|
||||||
|
"copyrightText": "NOASSERTION",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
internal_packages = [
|
||||||
|
("SPDXRef-Server", "FinalFactory.Rendezvous.Server"),
|
||||||
|
("SPDXRef-Client", "FinalFactory.Rendezvous.Client"),
|
||||||
|
("SPDXRef-Contracts", "FinalFactory.Rendezvous.Contracts"),
|
||||||
|
]
|
||||||
|
for spdx_id, package_id in internal_packages:
|
||||||
|
packages.append(
|
||||||
|
{
|
||||||
|
"SPDXID": spdx_id,
|
||||||
|
"name": package_id,
|
||||||
|
"versionInfo": args.version,
|
||||||
|
"downloadLocation": "NOASSERTION",
|
||||||
|
"filesAnalyzed": False,
|
||||||
|
"licenseConcluded": "NOASSERTION",
|
||||||
|
"licenseDeclared": "NOASSERTION",
|
||||||
|
"copyrightText": "NOASSERTION",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
inventory, dependency_edges = release_dependency_graph(
|
||||||
|
root, pathlib.Path(args.server_deps)
|
||||||
|
)
|
||||||
|
dependency_ids = {}
|
||||||
|
for index, (package_id, version) in enumerate(
|
||||||
|
inventory, start=1
|
||||||
|
):
|
||||||
|
expression = package_license(package_id, version, overrides)
|
||||||
|
spdx_id = f"SPDXRef-Package-{index}"
|
||||||
|
dependency_ids[package_id.lower()] = spdx_id
|
||||||
|
packages.append(
|
||||||
|
{
|
||||||
|
"SPDXID": spdx_id,
|
||||||
|
"name": package_id,
|
||||||
|
"versionInfo": version,
|
||||||
|
"downloadLocation": "NOASSERTION",
|
||||||
|
"filesAnalyzed": False,
|
||||||
|
"licenseConcluded": expression,
|
||||||
|
"licenseDeclared": expression,
|
||||||
|
"copyrightText": "NOASSERTION",
|
||||||
|
"externalRefs": [
|
||||||
|
{
|
||||||
|
"referenceCategory": "PACKAGE-MANAGER",
|
||||||
|
"referenceType": "purl",
|
||||||
|
"referenceLocator": f"pkg:nuget/{package_id}@{version}",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
created = dt.datetime.fromtimestamp(
|
||||||
|
int(args.source_date_epoch), dt.timezone.utc
|
||||||
|
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
document = {
|
||||||
|
"spdxVersion": "SPDX-2.3",
|
||||||
|
"dataLicense": "CC0-1.0",
|
||||||
|
"SPDXID": "SPDXRef-DOCUMENT",
|
||||||
|
"name": f"FinalFactory.Rendezvous-{args.version}",
|
||||||
|
"documentNamespace": (
|
||||||
|
"https://git.finalfactory.de/HeiKyu/Rendezvous/sbom/"
|
||||||
|
f"{args.version}/{args.commit}"
|
||||||
|
),
|
||||||
|
"creationInfo": {
|
||||||
|
"created": created,
|
||||||
|
"creators": ["Tool: eng/release_artifacts.py"],
|
||||||
|
},
|
||||||
|
"documentDescribes": ["SPDXRef-Rendezvous"],
|
||||||
|
"packages": packages,
|
||||||
|
"relationships": [
|
||||||
|
{
|
||||||
|
"spdxElementId": "SPDXRef-Rendezvous",
|
||||||
|
"relationshipType": "CONTAINS",
|
||||||
|
"relatedSpdxElement": spdx_id,
|
||||||
|
}
|
||||||
|
for spdx_id, _ in internal_packages
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
{
|
||||||
|
"spdxElementId": dependency_ids.get(source, source),
|
||||||
|
"relationshipType": "DEPENDS_ON",
|
||||||
|
"relatedSpdxElement": dependency_ids.get(target, target),
|
||||||
|
}
|
||||||
|
for source, target in sorted(dependency_edges)
|
||||||
|
if source in dependency_ids or source.startswith("SPDXRef-")
|
||||||
|
if target in dependency_ids or target.startswith("SPDXRef-")
|
||||||
|
],
|
||||||
|
}
|
||||||
|
output = pathlib.Path(args.output)
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def nuspec_metadata(archive: pathlib.Path):
|
||||||
|
with zipfile.ZipFile(archive) as package:
|
||||||
|
names = package.namelist()
|
||||||
|
nuspecs = [name for name in names if name.endswith(".nuspec")]
|
||||||
|
if len(nuspecs) != 1:
|
||||||
|
fail(f"{archive.name} must contain exactly one nuspec.")
|
||||||
|
root = ET.fromstring(package.read(nuspecs[0]))
|
||||||
|
metadata = root.find(".//{*}metadata")
|
||||||
|
if metadata is None:
|
||||||
|
fail(f"{archive.name} has no package metadata.")
|
||||||
|
values = {
|
||||||
|
child.tag.rsplit("}", 1)[-1]: (child.text or "").strip()
|
||||||
|
for child in metadata
|
||||||
|
if len(child) == 0
|
||||||
|
}
|
||||||
|
dependencies = {
|
||||||
|
item.get("id"): item.get("version")
|
||||||
|
for item in metadata.findall(".//{*}dependency")
|
||||||
|
}
|
||||||
|
repository = metadata.find("./{*}repository")
|
||||||
|
repository_attributes = {} if repository is None else dict(repository.attrib)
|
||||||
|
return values, dependencies, repository_attributes
|
||||||
|
|
||||||
|
|
||||||
|
def verify_checksum_file(release_dir: pathlib.Path, excluded=()) -> None:
|
||||||
|
checksum_path = release_dir / "checksums.sha256"
|
||||||
|
lines = checksum_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
if not lines:
|
||||||
|
fail("checksums.sha256 is empty.")
|
||||||
|
referenced = set()
|
||||||
|
for line in lines:
|
||||||
|
digest, marker, relative = line.partition(" ")
|
||||||
|
if marker != " " or not re.fullmatch(r"[0-9a-f]{64}", digest):
|
||||||
|
fail(f"Malformed checksum line: {line}")
|
||||||
|
target = release_dir / relative
|
||||||
|
if not target.is_file():
|
||||||
|
fail(f"Checksum references missing artifact: {relative}")
|
||||||
|
actual = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||||
|
if actual != digest:
|
||||||
|
fail(f"Checksum mismatch for {relative}.")
|
||||||
|
referenced.add(relative)
|
||||||
|
expected = {
|
||||||
|
path.name
|
||||||
|
for path in release_dir.iterdir()
|
||||||
|
if path.is_file()
|
||||||
|
and path.name != "checksums.sha256"
|
||||||
|
and path.name not in excluded
|
||||||
|
}
|
||||||
|
if referenced != expected:
|
||||||
|
fail(
|
||||||
|
"Checksum manifest coverage differs. "
|
||||||
|
f"Missing={expected - referenced}; extra={referenced - expected}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def command_verify(args) -> None:
|
||||||
|
release_dir = pathlib.Path(args.release_dir).resolve()
|
||||||
|
version = args.version
|
||||||
|
expected = {
|
||||||
|
f"FinalFactory.Rendezvous.Client.{version}.nupkg",
|
||||||
|
f"FinalFactory.Rendezvous.Client.{version}.snupkg",
|
||||||
|
f"FinalFactory.Rendezvous.Contracts.{version}.nupkg",
|
||||||
|
f"FinalFactory.Rendezvous.Contracts.{version}.snupkg",
|
||||||
|
f"FinalFactory.Rendezvous.Server.{version}.linux-x64.tar.gz",
|
||||||
|
f"FinalFactory.Rendezvous.{version}.spdx.json",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"checksums.sha256",
|
||||||
|
"release-provenance.json",
|
||||||
|
}
|
||||||
|
checksum_exclusions = set()
|
||||||
|
if args.phase in {"publish-ready", "signing-ready", "published"}:
|
||||||
|
expected.add(f"FinalFactory.Rendezvous.Container.{version}.spdx.json")
|
||||||
|
if args.phase in {"signing-ready", "published"}:
|
||||||
|
expected.update({"container-digest.txt", "cosign.pub"})
|
||||||
|
if args.phase == "published":
|
||||||
|
expected.add("checksums.sha256.bundle")
|
||||||
|
checksum_exclusions.add("checksums.sha256.bundle")
|
||||||
|
actual = {path.name for path in release_dir.iterdir() if path.is_file()}
|
||||||
|
if actual != expected:
|
||||||
|
fail(f"Release artifact set differs. Missing={expected - actual}; extra={actual - expected}")
|
||||||
|
|
||||||
|
if args.phase in {"publish-ready", "signing-ready", "published"}:
|
||||||
|
container_sbom = load_json(
|
||||||
|
release_dir / f"FinalFactory.Rendezvous.Container.{version}.spdx.json"
|
||||||
|
)
|
||||||
|
if container_sbom.get("spdxVersion") != "SPDX-2.3":
|
||||||
|
fail("Container inventory must be an SPDX 2.3 document.")
|
||||||
|
if not container_sbom.get("packages"):
|
||||||
|
fail("Container SPDX inventory contains no packages.")
|
||||||
|
|
||||||
|
policy = load_json(pathlib.Path(args.root) / "eng" / "release-policy.json")
|
||||||
|
forbidden = tuple(fragment.lower() for fragment in policy["forbiddenArtifactNameFragments"])
|
||||||
|
for artifact in actual:
|
||||||
|
if artifact != "release-provenance.json" and any(fragment in artifact.lower() for fragment in forbidden):
|
||||||
|
fail(f"Forbidden secret-like artifact name: {artifact}")
|
||||||
|
|
||||||
|
release_sbom = load_json(
|
||||||
|
release_dir / f"FinalFactory.Rendezvous.{version}.spdx.json"
|
||||||
|
)
|
||||||
|
package_ids = {
|
||||||
|
package.get("name"): package.get("SPDXID")
|
||||||
|
for package in release_sbom.get("packages", [])
|
||||||
|
}
|
||||||
|
relationships = {
|
||||||
|
(
|
||||||
|
relationship.get("spdxElementId"),
|
||||||
|
relationship.get("relationshipType"),
|
||||||
|
relationship.get("relatedSpdxElement"),
|
||||||
|
)
|
||||||
|
for relationship in release_sbom.get("relationships", [])
|
||||||
|
}
|
||||||
|
expected_component_edges = {
|
||||||
|
("SPDXRef-Server", "SPDXRef-Contracts"),
|
||||||
|
("SPDXRef-Server", package_ids.get("LiteNetLib")),
|
||||||
|
("SPDXRef-Server", package_ids.get("Microsoft.AspNetCore.OpenApi")),
|
||||||
|
("SPDXRef-Client", "SPDXRef-Contracts"),
|
||||||
|
("SPDXRef-Client", package_ids.get("LiteNetLib")),
|
||||||
|
("SPDXRef-Contracts", package_ids.get("System.Text.Json")),
|
||||||
|
}
|
||||||
|
for source, target in expected_component_edges:
|
||||||
|
if not target or (source, "DEPENDS_ON", target) not in relationships:
|
||||||
|
fail(f"Release SPDX inventory is missing component edge {source} -> {target}.")
|
||||||
|
bcl_id = package_ids.get("Microsoft.Bcl.AsyncInterfaces")
|
||||||
|
if ("SPDXRef-Server", "DEPENDS_ON", bcl_id) in relationships:
|
||||||
|
fail("Release SPDX inventory incorrectly flattens transitive dependencies onto Server.")
|
||||||
|
|
||||||
|
for package_id in policy["publishedPackages"]:
|
||||||
|
package = release_dir / f"{package_id}.{version}.nupkg"
|
||||||
|
metadata, dependencies, repository = nuspec_metadata(package)
|
||||||
|
if metadata.get("id") != package_id or metadata.get("version") != version:
|
||||||
|
fail(f"{package.name} identity/version metadata is incorrect.")
|
||||||
|
if metadata.get("projectUrl") != "https://git.finalfactory.de/HeiKyu/Rendezvous":
|
||||||
|
fail(f"{package.name} has an incorrect project URL.")
|
||||||
|
if repository.get("url") != "https://git.finalfactory.de/HeiKyu/Rendezvous":
|
||||||
|
fail(f"{package.name} has an incorrect repository URL.")
|
||||||
|
if repository.get("commit") != provenance_commit(release_dir):
|
||||||
|
fail(f"{package.name} does not identify the release commit.")
|
||||||
|
symbol_package = release_dir / f"{package_id}.{version}.snupkg"
|
||||||
|
with zipfile.ZipFile(symbol_package) as symbols:
|
||||||
|
if not any(name.endswith(".pdb") for name in symbols.namelist()):
|
||||||
|
fail(f"{symbol_package.name} contains no portable PDB.")
|
||||||
|
with zipfile.ZipFile(package) as archive:
|
||||||
|
names = set(archive.namelist())
|
||||||
|
required_entries = {
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
f"lib/netstandard2.1/{package_id}.dll",
|
||||||
|
}
|
||||||
|
if not required_entries <= names:
|
||||||
|
fail(
|
||||||
|
f"{package.name} is missing required package content: "
|
||||||
|
f"{required_entries - names}"
|
||||||
|
)
|
||||||
|
forbidden_entries = [
|
||||||
|
name
|
||||||
|
for name in names
|
||||||
|
if any(
|
||||||
|
fragment in name.lower()
|
||||||
|
for fragment in (
|
||||||
|
"appsettings",
|
||||||
|
"launchsettings",
|
||||||
|
".env",
|
||||||
|
"credential",
|
||||||
|
"signing-key",
|
||||||
|
"private-key",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if forbidden_entries:
|
||||||
|
fail(
|
||||||
|
f"{package.name} contains deployable configuration or secrets: "
|
||||||
|
f"{forbidden_entries}"
|
||||||
|
)
|
||||||
|
if package_id.endswith(".Client"):
|
||||||
|
if dependencies.get("LiteNetLib") != "[2.1.4]":
|
||||||
|
fail(f"Client package must pin LiteNetLib exactly to 2.1.4: {dependencies}")
|
||||||
|
contracts_range = dependencies.get("FinalFactory.Rendezvous.Contracts", "")
|
||||||
|
if contracts_range != f"[{version}]":
|
||||||
|
fail(f"Client package does not depend on the matching Contracts version.")
|
||||||
|
|
||||||
|
provenance = load_json(release_dir / "release-provenance.json")
|
||||||
|
if provenance.get("version") != version:
|
||||||
|
fail("Release provenance does not identify the requested version.")
|
||||||
|
if provenance.get("treeState") != "clean" and not args.allow_dirty:
|
||||||
|
fail("Release provenance must identify a clean tree.")
|
||||||
|
container = provenance.get("containerImage", "")
|
||||||
|
if container.endswith(":latest") or ":latest@" in container or f":{version}" not in container:
|
||||||
|
fail(f"Container reference is mutable or not versioned: {container}")
|
||||||
|
if provenance.get("containerPlatform") != "linux/amd64":
|
||||||
|
fail("Release provenance must pin the linux/amd64 container platform.")
|
||||||
|
for field in ("containerBaseDigests", "releaseBuilderBaseDigests"):
|
||||||
|
images = provenance.get(field, [])
|
||||||
|
if not images or any("@sha256:" not in image or image.endswith(":latest") for image in images):
|
||||||
|
fail(f"Release provenance contains an unpinned build image in {field}: {images}")
|
||||||
|
build_tools = provenance.get("buildTools", [])
|
||||||
|
required_tool_prefixes = ("dotnet", "python=", "tar=", "gzip=", "jq=")
|
||||||
|
observed_tools = [f"dotnet={provenance.get('dotnetSdk', '')}", *build_tools]
|
||||||
|
for prefix in required_tool_prefixes:
|
||||||
|
if not any(tool.startswith(prefix) for tool in observed_tools):
|
||||||
|
fail(f"Release provenance is missing an artifact tool version: {prefix}")
|
||||||
|
if args.phase in {"publish-ready", "signing-ready", "published"}:
|
||||||
|
if not re.fullmatch(
|
||||||
|
r"sha256:[0-9a-f]{64}", provenance.get("containerImageId", "")
|
||||||
|
):
|
||||||
|
fail("Release provenance is missing the verified local container image ID.")
|
||||||
|
expected_namespace = (
|
||||||
|
"https://git.finalfactory.de/HeiKyu/Rendezvous/container-sbom/"
|
||||||
|
f"{version}/{provenance_commit(release_dir)}"
|
||||||
|
)
|
||||||
|
if container_sbom.get("documentNamespace") != expected_namespace:
|
||||||
|
fail("Container SPDX inventory does not identify the release commit.")
|
||||||
|
expected_created = dt.datetime.fromtimestamp(
|
||||||
|
int(provenance.get("sourceDateEpoch", 0)), dt.timezone.utc
|
||||||
|
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
if container_sbom.get("creationInfo", {}).get("created") != expected_created:
|
||||||
|
fail("Container SPDX timestamp is not normalized to the source epoch.")
|
||||||
|
if args.phase in {"signing-ready", "published"}:
|
||||||
|
digest = (release_dir / "container-digest.txt").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
).strip()
|
||||||
|
if not re.fullmatch(
|
||||||
|
r"git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}", digest
|
||||||
|
):
|
||||||
|
fail(f"Published container digest is invalid: {digest}")
|
||||||
|
if provenance.get("containerDigest") != digest:
|
||||||
|
fail("Published provenance and container digest file differ.")
|
||||||
|
for prefix in ("docker-buildx=", "buildkit="):
|
||||||
|
if not any(tool.startswith(prefix) for tool in build_tools):
|
||||||
|
fail(f"Published provenance is missing container tool version: {prefix}")
|
||||||
|
verify_checksum_file(release_dir, checksum_exclusions)
|
||||||
|
print(f"Verified {args.phase} release artifact set for {version}.")
|
||||||
|
|
||||||
|
|
||||||
|
def provenance_commit(release_dir: pathlib.Path) -> str:
|
||||||
|
provenance = load_json(release_dir / "release-provenance.json")
|
||||||
|
commit = provenance.get("commit", "")
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||||
|
fail("Release provenance must contain a full Git commit SHA.")
|
||||||
|
return commit
|
||||||
|
|
||||||
|
|
||||||
|
def command_consumer(args) -> None:
|
||||||
|
assets = load_json(pathlib.Path(args.assets))
|
||||||
|
libraries = assets.get("libraries", {})
|
||||||
|
required = {
|
||||||
|
f"FinalFactory.Rendezvous.Client/{args.version}",
|
||||||
|
f"FinalFactory.Rendezvous.Contracts/{args.version}",
|
||||||
|
"LiteNetLib/2.1.4",
|
||||||
|
}
|
||||||
|
missing = required - set(libraries)
|
||||||
|
if missing:
|
||||||
|
fail(f"Consumer restore is missing exact release dependencies: {missing}")
|
||||||
|
forbidden = [name for name in libraries if name.lower().startswith("litenetlib/1.")]
|
||||||
|
if forbidden:
|
||||||
|
fail(f"Consumer resolved forbidden LiteNetLib 1.x assets: {forbidden}")
|
||||||
|
|
||||||
|
|
||||||
|
def command_consumer_config(args) -> None:
|
||||||
|
local_source = escape(str(pathlib.Path(args.local_source).resolve()))
|
||||||
|
configuration = f'''<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="rendezvous-candidate" value="{local_source}" />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||||
|
</packageSources>
|
||||||
|
<packageSourceMapping>
|
||||||
|
<packageSource key="rendezvous-candidate">
|
||||||
|
<package pattern="FinalFactory.Rendezvous.*" />
|
||||||
|
</packageSource>
|
||||||
|
<packageSource key="nuget.org">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
|
</packageSourceMapping>
|
||||||
|
</configuration>
|
||||||
|
'''
|
||||||
|
pathlib.Path(args.output).write_text(configuration, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def command_source_link(args) -> None:
|
||||||
|
document = load_json(pathlib.Path(args.file))
|
||||||
|
mappings = document.get("documents", {})
|
||||||
|
expected = (
|
||||||
|
"https://git.finalfactory.de/HeiKyu/Rendezvous/raw/commit/"
|
||||||
|
f"{args.commit}/"
|
||||||
|
)
|
||||||
|
if not mappings or any(not value.startswith(expected) for value in mappings.values()):
|
||||||
|
fail(f"SourceLink mappings do not identify commit {args.commit}: {mappings}")
|
||||||
|
|
||||||
|
|
||||||
|
def command_normalize_package(args) -> None:
|
||||||
|
package_path = pathlib.Path(args.package).resolve()
|
||||||
|
if package_path.suffix not in {".nupkg", ".snupkg"}:
|
||||||
|
fail(f"Unsupported NuGet archive extension: {package_path.name}")
|
||||||
|
timestamp = dt.datetime.fromtimestamp(
|
||||||
|
int(args.source_date_epoch), dt.timezone.utc
|
||||||
|
)
|
||||||
|
zip_timestamp = (
|
||||||
|
max(timestamp.year, 1980),
|
||||||
|
timestamp.month,
|
||||||
|
timestamp.day,
|
||||||
|
timestamp.hour,
|
||||||
|
timestamp.minute,
|
||||||
|
timestamp.second - (timestamp.second % 2),
|
||||||
|
)
|
||||||
|
with zipfile.ZipFile(package_path) as source:
|
||||||
|
entries = {name: source.read(name) for name in source.namelist()}
|
||||||
|
if ".signature.p7s" in entries:
|
||||||
|
fail(f"Refusing to rewrite signed package: {package_path.name}")
|
||||||
|
core_paths = [
|
||||||
|
name
|
||||||
|
for name in entries
|
||||||
|
if name.startswith("package/services/metadata/core-properties/")
|
||||||
|
and name.endswith(".psmdcp")
|
||||||
|
]
|
||||||
|
if len(core_paths) != 1:
|
||||||
|
fail(f"Expected one NuGet core-properties part in {package_path.name}.")
|
||||||
|
entries[CORE_PROPERTIES_PATH] = entries.pop(core_paths[0])
|
||||||
|
|
||||||
|
nuspec_paths = [name for name in entries if name.endswith(".nuspec")]
|
||||||
|
if len(nuspec_paths) != 1:
|
||||||
|
fail(f"Expected one nuspec in {package_path.name}.")
|
||||||
|
nuspec = ET.fromstring(entries[nuspec_paths[0]])
|
||||||
|
nuspec_namespace = nuspec.tag.partition("}")[0].removeprefix("{")
|
||||||
|
if nuspec_namespace:
|
||||||
|
ET.register_namespace("", nuspec_namespace)
|
||||||
|
package_id = nuspec.findtext(".//{*}id")
|
||||||
|
if package_id == "FinalFactory.Rendezvous.Client":
|
||||||
|
contracts = nuspec.find(
|
||||||
|
".//{*}dependency[@id='FinalFactory.Rendezvous.Contracts']"
|
||||||
|
)
|
||||||
|
if contracts is None:
|
||||||
|
fail("Client package has no Contracts dependency to pin.")
|
||||||
|
contracts.set("version", f"[{args.version}]")
|
||||||
|
entries[nuspec_paths[0]] = ET.tostring(
|
||||||
|
nuspec, encoding="utf-8", xml_declaration=True
|
||||||
|
)
|
||||||
|
|
||||||
|
relationships_namespace = (
|
||||||
|
"http://schemas.openxmlformats.org/package/2006/relationships"
|
||||||
|
)
|
||||||
|
ET.register_namespace("", relationships_namespace)
|
||||||
|
relationships = ET.fromstring(entries["_rels/.rels"])
|
||||||
|
for relationship in relationships:
|
||||||
|
relationship_type = relationship.get("Type", "")
|
||||||
|
if relationship_type.endswith("/manifest"):
|
||||||
|
relationship.set("Id", "RManifest")
|
||||||
|
elif relationship_type.endswith("/metadata/core-properties"):
|
||||||
|
relationship.set("Id", "RCoreProperties")
|
||||||
|
relationship.set("Target", f"/{CORE_PROPERTIES_PATH}")
|
||||||
|
entries["_rels/.rels"] = ET.tostring(
|
||||||
|
relationships, encoding="utf-8", xml_declaration=True
|
||||||
|
)
|
||||||
|
|
||||||
|
temporary = package_path.with_suffix(package_path.suffix + ".normalized")
|
||||||
|
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_STORED) as target:
|
||||||
|
for name in sorted(entries):
|
||||||
|
info = zipfile.ZipInfo(name, date_time=zip_timestamp)
|
||||||
|
info.compress_type = zipfile.ZIP_STORED
|
||||||
|
info.create_system = 3
|
||||||
|
info.external_attr = 0o100644 << 16
|
||||||
|
target.writestr(info, entries[name])
|
||||||
|
temporary.replace(package_path)
|
||||||
|
|
||||||
|
|
||||||
|
def command_provenance(args) -> None:
|
||||||
|
versions = ET.parse(pathlib.Path(args.root) / "eng" / "Versions.props")
|
||||||
|
|
||||||
|
def version_property(name: str) -> str:
|
||||||
|
element = versions.find(f".//{name}")
|
||||||
|
if element is None or not element.text:
|
||||||
|
fail(f"Missing central release property: {name}")
|
||||||
|
return element.text.strip()
|
||||||
|
|
||||||
|
provenance = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"version": args.version,
|
||||||
|
"commit": args.commit,
|
||||||
|
"treeState": args.tree_state,
|
||||||
|
"buildConfiguration": "Release",
|
||||||
|
"sourceDateEpoch": int(args.source_date_epoch),
|
||||||
|
"dotnetSdk": args.dotnet_sdk,
|
||||||
|
"buildTools": sorted(args.build_tool),
|
||||||
|
"containerImage": f"git.finalfactory.de/heikyu/rendezvous:{args.version}",
|
||||||
|
"containerPlatform": "linux/amd64",
|
||||||
|
"containerBaseDigests": args.base_digest,
|
||||||
|
"releaseBuilderBaseDigests": args.builder_base,
|
||||||
|
"packages": [
|
||||||
|
f"FinalFactory.Rendezvous.Client/{args.version}",
|
||||||
|
f"FinalFactory.Rendezvous.Contracts/{args.version}",
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimumClientVersion": version_property("MinimumClientVersion"),
|
||||||
|
"maximumClientMajorVersion": int(version_property("MaximumClientMajorVersion")),
|
||||||
|
"httpContractVersions": [int(version_property("HttpContractVersion"))],
|
||||||
|
"udpContractVersions": [int(version_property("UdpContractVersion"))],
|
||||||
|
"connectionTicketFormatVersions": [
|
||||||
|
int(version_property("ConnectionTicketFormatVersion"))
|
||||||
|
],
|
||||||
|
"liteNetLib": version_property("LiteNetLibVersion"),
|
||||||
|
"gameplayProtocol": "exact-per-tenant",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
pathlib.Path(args.output).write_text(
|
||||||
|
json.dumps(provenance, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def command_record_container_build(args) -> None:
|
||||||
|
path = pathlib.Path(args.provenance)
|
||||||
|
provenance = load_json(path)
|
||||||
|
tools = set(provenance.get("buildTools", []))
|
||||||
|
tools.add(f"docker-buildx={args.buildx_version}")
|
||||||
|
tools.add(f"buildkit={args.buildkit_version}")
|
||||||
|
provenance["buildTools"] = sorted(tools)
|
||||||
|
provenance["containerPlatform"] = "linux/amd64"
|
||||||
|
if not re.fullmatch(r"sha256:[0-9a-f]{64}", args.image_id):
|
||||||
|
fail(f"Container image ID is invalid: {args.image_id}")
|
||||||
|
provenance["containerImageId"] = args.image_id
|
||||||
|
path.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def command_record_container_digest(args) -> None:
|
||||||
|
if not re.fullmatch(
|
||||||
|
r"git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}",
|
||||||
|
args.digest,
|
||||||
|
):
|
||||||
|
fail(f"Published container digest is invalid: {args.digest}")
|
||||||
|
release_dir = pathlib.Path(args.release_dir)
|
||||||
|
provenance_path = release_dir / "release-provenance.json"
|
||||||
|
provenance = load_json(provenance_path)
|
||||||
|
provenance["containerDigest"] = args.digest
|
||||||
|
provenance_path.write_text(
|
||||||
|
json.dumps(provenance, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(release_dir / "container-digest.txt").write_text(
|
||||||
|
args.digest + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def command_normalize_container_sbom(args) -> None:
|
||||||
|
path = pathlib.Path(args.file)
|
||||||
|
document = load_json(path)
|
||||||
|
created = dt.datetime.fromtimestamp(
|
||||||
|
int(args.source_date_epoch), dt.timezone.utc
|
||||||
|
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
document["name"] = f"FinalFactory.Rendezvous.Container-{args.version}"
|
||||||
|
document["documentNamespace"] = (
|
||||||
|
"https://git.finalfactory.de/HeiKyu/Rendezvous/container-sbom/"
|
||||||
|
f"{args.version}/{args.commit}"
|
||||||
|
)
|
||||||
|
creation = document.setdefault("creationInfo", {})
|
||||||
|
creation["created"] = created
|
||||||
|
creation["creators"] = ["Tool: Trivy-0.69.3"]
|
||||||
|
if isinstance(document.get("packages"), list):
|
||||||
|
document["packages"] = sorted(
|
||||||
|
document["packages"],
|
||||||
|
key=lambda item: (
|
||||||
|
item.get("SPDXID", ""),
|
||||||
|
item.get("name", ""),
|
||||||
|
item.get("versionInfo", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if isinstance(document.get("relationships"), list):
|
||||||
|
document["relationships"] = sorted(
|
||||||
|
document["relationships"],
|
||||||
|
key=lambda item: (
|
||||||
|
item.get("spdxElementId", ""),
|
||||||
|
item.get("relationshipType", ""),
|
||||||
|
item.get("relatedSpdxElement", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
policy = subparsers.add_parser("policy")
|
||||||
|
policy.add_argument("--root", required=True)
|
||||||
|
policy.set_defaults(handler=command_policy)
|
||||||
|
audit = subparsers.add_parser("audit")
|
||||||
|
audit.add_argument("--input", required=True)
|
||||||
|
audit.set_defaults(handler=command_audit)
|
||||||
|
sbom = subparsers.add_parser("sbom")
|
||||||
|
sbom.add_argument("--root", required=True)
|
||||||
|
sbom.add_argument("--version", required=True)
|
||||||
|
sbom.add_argument("--commit", required=True)
|
||||||
|
sbom.add_argument("--source-date-epoch", required=True)
|
||||||
|
sbom.add_argument("--server-deps", required=True)
|
||||||
|
sbom.add_argument("--output", required=True)
|
||||||
|
sbom.set_defaults(handler=command_sbom)
|
||||||
|
verify = subparsers.add_parser("verify")
|
||||||
|
verify.add_argument("--root", required=True)
|
||||||
|
verify.add_argument("--release-dir", required=True)
|
||||||
|
verify.add_argument("--version", required=True)
|
||||||
|
verify.add_argument("--allow-dirty", action="store_true")
|
||||||
|
verify.add_argument(
|
||||||
|
"--phase",
|
||||||
|
choices=("build", "publish-ready", "signing-ready", "published"),
|
||||||
|
default="build",
|
||||||
|
)
|
||||||
|
verify.set_defaults(handler=command_verify)
|
||||||
|
consumer = subparsers.add_parser("consumer")
|
||||||
|
consumer.add_argument("--assets", required=True)
|
||||||
|
consumer.add_argument("--version", required=True)
|
||||||
|
consumer.set_defaults(handler=command_consumer)
|
||||||
|
consumer_config = subparsers.add_parser("consumer-config")
|
||||||
|
consumer_config.add_argument("--local-source", required=True)
|
||||||
|
consumer_config.add_argument("--output", required=True)
|
||||||
|
consumer_config.set_defaults(handler=command_consumer_config)
|
||||||
|
source_link = subparsers.add_parser("source-link")
|
||||||
|
source_link.add_argument("--file", required=True)
|
||||||
|
source_link.add_argument("--commit", required=True)
|
||||||
|
source_link.set_defaults(handler=command_source_link)
|
||||||
|
normalize = subparsers.add_parser("normalize-package")
|
||||||
|
normalize.add_argument("--package", required=True)
|
||||||
|
normalize.add_argument("--source-date-epoch", required=True)
|
||||||
|
normalize.add_argument("--version", required=True)
|
||||||
|
normalize.set_defaults(handler=command_normalize_package)
|
||||||
|
provenance = subparsers.add_parser("provenance")
|
||||||
|
provenance.add_argument("--root", required=True)
|
||||||
|
provenance.add_argument("--version", required=True)
|
||||||
|
provenance.add_argument("--commit", required=True)
|
||||||
|
provenance.add_argument("--tree-state", required=True)
|
||||||
|
provenance.add_argument("--source-date-epoch", required=True)
|
||||||
|
provenance.add_argument("--dotnet-sdk", required=True)
|
||||||
|
provenance.add_argument("--build-tool", action="append", default=[])
|
||||||
|
provenance.add_argument("--base-digest", action="append", default=[])
|
||||||
|
provenance.add_argument("--builder-base", action="append", default=[])
|
||||||
|
provenance.add_argument("--output", required=True)
|
||||||
|
provenance.set_defaults(handler=command_provenance)
|
||||||
|
record_container = subparsers.add_parser("record-container-build")
|
||||||
|
record_container.add_argument("--provenance", required=True)
|
||||||
|
record_container.add_argument("--buildx-version", required=True)
|
||||||
|
record_container.add_argument("--buildkit-version", required=True)
|
||||||
|
record_container.add_argument("--image-id", required=True)
|
||||||
|
record_container.set_defaults(handler=command_record_container_build)
|
||||||
|
record_digest = subparsers.add_parser("record-container-digest")
|
||||||
|
record_digest.add_argument("--release-dir", required=True)
|
||||||
|
record_digest.add_argument("--digest", required=True)
|
||||||
|
record_digest.set_defaults(handler=command_record_container_digest)
|
||||||
|
normalize_container_sbom = subparsers.add_parser("normalize-container-sbom")
|
||||||
|
normalize_container_sbom.add_argument("--file", required=True)
|
||||||
|
normalize_container_sbom.add_argument("--version", required=True)
|
||||||
|
normalize_container_sbom.add_argument("--commit", required=True)
|
||||||
|
normalize_container_sbom.add_argument("--source-date-epoch", required=True)
|
||||||
|
normalize_container_sbom.set_defaults(handler=command_normalize_container_sbom)
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.handler(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+253
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
version="${1:-}"
|
||||||
|
output="${2:-}"
|
||||||
|
|
||||||
|
property() {
|
||||||
|
sed -n "s:.*<$1>\(.*\)</$1>.*:\1:p" "$root/eng/Versions.props"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "$version" ]]; then
|
||||||
|
version="$(property RendezvousVersion)"
|
||||||
|
fi
|
||||||
|
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+\.)*[0-9A-Za-z-]+)?(\+([0-9A-Za-z-]+\.)*[0-9A-Za-z-]+)?$'
|
||||||
|
if [[ ! "$version" =~ $semver ]]; then
|
||||||
|
echo "Release version is not valid SemVer: $version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
prerelease="${version%%+*}"
|
||||||
|
if [[ "$prerelease" == *-* ]]; then
|
||||||
|
prerelease="${prerelease#*-}"
|
||||||
|
IFS='.' read -r -a prerelease_identifiers <<<"$prerelease"
|
||||||
|
for identifier in "${prerelease_identifiers[@]}"; do
|
||||||
|
if [[ "$identifier" =~ ^[0-9]+$ && ! "$identifier" =~ ^(0|[1-9][0-9]*)$ ]]; then
|
||||||
|
echo "Numeric prerelease identifiers must not contain leading zeroes: $version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [[ "$version" != "$(property RendezvousVersion)" ]]; then
|
||||||
|
echo "Requested version $version differs from eng/Versions.props." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for command in dotnet git python3 tar gzip sha256sum cmp jq; do
|
||||||
|
command -v "$command" >/dev/null || {
|
||||||
|
echo "Required release command is unavailable: $command" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
commit="$(git -C "$root" rev-parse HEAD)"
|
||||||
|
source_date_epoch="$(git -C "$root" show -s --format=%ct "$commit")"
|
||||||
|
tree_state=clean
|
||||||
|
if [[ -n "$(git -C "$root" status --porcelain --untracked-files=normal)" ]]; then
|
||||||
|
tree_state=dirty
|
||||||
|
fi
|
||||||
|
allow_dirty=()
|
||||||
|
if [[ "$tree_state" != clean ]]; then
|
||||||
|
if [[ "${RENDEZVOUS_RELEASE_ALLOW_DIRTY:-0}" != 1 ]]; then
|
||||||
|
echo "A formal release must be built from a clean Git tree." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
allow_dirty=(--allow-dirty)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$output" ]]; then
|
||||||
|
output="$root/artifacts/release/$version"
|
||||||
|
fi
|
||||||
|
if [[ -e "$output" ]]; then
|
||||||
|
echo "Release output already exists; refusing to overwrite: $output" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$output")"
|
||||||
|
output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
|
||||||
|
|
||||||
|
work="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-release.XXXXXX")"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$work"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
mkdir -p "$work/pack-1" "$work/pack-2" "$work/publish-1" "$work/publish-2" "$output"
|
||||||
|
|
||||||
|
create_server_archive() {
|
||||||
|
local publish_directory="$1"
|
||||||
|
local destination="$2"
|
||||||
|
tar --sort=name \
|
||||||
|
--mtime="@$source_date_epoch" \
|
||||||
|
--owner=0 --group=0 --numeric-owner \
|
||||||
|
-C "$publish_directory" -cf - . \
|
||||||
|
| gzip -n >"$destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
common=(
|
||||||
|
-p:ContinuousIntegrationBuild=true
|
||||||
|
-p:PackageVersion="$version"
|
||||||
|
-p:RepositoryCommit="$commit"
|
||||||
|
-p:SourceRevisionId="$commit"
|
||||||
|
)
|
||||||
|
|
||||||
|
cd "$root"
|
||||||
|
dotnet restore Rendezvous.slnx --locked-mode
|
||||||
|
python3 eng/release_artifacts.py policy --root "$root" >"$work/dependency-policy.json"
|
||||||
|
dotnet package list \
|
||||||
|
--project Rendezvous.slnx \
|
||||||
|
--vulnerable \
|
||||||
|
--include-transitive \
|
||||||
|
--no-restore \
|
||||||
|
--format json >"$work/nuget-vulnerabilities.json"
|
||||||
|
python3 eng/release_artifacts.py audit --input "$work/nuget-vulnerabilities.json"
|
||||||
|
dotnet format Rendezvous.slnx --verify-no-changes --no-restore
|
||||||
|
api_before="$(sha256sum docs/api/*.json)"
|
||||||
|
dotnet build Rendezvous.slnx --configuration Release --no-restore "${common[@]}"
|
||||||
|
cp src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.dll \
|
||||||
|
"$work/FinalFactory.Rendezvous.Server.first.dll"
|
||||||
|
cp src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.pdb \
|
||||||
|
"$work/FinalFactory.Rendezvous.Server.first.pdb"
|
||||||
|
dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--no-build \
|
||||||
|
--no-restore \
|
||||||
|
--output "$work/publish-1" \
|
||||||
|
-p:UseAppHost=false \
|
||||||
|
-p:OpenApiGenerateDocuments=false \
|
||||||
|
"${common[@]}"
|
||||||
|
create_server_archive "$work/publish-1" "$work/FinalFactory.Rendezvous.Server.first.tar.gz"
|
||||||
|
if [[ "$tree_state" == clean ]]; then
|
||||||
|
git diff --exit-code -- docs/api
|
||||||
|
elif [[ "$api_before" != "$(sha256sum docs/api/*.json)" ]]; then
|
||||||
|
echo "Generated OpenAPI changed during the release build." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
dotnet test Rendezvous.slnx --configuration Release --no-build
|
||||||
|
|
||||||
|
for project in Client Contracts; do
|
||||||
|
dotnet pack "src/FinalFactory.Rendezvous.$project/FinalFactory.Rendezvous.$project.csproj" \
|
||||||
|
--configuration Release --no-build --output "$work/pack-1" "${common[@]}"
|
||||||
|
done
|
||||||
|
for package in "$work/pack-1"/*; do
|
||||||
|
python3 eng/release_artifacts.py normalize-package \
|
||||||
|
--package "$package" \
|
||||||
|
--source-date-epoch "$source_date_epoch" \
|
||||||
|
--version "$version"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Rebuild from the locked graph and prove package byte reproducibility.
|
||||||
|
dotnet clean Rendezvous.slnx --configuration Release >/dev/null
|
||||||
|
dotnet restore Rendezvous.slnx --locked-mode
|
||||||
|
dotnet build Rendezvous.slnx --configuration Release --no-restore "${common[@]}"
|
||||||
|
for project in Client Contracts; do
|
||||||
|
python3 eng/release_artifacts.py source-link \
|
||||||
|
--file "src/FinalFactory.Rendezvous.$project/obj/Release/netstandard2.1/FinalFactory.Rendezvous.$project.sourcelink.json" \
|
||||||
|
--commit "$commit"
|
||||||
|
done
|
||||||
|
cmp --silent \
|
||||||
|
"$work/FinalFactory.Rendezvous.Server.first.dll" \
|
||||||
|
src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.dll || {
|
||||||
|
echo "Server assembly is not byte reproducible." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
cmp --silent \
|
||||||
|
"$work/FinalFactory.Rendezvous.Server.first.pdb" \
|
||||||
|
src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.pdb || {
|
||||||
|
echo "Server portable PDB is not byte reproducible." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
for project in Client Contracts; do
|
||||||
|
dotnet pack "src/FinalFactory.Rendezvous.$project/FinalFactory.Rendezvous.$project.csproj" \
|
||||||
|
--configuration Release --no-build --output "$work/pack-2" "${common[@]}"
|
||||||
|
done
|
||||||
|
for package in "$work/pack-2"/*; do
|
||||||
|
python3 eng/release_artifacts.py normalize-package \
|
||||||
|
--package "$package" \
|
||||||
|
--source-date-epoch "$source_date_epoch" \
|
||||||
|
--version "$version"
|
||||||
|
done
|
||||||
|
for package in "$work/pack-1"/*; do
|
||||||
|
cmp --silent "$package" "$work/pack-2/$(basename "$package")" || {
|
||||||
|
echo "Package is not byte reproducible: $(basename "$package")" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
cp "$work/pack-1"/* "$output/"
|
||||||
|
|
||||||
|
python3 eng/release_artifacts.py consumer-config \
|
||||||
|
--local-source "$output" \
|
||||||
|
--output "$work/consumer.NuGet.config"
|
||||||
|
for consumer in spacegame unscouted; do
|
||||||
|
project="$root/tests/consumers/$consumer/$(find "$root/tests/consumers/$consumer" -maxdepth 1 -name '*.csproj' -printf '%f\n')"
|
||||||
|
packages="$work/consumer-packages-$consumer"
|
||||||
|
dotnet restore "$project" \
|
||||||
|
-p:RendezvousPackageVersion="$version" \
|
||||||
|
-p:RestoreLockedMode=false \
|
||||||
|
--packages "$packages" \
|
||||||
|
--configfile "$work/consumer.NuGet.config" \
|
||||||
|
--force-evaluate
|
||||||
|
dotnet build "$project" \
|
||||||
|
--configuration Release --no-restore \
|
||||||
|
-p:RendezvousPackageVersion="$version"
|
||||||
|
assets="$(dirname "$project")/obj/project.assets.json"
|
||||||
|
python3 eng/release_artifacts.py consumer --assets "$assets" --version "$version"
|
||||||
|
done
|
||||||
|
|
||||||
|
dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--no-build \
|
||||||
|
--no-restore \
|
||||||
|
--output "$work/publish-2" \
|
||||||
|
-p:UseAppHost=false \
|
||||||
|
-p:OpenApiGenerateDocuments=false \
|
||||||
|
"${common[@]}"
|
||||||
|
server_archive="$output/FinalFactory.Rendezvous.Server.$version.linux-x64.tar.gz"
|
||||||
|
create_server_archive "$work/publish-2" "$server_archive"
|
||||||
|
cmp --silent "$work/FinalFactory.Rendezvous.Server.first.tar.gz" "$server_archive" || {
|
||||||
|
echo "Server archive is not byte reproducible." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cp CHANGELOG.md "$output/CHANGELOG.md"
|
||||||
|
python3 eng/release_artifacts.py sbom \
|
||||||
|
--root "$root" \
|
||||||
|
--version "$version" \
|
||||||
|
--commit "$commit" \
|
||||||
|
--source-date-epoch "$source_date_epoch" \
|
||||||
|
--server-deps "$work/publish-2/FinalFactory.Rendezvous.Server.deps.json" \
|
||||||
|
--output "$output/FinalFactory.Rendezvous.$version.spdx.json"
|
||||||
|
|
||||||
|
provenance=(
|
||||||
|
provenance
|
||||||
|
--root "$root"
|
||||||
|
--version "$version"
|
||||||
|
--commit "$commit"
|
||||||
|
--tree-state "$tree_state"
|
||||||
|
--source-date-epoch "$source_date_epoch"
|
||||||
|
--dotnet-sdk "$(dotnet --version)"
|
||||||
|
--build-tool "python=$(python3 --version 2>&1)"
|
||||||
|
--build-tool "tar=$(tar --version | sed -n '1p')"
|
||||||
|
--build-tool "gzip=$(gzip --version | sed -n '1p')"
|
||||||
|
--build-tool "jq=$(jq --version)"
|
||||||
|
--output "$output/release-provenance.json"
|
||||||
|
)
|
||||||
|
while IFS= read -r base; do
|
||||||
|
provenance+=(--base-digest "$base")
|
||||||
|
done < <(sed -n 's/^FROM \([^ ]*\).*/\1/p' Dockerfile)
|
||||||
|
while IFS= read -r base; do
|
||||||
|
provenance+=(--builder-base "$base")
|
||||||
|
done < <(sed -n 's/^FROM \([^ ]*\).*/\1/p' eng/release-builder.Dockerfile)
|
||||||
|
python3 eng/release_artifacts.py "${provenance[@]}"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$output"
|
||||||
|
find . -maxdepth 1 -type f ! -name checksums.sha256 -printf '%f\n' \
|
||||||
|
| LC_ALL=C sort \
|
||||||
|
| xargs sha256sum >checksums.sha256
|
||||||
|
)
|
||||||
|
python3 eng/release_artifacts.py verify \
|
||||||
|
--root "$root" \
|
||||||
|
--release-dir "$output" \
|
||||||
|
--version "$version" \
|
||||||
|
"${allow_dirty[@]}"
|
||||||
|
|
||||||
|
echo "Release artifacts verified at $output"
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
base="${1:-origin/main}"
|
||||||
|
|
||||||
|
if ! git -C "$root" cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||||
|
echo "Compatibility base does not exist; this is valid only for the initial version baseline: $base"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "$(git -C "$root" rev-parse "$base")" == "$(git -C "$root" rev-parse HEAD)" ]]; then
|
||||||
|
base="HEAD^"
|
||||||
|
fi
|
||||||
|
if ! git -C "$root" cat-file -e "$base:eng/Versions.props" 2>/dev/null; then
|
||||||
|
echo "Base has no release version manifest; accepting the initial compatibility baseline."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
current_property() {
|
||||||
|
sed -n "s:.*<$1>\(.*\)</$1>.*:\1:p" "$root/eng/Versions.props"
|
||||||
|
}
|
||||||
|
base_property() {
|
||||||
|
git -C "$root" show "$base:eng/Versions.props" \
|
||||||
|
| sed -n "s:.*<$1>\(.*\)</$1>.*:\1:p"
|
||||||
|
}
|
||||||
|
changed() {
|
||||||
|
git -C "$root" diff --name-only "$base"...HEAD -- "$@" | grep -q .
|
||||||
|
}
|
||||||
|
require_increase() {
|
||||||
|
local property="$1"
|
||||||
|
local description="$2"
|
||||||
|
shift 2
|
||||||
|
if changed "$@"; then
|
||||||
|
local before after
|
||||||
|
before="$(base_property "$property")"
|
||||||
|
after="$(current_property "$property")"
|
||||||
|
if [[ ! "$before" =~ ^[0-9]+$ || ! "$after" =~ ^[0-9]+$ || "$after" -le "$before" ]]; then
|
||||||
|
echo "$description changed without increasing $property ($before -> $after)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_increase RendezvousMajorVersion "Published .NET API snapshot" \
|
||||||
|
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/client-public-api.txt' \
|
||||||
|
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/contracts-public-api.txt'
|
||||||
|
require_increase HttpContractVersion "HTTP/OpenAPI contract evidence" \
|
||||||
|
'docs/api/*.json' \
|
||||||
|
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/*.json' \
|
||||||
|
':(exclude)tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/connection-ticket.json'
|
||||||
|
require_increase UdpContractVersion "UDP contract evidence" \
|
||||||
|
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/*.hex'
|
||||||
|
require_increase ConnectionTicketFormatVersion "Connection-ticket format evidence" \
|
||||||
|
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/connection-ticket.json'
|
||||||
|
|
||||||
|
echo "Compatibility changes are paired with the required version increase."
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
RECORD="${1:-$ROOT/docs/evidence/production-readiness-v1.json}"
|
||||||
|
|
||||||
|
exec python3 "$ROOT/eng/check_production_readiness.py" "$RECORD"
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
tag="${1:-${GITHUB_REF_NAME:-}}"
|
||||||
|
version="$(sed -n 's:.*<RendezvousVersion>\(.*\)</RendezvousVersion>.*:\1:p' "$root/eng/Versions.props")"
|
||||||
|
|
||||||
|
if [[ "$tag" != "v$version" ]]; then
|
||||||
|
echo "Release tag $tag does not match central version v$version." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$(git -C "$root" status --porcelain --untracked-files=normal)" ]]; then
|
||||||
|
echo "Release tag checkout is not clean." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$(git -C "$root" tag --points-at HEAD --list "$tag")" != "$tag" ]]; then
|
||||||
|
echo "Release tag $tag does not point at the checked-out commit." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -Eq "^## $version - [0-9]{4}-[0-9]{2}-[0-9]{2}$" "$root/CHANGELOG.md"; then
|
||||||
|
echo "CHANGELOG.md must contain a dated heading for $version." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -Eq "^## $version - Unreleased$" "$root/CHANGELOG.md"; then
|
||||||
|
echo "Release $version is still marked Unreleased." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
version="${1:?usage: finalize-release-candidate.sh VERSION RELEASE_DIRECTORY}"
|
||||||
|
release_dir="${2:?usage: finalize-release-candidate.sh VERSION RELEASE_DIRECTORY}"
|
||||||
|
container_sbom="$release_dir/FinalFactory.Rendezvous.Container.$version.spdx.json"
|
||||||
|
|
||||||
|
[[ -s "$container_sbom" ]] || {
|
||||||
|
echo "Container SBOM is missing or empty: $container_sbom" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$release_dir"
|
||||||
|
find . -maxdepth 1 -type f ! -name checksums.sha256 -printf '%f\n' \
|
||||||
|
| LC_ALL=C sort \
|
||||||
|
| xargs sha256sum >checksums.sha256
|
||||||
|
)
|
||||||
|
python3 "$root/eng/release_artifacts.py" verify \
|
||||||
|
--root "$root" \
|
||||||
|
--release-dir "$release_dir" \
|
||||||
|
--version "$version" \
|
||||||
|
--phase publish-ready
|
||||||
|
|
||||||
|
echo "Finalized publish-ready release candidate $version"
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
version="${1:?usage: finalize-signing-ready-release.sh VERSION RELEASE_DIRECTORY}"
|
||||||
|
release_dir="${2:?usage: finalize-signing-ready-release.sh VERSION RELEASE_DIRECTORY}"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$release_dir"
|
||||||
|
find . -maxdepth 1 -type f \
|
||||||
|
! -name checksums.sha256 \
|
||||||
|
! -name checksums.sha256.bundle \
|
||||||
|
-printf '%f\n' \
|
||||||
|
| LC_ALL=C sort \
|
||||||
|
| xargs sha256sum >checksums.sha256
|
||||||
|
)
|
||||||
|
python3 "$root/eng/release_artifacts.py" verify \
|
||||||
|
--root "$root" \
|
||||||
|
--release-dir "$release_dir" \
|
||||||
|
--version "$version" \
|
||||||
|
--phase signing-ready
|
||||||
|
|
||||||
|
echo "Finalized signing-ready release $version"
|
||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
LOCAL_KEY="${RENDEZVOUS_SMOKE_LOCAL_KEY:-$ROOT/deploy/compose/secrets/signing-key}"
|
||||||
|
GAME_ID="${RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID:-space-game}"
|
||||||
|
|
||||||
|
case "$GAME_ID" in
|
||||||
|
space-game)
|
||||||
|
KEY_ID="local-smoke-1"
|
||||||
|
SUBJECT="local-smoke-host"
|
||||||
|
;;
|
||||||
|
unscouted)
|
||||||
|
KEY_ID="local-smoke-unscouted-1"
|
||||||
|
SUBJECT="local-smoke-unscouted-host"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID must be space-game or unscouted.\n' >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if (( $# != 0 )); then
|
||||||
|
printf 'This helper accepts no arguments; select only a provisioned local game through RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v python3 >/dev/null || {
|
||||||
|
printf 'Missing required command: python3\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# This is deliberately a local-fixture tool, not a general credential issuer.
|
||||||
|
# Python reads the raw key from the protected file; key material never appears in
|
||||||
|
# a child process argument, environment value, temporary file, or command output.
|
||||||
|
python3 - "$LOCAL_KEY" "$GAME_ID" "$KEY_ID" "$SUBJECT" <<'PY'
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
key_path = sys.argv[1]
|
||||||
|
game_id = sys.argv[2]
|
||||||
|
key_id = sys.argv[3]
|
||||||
|
subject = sys.argv[4]
|
||||||
|
try:
|
||||||
|
metadata = os.lstat(key_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise SystemExit(f"Local Compose smoke key does not exist: {key_path}")
|
||||||
|
|
||||||
|
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise SystemExit(f"Local Compose smoke key must be a regular non-symlink file: {key_path}")
|
||||||
|
parent_path = os.path.dirname(os.path.abspath(key_path))
|
||||||
|
parent = os.lstat(parent_path)
|
||||||
|
if stat.S_ISLNK(parent.st_mode) or not stat.S_ISDIR(parent.st_mode):
|
||||||
|
raise SystemExit(f"Local Compose secret directory must be a non-symlink directory: {parent_path}")
|
||||||
|
if parent.st_uid != os.geteuid() or parent.st_mode & 0o077:
|
||||||
|
raise SystemExit(f"Local Compose secret directory must be owned by this user with mode 0700: {parent_path}")
|
||||||
|
if metadata.st_uid != os.geteuid() or metadata.st_mode & 0o077 or metadata.st_nlink != 1:
|
||||||
|
raise SystemExit(f"Local Compose smoke key must be owned by this user, single-linked, and private to its owner: {key_path}")
|
||||||
|
|
||||||
|
with open(key_path, "rb") as key_file:
|
||||||
|
key = key_file.read(33)
|
||||||
|
if len(key) != 32:
|
||||||
|
raise SystemExit(f"Local Compose smoke key must be exactly 32 bytes: {key_path}")
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"version": 1,
|
||||||
|
"issuer": "final-factory-rendezvous-smoke",
|
||||||
|
"audience": "rendezvous-service",
|
||||||
|
"subject": subject,
|
||||||
|
"kind": "dedicatedPublisher",
|
||||||
|
"gameId": game_id,
|
||||||
|
"environmentId": "smoke",
|
||||||
|
"regions": ["local"],
|
||||||
|
"permissions": [],
|
||||||
|
"issuedAtUnixSeconds": now,
|
||||||
|
"notBeforeUnixSeconds": now,
|
||||||
|
"expiresAtUnixSeconds": now + 600,
|
||||||
|
"nonce": secrets.token_hex(16),
|
||||||
|
}
|
||||||
|
|
||||||
|
def base64url(value: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
encoded = base64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||||
|
signed = f"rv1.{key_id}.{encoded}"
|
||||||
|
signature = base64url(hmac.new(key, signed.encode("ascii"), hashlib.sha256).digest())
|
||||||
|
print(f"{signed}.{signature}")
|
||||||
|
PY
|
||||||
Executable
+153
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
version="${1:?usage: publish-release.sh VERSION RELEASE_DIRECTORY}"
|
||||||
|
release_dir="${2:?usage: publish-release.sh VERSION RELEASE_DIRECTORY}"
|
||||||
|
api="${RENDEZVOUS_GITEA_API:-https://git.finalfactory.de/api/v1}"
|
||||||
|
registry="${RENDEZVOUS_CONTAINER_REGISTRY:-git.finalfactory.de}"
|
||||||
|
image="$registry/heikyu/rendezvous:$version"
|
||||||
|
token="${RENDEZVOUS_RELEASE_TOKEN:?RENDEZVOUS_RELEASE_TOKEN is required}"
|
||||||
|
username="${RENDEZVOUS_RELEASE_USERNAME:?RENDEZVOUS_RELEASE_USERNAME is required}"
|
||||||
|
release_builder="${RENDEZVOUS_RELEASE_BUILDER:?RENDEZVOUS_RELEASE_BUILDER is required}"
|
||||||
|
docker_config="$(mktemp -d)"
|
||||||
|
curl_config="$(mktemp)"
|
||||||
|
release_request=""
|
||||||
|
release_response=""
|
||||||
|
cleanup() {
|
||||||
|
[[ -z "$release_request" ]] || rm -f "$release_request"
|
||||||
|
[[ -z "$release_response" ]] || rm -f "$release_response"
|
||||||
|
rm -f "$curl_config"
|
||||||
|
rm -rf "$docker_config"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
chmod 0700 "$docker_config"
|
||||||
|
chmod 0600 "$curl_config"
|
||||||
|
printf 'header = "Authorization: token %s"\n' "$token" >"$curl_config"
|
||||||
|
export DOCKER_CONFIG="$docker_config"
|
||||||
|
|
||||||
|
for command in cosign curl docker dotnet jq; do
|
||||||
|
command -v "$command" >/dev/null || {
|
||||||
|
echo "Required publication command is unavailable: $command" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
run_release_builder() {
|
||||||
|
docker run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--volume "$root:/source:ro" \
|
||||||
|
--volume "$release_dir:$release_dir" \
|
||||||
|
--workdir /source \
|
||||||
|
"$release_builder" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
"$root/scripts/check-release-tag.sh" "v$version"
|
||||||
|
"$root/scripts/verify-release.sh" "$version" "$release_dir" publish-ready
|
||||||
|
|
||||||
|
require_absent() {
|
||||||
|
local description="$1"
|
||||||
|
local url="$2"
|
||||||
|
local status
|
||||||
|
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||||
|
--config "$curl_config" "$url")"
|
||||||
|
if [[ "$status" != 404 ]]; then
|
||||||
|
echo "$description must not exist before publication (HTTP $status)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gitea package versions are immutable. Require all destinations to be empty
|
||||||
|
# before the first write so a tag can never become a silent partial rerun.
|
||||||
|
require_absent "Client package $version" \
|
||||||
|
"$api/packages/HeiKyu/nuget/FinalFactory.Rendezvous.Client/$version"
|
||||||
|
require_absent "Contracts package $version" \
|
||||||
|
"$api/packages/HeiKyu/nuget/FinalFactory.Rendezvous.Contracts/$version"
|
||||||
|
require_absent "Container $version" \
|
||||||
|
"$api/packages/HeiKyu/container/rendezvous/$version"
|
||||||
|
require_absent "Release v$version" \
|
||||||
|
"$api/repos/HeiKyu/Rendezvous/releases/tags/v$version"
|
||||||
|
|
||||||
|
feed="https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json"
|
||||||
|
for package in \
|
||||||
|
"$release_dir/FinalFactory.Rendezvous.Contracts.$version.nupkg" \
|
||||||
|
"$release_dir/FinalFactory.Rendezvous.Client.$version.nupkg"; do
|
||||||
|
dotnet nuget push "$package" \
|
||||||
|
--source "$feed" \
|
||||||
|
--api-key "$token" \
|
||||||
|
--timeout 300
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '%s' "$token" | docker login "$registry" --username "$username" --password-stdin
|
||||||
|
expected_image_id="$(jq -er '.containerImageId' "$release_dir/release-provenance.json")"
|
||||||
|
current_image_id="$(docker image inspect --format '{{.Id}}' "$image")"
|
||||||
|
if [[ "$current_image_id" != "$expected_image_id" ]]; then
|
||||||
|
echo "Local release tag changed after staging ($expected_image_id -> $current_image_id)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker push "$image"
|
||||||
|
digest_ref="$(docker inspect --format '{{index .RepoDigests 0}}' "$image")"
|
||||||
|
if [[ ! "$digest_ref" =~ ^git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}$ ]]; then
|
||||||
|
echo "Registry did not return an immutable Rendezvous image digest: $digest_ref" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
run_release_builder python3 eng/release_artifacts.py record-container-digest \
|
||||||
|
--release-dir "$release_dir" \
|
||||||
|
--digest "$digest_ref"
|
||||||
|
cosign public-key --key env://COSIGN_PRIVATE_KEY >"$release_dir/cosign.pub"
|
||||||
|
run_release_builder ./scripts/finalize-signing-ready-release.sh \
|
||||||
|
"$version" "$release_dir"
|
||||||
|
|
||||||
|
cosign sign --yes --key env://COSIGN_PRIVATE_KEY "$digest_ref"
|
||||||
|
cosign attest --yes \
|
||||||
|
--key env://COSIGN_PRIVATE_KEY \
|
||||||
|
--type https://finalfactory.de/rendezvous/release-provenance/v1 \
|
||||||
|
--predicate "$release_dir/release-provenance.json" \
|
||||||
|
"$digest_ref"
|
||||||
|
cosign sign-blob --yes \
|
||||||
|
--key env://COSIGN_PRIVATE_KEY \
|
||||||
|
--bundle "$release_dir/checksums.sha256.bundle" \
|
||||||
|
"$release_dir/checksums.sha256"
|
||||||
|
"$root/scripts/verify-release.sh" "$version" "$release_dir" published
|
||||||
|
cosign verify --key "$release_dir/cosign.pub" "$digest_ref" >/dev/null
|
||||||
|
cosign verify-attestation \
|
||||||
|
--key "$release_dir/cosign.pub" \
|
||||||
|
--type https://finalfactory.de/rendezvous/release-provenance/v1 \
|
||||||
|
"$digest_ref" >/dev/null
|
||||||
|
cosign verify-blob \
|
||||||
|
--key "$release_dir/cosign.pub" \
|
||||||
|
--bundle "$release_dir/checksums.sha256.bundle" \
|
||||||
|
"$release_dir/checksums.sha256" >/dev/null
|
||||||
|
|
||||||
|
release_request="$(mktemp)"
|
||||||
|
release_response="$(mktemp)"
|
||||||
|
prerelease=false
|
||||||
|
if [[ "$version" == *-* ]]; then
|
||||||
|
prerelease=true
|
||||||
|
fi
|
||||||
|
jq -n \
|
||||||
|
--arg tag "v$version" \
|
||||||
|
--arg commit "${GITHUB_SHA:?GITHUB_SHA is required}" \
|
||||||
|
--arg digest "$digest_ref" \
|
||||||
|
--argjson prerelease "$prerelease" \
|
||||||
|
--rawfile changelog "$release_dir/CHANGELOG.md" \
|
||||||
|
'{tag_name:$tag,target_commitish:$commit,name:("Rendezvous " + $tag),body:($changelog + "\n\n## Immutable container\n\n`" + $digest + "`\n"),draft:false,prerelease:$prerelease}' \
|
||||||
|
>"$release_request"
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--request POST \
|
||||||
|
--config "$curl_config" \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data-binary "@$release_request" \
|
||||||
|
"$api/repos/HeiKyu/Rendezvous/releases" >"$release_response"
|
||||||
|
release_id="$(jq -er '.id' "$release_response")"
|
||||||
|
|
||||||
|
for artifact in "$release_dir"/*; do
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--request POST \
|
||||||
|
--config "$curl_config" \
|
||||||
|
--form "attachment=@$artifact" \
|
||||||
|
"$api/repos/HeiKyu/Rendezvous/releases/$release_id/assets?name=$(basename "$artifact")" \
|
||||||
|
>/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Published immutable release v$version with container $digest_ref"
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PROFILE="${RENDEZVOUS_CAPACITY_PROFILE:-quick}"
|
||||||
|
OUTPUT="${RENDEZVOUS_CAPACITY_OUTPUT:-$ROOT/artifacts/capacity/rendezvous-capacity-v2.json}"
|
||||||
|
CPUSET="${RENDEZVOUS_CAPACITY_CPUSET:-}"
|
||||||
|
PROJECT="$ROOT/tests/FinalFactory.Rendezvous.Capacity/FinalFactory.Rendezvous.Capacity.csproj"
|
||||||
|
TESTS="$ROOT/tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj"
|
||||||
|
|
||||||
|
if [[ "$PROFILE" != quick && "$PROFILE" != candidate ]]; then
|
||||||
|
printf 'RENDEZVOUS_CAPACITY_PROFILE must be quick or candidate.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
command -v dotnet >/dev/null || {
|
||||||
|
printf 'Missing required command: dotnet\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
if [[ -n "$CPUSET" ]]; then
|
||||||
|
command -v taskset >/dev/null || {
|
||||||
|
printf 'taskset is required when RENDEZVOUS_CAPACITY_CPUSET is set.\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
export RENDEZVOUS_EVIDENCE_COMMIT="$(git rev-parse HEAD)"
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
export RENDEZVOUS_EVIDENCE_TREE_STATE=dirty
|
||||||
|
else
|
||||||
|
export RENDEZVOUS_EVIDENCE_TREE_STATE=clean
|
||||||
|
fi
|
||||||
|
if [[ "$PROFILE" == candidate && "$RENDEZVOUS_EVIDENCE_TREE_STATE" != clean ]]; then
|
||||||
|
printf 'Candidate evidence requires a clean source tree.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
export RENDEZVOUS_EVIDENCE_CPUSET="${CPUSET:-unrestricted}"
|
||||||
|
export RENDEZVOUS_EVIDENCE_COMMAND="RENDEZVOUS_CAPACITY_PROFILE=$PROFILE RENDEZVOUS_CAPACITY_CPUSET=${CPUSET:-unrestricted} ./scripts/run-capacity-gate.sh"
|
||||||
|
|
||||||
|
dotnet restore "$ROOT/Rendezvous.slnx" --locked-mode
|
||||||
|
dotnet build "$ROOT/Rendezvous.slnx" --configuration Release --no-restore
|
||||||
|
|
||||||
|
filter='FullyQualifiedName~TrackerCapacityFailsClosedWithoutGrowingAndAWindowResetRecovers|FullyQualifiedName~OptionalTrafficCannotConsumeTheLeaseOperationReserve|FullyQualifiedName~ConcurrentAbusiveBurstStaysBoundedAndCannotBlockCriticalHttp|FullyQualifiedName~HttpOverloadIsTypedAndOversizedBodiesAreRejectedBeforeDispatch|FullyQualifiedName~WallClockMovementDoesNotExpireOrExtendLease|FullyQualifiedName~RepeatedMutableDeadlineRefreshesKeepOneScheduledEntryPerKey|FullyQualifiedName~RepeatedPrincipalRevocationCanExtendButCannotShortenProtection|FullyQualifiedName~RestartHasNewGenerationAndNoEphemeralState|FullyQualifiedName~RestartReturnsTypedUnavailabilityThenAllowsHostReregistration|FullyQualifiedName~DrainRejectsNewWorkAllowsInflightCompletionThenClearsState|FullyQualifiedName~UnavailableStoreFailsNewAuthorizationClosedAndErasesActiveState|FullyQualifiedName~KeyRotationHonorsOverlapAndRejectsRetiredKeys|FullyQualifiedName~OperatorSurfaceSeparatesAuthenticationConfirmsActionsAndRedactsInspection|FullyQualifiedName~SigtermDrainsThenReleasesHttpAndUdpSockets|FullyQualifiedName~ProductionTransportSoakKeepsHandlesMemoryAndSocketsBounded|FullyQualifiedName~NativeLiteNetLibRequestsIntroduceTheAuthorizedPair'
|
||||||
|
dotnet test "$TESTS" --configuration Release --no-build --filter "$filter" \
|
||||||
|
--logger 'console;verbosity=minimal'
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$OUTPUT")"
|
||||||
|
arguments=(
|
||||||
|
dotnet run --project "$PROJECT" --configuration Release --no-build --
|
||||||
|
--profile "$PROFILE" --output "$OUTPUT"
|
||||||
|
)
|
||||||
|
if [[ -n "$CPUSET" ]]; then
|
||||||
|
taskset -c "$CPUSET" "${arguments[@]}"
|
||||||
|
else
|
||||||
|
"${arguments[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Capacity and resilience gate passed; evidence: %s\n' "$OUTPUT"
|
||||||
Executable
+243
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PROJECT="$ROOT/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj"
|
||||||
|
ROLE="${RENDEZVOUS_CANARY_ROLE:-}"
|
||||||
|
TOPOLOGY="${RENDEZVOUS_CANARY_TOPOLOGY:-}"
|
||||||
|
ADDRESS_FAMILY="${RENDEZVOUS_CANARY_ADDRESS_FAMILY:-ipv4}"
|
||||||
|
SERVICE_URL="${RENDEZVOUS_CANARY_HTTP_URL:-}"
|
||||||
|
MEDIATOR="${RENDEZVOUS_CANARY_UDP_ENDPOINT:-}"
|
||||||
|
GAME_ID="${RENDEZVOUS_CANARY_GAME_ID:-space-game}"
|
||||||
|
ENVIRONMENT_ID="${RENDEZVOUS_CANARY_ENVIRONMENT_ID:-production-canary}"
|
||||||
|
REGION="${RENDEZVOUS_CANARY_REGION:-production-canary}"
|
||||||
|
PROTOCOL_VERSION="${RENDEZVOUS_CANARY_PROTOCOL_VERSION:-1}"
|
||||||
|
TIMEOUT_SECONDS="${RENDEZVOUS_CANARY_TIMEOUT_SECONDS:-60}"
|
||||||
|
RUN_SECONDS="${RENDEZVOUS_CANARY_RUN_SECONDS:-900}"
|
||||||
|
OUTPUT="${RENDEZVOUS_CANARY_OUTPUT:-$ROOT/artifacts/canary/${ROLE:-unknown}-${TOPOLOGY:-unknown}.json}"
|
||||||
|
COORDINATION_FILE="${RENDEZVOUS_CANARY_COORDINATION_FILE:-}"
|
||||||
|
LISTING_ID="${RENDEZVOUS_CANARY_LISTING_ID:-}"
|
||||||
|
REQUIRE_CLEAN="${RENDEZVOUS_CANARY_REQUIRE_CLEAN:-true}"
|
||||||
|
KEEP_RAW="${RENDEZVOUS_CANARY_KEEP_RAW:-false}"
|
||||||
|
UUID_PATTERN='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf '%s\n' \
|
||||||
|
'Set RENDEZVOUS_CANARY_ROLE to host, client-success, or client-expected-failure.' \
|
||||||
|
'Also set RENDEZVOUS_CANARY_TOPOLOGY, RENDEZVOUS_CANARY_HTTP_URL, and' \
|
||||||
|
'RENDEZVOUS_CANARY_UDP_ENDPOINT. See docs/operations/production-readiness.md.' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
for command in date dotnet git jq mktemp tail; do
|
||||||
|
command -v "$command" >/dev/null || {
|
||||||
|
printf 'Missing required command: %s\n' "$command" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$ROLE" in
|
||||||
|
host|client-success|client-expected-failure) ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
case "$TOPOLOGY" in
|
||||||
|
same-lan|home-nat|firewall-blocked-udp|restrictive-cgnat|ipv6-direct) ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
case "$ADDRESS_FAMILY" in
|
||||||
|
ipv4|ipv6) ;;
|
||||||
|
*) printf 'RENDEZVOUS_CANARY_ADDRESS_FAMILY must be ipv4 or ipv6.\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
if [[ "$TOPOLOGY" == ipv6-direct && "$ADDRESS_FAMILY" != ipv6 ]]; then
|
||||||
|
printf 'The ipv6-direct topology requires RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv6.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$TOPOLOGY" =~ ^(firewall-blocked-udp|restrictive-cgnat)$ \
|
||||||
|
&& "$ROLE" == client-success ]]; then
|
||||||
|
printf 'Failure topologies must use the client-expected-failure role.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "$SERVICE_URL" || -z "$MEDIATOR" ]]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] \
|
||||||
|
|| (( TIMEOUT_SECONDS < 1 || TIMEOUT_SECONDS > 300 )); then
|
||||||
|
printf 'RENDEZVOUS_CANARY_TIMEOUT_SECONDS must be an integer from 1 through 300.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! "$RUN_SECONDS" =~ ^[0-9]+$ ]] \
|
||||||
|
|| (( RUN_SECONDS < 60 || RUN_SECONDS > 3600 )); then
|
||||||
|
printf 'RENDEZVOUS_CANARY_RUN_SECONDS must be an integer from 60 through 3600.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! "$PROTOCOL_VERSION" =~ ^[0-9]+$ ]] || (( PROTOCOL_VERSION < 1 )); then
|
||||||
|
printf 'RENDEZVOUS_CANARY_PROTOCOL_VERSION must be a positive integer.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$REQUIRE_CLEAN" != true && "$REQUIRE_CLEAN" != false ]]; then
|
||||||
|
printf 'RENDEZVOUS_CANARY_REQUIRE_CLEAN must be true or false.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$KEEP_RAW" != true && "$KEEP_RAW" != false ]]; then
|
||||||
|
printf 'RENDEZVOUS_CANARY_KEEP_RAW must be true or false.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
commit="$(git rev-parse HEAD)"
|
||||||
|
tree_state=clean
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
tree_state=dirty
|
||||||
|
fi
|
||||||
|
if [[ "$REQUIRE_CLEAN" == true && "$tree_state" != clean ]]; then
|
||||||
|
printf 'Formal canary evidence requires a clean source tree.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ROLE" == host ]]; then
|
||||||
|
if [[ -z "$COORDINATION_FILE" ]]; then
|
||||||
|
printf 'The host role requires RENDEZVOUS_CANARY_COORDINATION_FILE.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -e "$COORDINATION_FILE" ]]; then
|
||||||
|
printf 'The host coordination file already exists; remove it explicitly before a new canary.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}" ]]; then
|
||||||
|
printf 'The host role requires RENDEZVOUS_PUBLISHER_CREDENTIAL.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ ! "$LISTING_ID" =~ $UUID_PATTERN ]]; then
|
||||||
|
printf 'A client role requires a UUID in RENDEZVOUS_CANARY_LISTING_ID.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
raw_dir="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-canary.XXXXXXXX")"
|
||||||
|
raw_log="$raw_dir/events.jsonl"
|
||||||
|
run_succeeded=false
|
||||||
|
host_pid=''
|
||||||
|
cleanup() {
|
||||||
|
local status="$?"
|
||||||
|
if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then
|
||||||
|
kill -TERM "$host_pid" 2>/dev/null || true
|
||||||
|
wait "$host_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ "$run_succeeded" == true && "$KEEP_RAW" == false ]]; then
|
||||||
|
rm -rf "$raw_dir"
|
||||||
|
else
|
||||||
|
printf 'Private raw canary events retained at %s\n' "$raw_dir" >&2
|
||||||
|
fi
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
common_arguments=(
|
||||||
|
--service "$SERVICE_URL"
|
||||||
|
--mediator "$MEDIATOR"
|
||||||
|
--game "$GAME_ID"
|
||||||
|
--environment "$ENVIRONMENT_ID"
|
||||||
|
--region "$REGION"
|
||||||
|
--protocol "$PROTOCOL_VERSION"
|
||||||
|
--script
|
||||||
|
--json
|
||||||
|
--timeout-seconds "$TIMEOUT_SECONDS"
|
||||||
|
)
|
||||||
|
|
||||||
|
exit_code=0
|
||||||
|
if [[ "$ROLE" == host ]]; then
|
||||||
|
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
||||||
|
host "${common_arguments[@]}" --exit-after-echo --run-seconds "$RUN_SECONDS" \
|
||||||
|
>"$raw_log" 2>&1 &
|
||||||
|
host_pid="$!"
|
||||||
|
ready=false
|
||||||
|
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
|
||||||
|
if jq -e 'select(.event == "host.ready" and .status == "ready")' "$raw_log" \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
ready=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$host_pid" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
if [[ "$ready" != true ]]; then
|
||||||
|
printf 'The canary host did not become ready within the bounded startup window.\n' >&2
|
||||||
|
kill -TERM "$host_pid" 2>/dev/null || true
|
||||||
|
wait "$host_pid" 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
observed_listing="$(jq -r 'select(.event == "host.registered") | .listingId' "$raw_log" | tail -n 1)"
|
||||||
|
if [[ ! "$observed_listing" =~ $UUID_PATTERN ]]; then
|
||||||
|
printf 'The canary host did not produce a valid coordination identifier.\n' >&2
|
||||||
|
kill -TERM "$host_pid" 2>/dev/null || true
|
||||||
|
wait "$host_pid" 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
coordination_parent="$(dirname "$COORDINATION_FILE")"
|
||||||
|
mkdir -p "$coordination_parent"
|
||||||
|
coordination_temp="$(mktemp "$COORDINATION_FILE.tmp.XXXXXXXX")"
|
||||||
|
printf '%s\n' "$observed_listing" >"$coordination_temp"
|
||||||
|
mv "$coordination_temp" "$COORDINATION_FILE"
|
||||||
|
printf 'Host ready; securely transfer the private coordination file to the client operator.\n'
|
||||||
|
set +e
|
||||||
|
wait "$host_pid"
|
||||||
|
exit_code="$?"
|
||||||
|
set -e
|
||||||
|
elif [[ "$ROLE" == client-success ]]; then
|
||||||
|
set +e
|
||||||
|
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
||||||
|
join "${common_arguments[@]}" --listing "$LISTING_ID" >"$raw_log" 2>&1
|
||||||
|
exit_code="$?"
|
||||||
|
set -e
|
||||||
|
else
|
||||||
|
set +e
|
||||||
|
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
||||||
|
join "${common_arguments[@]}" --listing "$LISTING_ID" >"$raw_log" 2>&1
|
||||||
|
exit_code="$?"
|
||||||
|
set -e
|
||||||
|
fi
|
||||||
|
|
||||||
|
checks='{}'
|
||||||
|
if [[ "$ROLE" == host ]]; then
|
||||||
|
[[ "$exit_code" -eq 0 ]]
|
||||||
|
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "host.direct-traffic" and .status == "verified" and .addressFamily == $family)' "$raw_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "host.deregistered" and .status == "complete")' "$raw_log" >/dev/null
|
||||||
|
checks='{"authenticatedDirectTraffic":true,"deregistered":true}'
|
||||||
|
elif [[ "$ROLE" == client-success ]]; then
|
||||||
|
[[ "$exit_code" -eq 0 ]]
|
||||||
|
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "join.connected" and .status == "connected" and .addressFamily == $family)' "$raw_log" >/dev/null
|
||||||
|
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "join.direct-traffic" and .status == "verified" and .addressFamily == $family)' "$raw_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "join.outcome-report" and .status == "accepted")' "$raw_log" >/dev/null
|
||||||
|
checks='{"authenticatedDirectTraffic":true,"typedOutcomeReported":true}'
|
||||||
|
else
|
||||||
|
[[ "$exit_code" -eq 12 ]]
|
||||||
|
jq -e 'select((.event == "join.traversal" or .event == "join.authorization") and .status == "failed" and (.outcome | type == "string") and (.outcome | length > 0))' "$raw_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "join.fallback" and (.status == "available" or .status == "unavailable") and (.outcome | type == "string") and (.outcome | length > 0))' "$raw_log" >/dev/null
|
||||||
|
checks='{"boundedTypedFailure":true,"fallbackPolicyReported":true}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$OUTPUT")"
|
||||||
|
raw_retention=deleted-after-success
|
||||||
|
if [[ "$KEEP_RAW" == true ]]; then
|
||||||
|
raw_retention=retained-private-on-request
|
||||||
|
fi
|
||||||
|
jq -n \
|
||||||
|
--arg commit "$commit" \
|
||||||
|
--arg treeState "$tree_state" \
|
||||||
|
--arg timestampUtc "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
--arg role "$ROLE" \
|
||||||
|
--arg topology "$TOPOLOGY" \
|
||||||
|
--arg addressFamily "$ADDRESS_FAMILY" \
|
||||||
|
--arg rawEvents "$raw_retention" \
|
||||||
|
--argjson checks "$checks" \
|
||||||
|
'{schemaVersion:1,kind:"rendezvous-real-network-canary",commit:$commit,treeState:$treeState,timestampUtc:$timestampUtc,role:$role,topology:$topology,addressFamily:$addressFamily,result:"pass",checks:$checks,dataRetention:{rawEvents:$rawEvents,identifiers:"not-in-summary",networkEndpoints:"not-in-summary"}}' \
|
||||||
|
>"$OUTPUT"
|
||||||
|
|
||||||
|
run_succeeded=true
|
||||||
|
printf 'Real-network canary passed; sanitized evidence: %s\n' "$OUTPUT"
|
||||||
@@ -13,7 +13,7 @@ ENVIRONMENT_ID="${RENDEZVOUS_SMOKE_ENVIRONMENT_ID:-smoke}"
|
|||||||
REGION="${RENDEZVOUS_SMOKE_REGION:-local}"
|
REGION="${RENDEZVOUS_SMOKE_REGION:-local}"
|
||||||
PROTOCOL_VERSION="${RENDEZVOUS_SMOKE_PROTOCOL_VERSION:-1}"
|
PROTOCOL_VERSION="${RENDEZVOUS_SMOKE_PROTOCOL_VERSION:-1}"
|
||||||
|
|
||||||
for command in curl date dotnet jq mktemp od openssl tail tr wc; do
|
for command in curl dotnet jq mktemp tail; do
|
||||||
command -v "$command" >/dev/null || {
|
command -v "$command" >/dev/null || {
|
||||||
printf 'Missing required command: %s\n' "$command" >&2
|
printf 'Missing required command: %s\n' "$command" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -35,39 +35,14 @@ for scoped_value in "$GAME_ID" "$ENVIRONMENT_ID" "$REGION"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
base64url() {
|
|
||||||
openssl base64 -A | tr '+/' '-_' | tr -d '='
|
|
||||||
}
|
|
||||||
|
|
||||||
local_credential() {
|
local_credential() {
|
||||||
if [[ ! -f "$LOCAL_KEY" ]] || [[ "$(wc -c < "$LOCAL_KEY")" -ne 32 ]]; then
|
if [[ "$GAME_ID" != space-game || "$ENVIRONMENT_ID" != smoke \
|
||||||
printf 'Local Compose smoke key must be exactly 32 bytes: %s\n' "$LOCAL_KEY" >&2
|
|| "$REGION" != local || "$PROTOCOL_VERSION" != 1 ]]; then
|
||||||
|
printf 'The local credential helper supports only space-game/smoke/local protocol 1. Supply RENDEZVOUS_PUBLISHER_CREDENTIAL for any other scope.\n' >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
RENDEZVOUS_SMOKE_LOCAL_KEY="$LOCAL_KEY" \
|
||||||
local now expires nonce payload encoded signed hex signature
|
"$ROOT/scripts/mint-local-publisher-credential.sh"
|
||||||
now="$(date +%s)"
|
|
||||||
expires="$((now + 600))"
|
|
||||||
nonce="$(openssl rand -hex 16)"
|
|
||||||
payload="$(jq -cn \
|
|
||||||
--arg issuer final-factory-rendezvous-smoke \
|
|
||||||
--arg audience rendezvous-service \
|
|
||||||
--arg subject local-smoke-host \
|
|
||||||
--arg kind dedicatedPublisher \
|
|
||||||
--arg gameId "$GAME_ID" \
|
|
||||||
--arg environmentId "$ENVIRONMENT_ID" \
|
|
||||||
--arg region "$REGION" \
|
|
||||||
--arg nonce "$nonce" \
|
|
||||||
--argjson now "$now" \
|
|
||||||
--argjson expires "$expires" \
|
|
||||||
'{version:1,issuer:$issuer,audience:$audience,subject:$subject,kind:$kind,gameId:$gameId,environmentId:$environmentId,regions:[$region],permissions:[],issuedAtUnixSeconds:$now,notBeforeUnixSeconds:$now,expiresAtUnixSeconds:$expires,nonce:$nonce}')"
|
|
||||||
encoded="$(printf '%s' "$payload" | base64url)"
|
|
||||||
signed="rv1.local-smoke-1.$encoded"
|
|
||||||
hex="$(od -An -v -tx1 "$LOCAL_KEY" | tr -d ' \n')"
|
|
||||||
signature="$(printf '%s' "$signed" \
|
|
||||||
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$hex" -binary \
|
|
||||||
| base64url)"
|
|
||||||
printf '%s.%s' "$signed" "$signature"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
credential="${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}"
|
credential="${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}"
|
||||||
|
|||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
version="${1:?usage: verify-real-consumers.sh VERSION RELEASE_DIRECTORY}"
|
||||||
|
release_dir="${2:?usage: verify-real-consumers.sh VERSION RELEASE_DIRECTORY}"
|
||||||
|
manifest="$root/eng/consumer-revisions.json"
|
||||||
|
work="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-consumers.XXXXXX")"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$work"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
for command in dotnet git jq python3; do
|
||||||
|
command -v "$command" >/dev/null || {
|
||||||
|
echo "Required consumer verification command is unavailable: $command" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
python3 "$root/eng/release_artifacts.py" consumer-config \
|
||||||
|
--local-source "$release_dir" \
|
||||||
|
--output "$work/NuGet.config"
|
||||||
|
|
||||||
|
count="$(jq '.consumers | length' "$manifest")"
|
||||||
|
for ((index = 0; index < count; index++)); do
|
||||||
|
name="$(jq -r ".consumers[$index].name" "$manifest")"
|
||||||
|
repository="$(jq -r ".consumers[$index].repository" "$manifest")"
|
||||||
|
revision="$(jq -r ".consumers[$index].revision" "$manifest")"
|
||||||
|
project_relative="$(jq -r ".consumers[$index].project" "$manifest")"
|
||||||
|
checkout="$work/$name"
|
||||||
|
git -c init.defaultBranch=main init --quiet "$checkout"
|
||||||
|
git -C "$checkout" remote add origin "$repository"
|
||||||
|
git -C "$checkout" fetch --quiet --depth 1 origin "$revision"
|
||||||
|
GIT_LFS_SKIP_SMUDGE=1 git -C "$checkout" checkout --quiet --detach FETCH_HEAD
|
||||||
|
[[ "$(git -C "$checkout" rev-parse HEAD)" == "$revision" ]] || {
|
||||||
|
echo "$name did not resolve the pinned consumer revision." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
project="$checkout/$project_relative"
|
||||||
|
[[ -f "$project" ]] || {
|
||||||
|
echo "$name consumer project does not exist at $project_relative." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
targets="$work/$name.Rendezvous.Consumer.targets"
|
||||||
|
cat >"$targets" <<EOF
|
||||||
|
<Project>
|
||||||
|
<ItemGroup Condition="'\$(MSBuildProjectFullPath)' == '$project'">
|
||||||
|
<PackageReference Remove="FinalFactory.Rendezvous.Client" />
|
||||||
|
<PackageReference Remove="FinalFactory.Rendezvous.Contracts" />
|
||||||
|
<PackageReference Include="FinalFactory.Rendezvous.Client" Version="[$version]" />
|
||||||
|
<PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="[$version]" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
EOF
|
||||||
|
packages="$work/packages-$name"
|
||||||
|
dotnet restore "$project" \
|
||||||
|
-p:CustomAfterMicrosoftCommonTargets="$targets" \
|
||||||
|
-p:RestorePackagesWithLockFile=false \
|
||||||
|
-p:RestoreLockedMode=false \
|
||||||
|
--packages "$packages" \
|
||||||
|
--configfile "$work/NuGet.config" \
|
||||||
|
--force-evaluate
|
||||||
|
assets=""
|
||||||
|
while IFS= read -r candidate_assets; do
|
||||||
|
if grep -Fq "FinalFactory.Rendezvous.Client/$version" "$candidate_assets"; then
|
||||||
|
assets="$candidate_assets"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done < <(find "$checkout" -path '*/obj/project.assets.json' -type f -print)
|
||||||
|
[[ -n "$assets" ]] || {
|
||||||
|
echo "$name restore did not produce assets for the injected Rendezvous references." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
python3 "$root/eng/release_artifacts.py" consumer \
|
||||||
|
--assets "$assets" \
|
||||||
|
--version "$version"
|
||||||
|
echo "Verified $name at $revision can pin and restore Rendezvous $version."
|
||||||
|
done
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
version="${1:?usage: verify-release.sh VERSION [RELEASE_DIRECTORY] [PHASE]}"
|
||||||
|
release_dir="${2:-$root/artifacts/release/$version}"
|
||||||
|
phase="${3:-build}"
|
||||||
|
|
||||||
|
python3 "$root/eng/release_artifacts.py" verify \
|
||||||
|
--root "$root" \
|
||||||
|
--release-dir "$release_dir" \
|
||||||
|
--version "$version" \
|
||||||
|
--phase "$phase"
|
||||||
@@ -7,10 +7,12 @@
|
|||||||
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
|
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
|
||||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
|
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
|
||||||
|
<PackageTags>final-factory;multiplayer;nat;godot;litenetlib</PackageTags>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
|
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
|
||||||
<PackageReference Include="LiteNetLib" />
|
<PackageReference Include="LiteNetLib" />
|
||||||
<None Update="README.md" Pack="true" PackagePath="\" />
|
<None Update="README.md" Pack="true" PackagePath="\" />
|
||||||
|
<None Include="../../CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -67,12 +67,17 @@ factory does not open a socket, and synchronized events must remain enabled:
|
|||||||
```csharp
|
```csharp
|
||||||
RendezvousNetListener networkEvents = new();
|
RendezvousNetListener networkEvents = new();
|
||||||
NetManager gameplayNetManager = networkEvents.CreateManager();
|
NetManager gameplayNetManager = networkEvents.CreateManager();
|
||||||
|
gameplayNetManager.ChannelsCount = 3; // example: configure the game protocol first
|
||||||
if (!gameplayNetManager.Start(0))
|
if (!gameplayNetManager.Start(0))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The gameplay UDP socket could not start.");
|
throw new InvalidOperationException("The gameplay UDP socket could not start.");
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
LiteNetLib defaults to one QoS channel. Set `ChannelsCount` before `Start` when
|
||||||
|
the game protocol uses more than one; both game processes must agree. Rendezvous
|
||||||
|
does not reserve or reinterpret any gameplay channel.
|
||||||
|
|
||||||
The host polls join invitations asynchronously; that method only queues a
|
The host polls join invitations asynchronously; that method only queues a
|
||||||
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
|
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
|
||||||
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
|
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ public interface IRendezvousSessionBrowserClient
|
|||||||
EnvironmentId environmentId,
|
EnvironmentId environmentId,
|
||||||
uint protocolVersion,
|
uint protocolVersion,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
string streamCursor,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IRendezvousJoinClient
|
public interface IRendezvousJoinClient
|
||||||
|
|||||||
@@ -114,6 +114,49 @@ internal sealed class RendezvousHttpTransport
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal async Task<RendezvousClientResult<HttpResponseMessage>> OpenStreamAsync(
|
||||||
|
Func<HttpRequestMessage> requestFactory,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
using CancellationTokenSource requestTimeout =
|
||||||
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
requestTimeout.CancelAfter(_options.RequestTimeout);
|
||||||
|
CancellationToken requestCancellation = requestTimeout.Token;
|
||||||
|
using HttpRequestMessage request = requestFactory();
|
||||||
|
HttpResponseMessage? response = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await _httpClient.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
requestCancellation).ConfigureAwait(false);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
HttpResponseMessage ownedResponse = response;
|
||||||
|
response = null;
|
||||||
|
return RendezvousClientResult.Success(ownedResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiError error = await ReadErrorAsync(response, requestCancellation).ConfigureAwait(false);
|
||||||
|
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
|
||||||
|
return RendezvousClientResult.Failure<HttpResponseMessage>(
|
||||||
|
error.Code,
|
||||||
|
error.Message,
|
||||||
|
retryAfter);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<HttpResponseMessage>(
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
"The Rendezvous event stream could not be opened.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
response?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static HttpRequestMessage JsonRequest<T>(
|
internal static HttpRequestMessage JsonRequest<T>(
|
||||||
HttpMethod method,
|
HttpMethod method,
|
||||||
string uri,
|
string uri,
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
|
|||||||
{
|
{
|
||||||
ContractVersion = request.ContractVersion,
|
ContractVersion = request.ContractVersion,
|
||||||
LeaseToken = session.LeaseToken,
|
LeaseToken = session.LeaseToken,
|
||||||
|
RegionId = request.RegionId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
Visibility = request.Visibility,
|
||||||
BuildVersion = request.BuildVersion,
|
BuildVersion = request.BuildVersion,
|
||||||
DisplayName = request.DisplayName,
|
DisplayName = request.DisplayName,
|
||||||
Capacity = CopyCapacity(request.Capacity),
|
Capacity = CopyCapacity(request.Capacity),
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Client;
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
@@ -106,5 +110,264 @@ public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserCl
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
|
public async IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
string streamCursor,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(streamCursor)
|
||||||
|
|| !ContractValidation.IsCursorValid(streamCursor))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A valid snapshot stream cursor is required.", nameof(streamCursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
string query = $"v1/sessions/stream?contractVersion={request.ContractVersion}"
|
||||||
|
+ $"&gameId={Escape(request.GameId.Value)}"
|
||||||
|
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
|
||||||
|
+ $"&protocolVersion={request.ProtocolVersion}"
|
||||||
|
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
|
||||||
|
+ (request.RegionId.HasValue ? $"®ionId={Escape(request.RegionId.Value.Value)}" : string.Empty);
|
||||||
|
RendezvousClientResult<HttpResponseMessage> opened = await _transport.OpenStreamAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
HttpRequestMessage message = new(HttpMethod.Get, query);
|
||||||
|
message.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||||
|
message.Headers.TryAddWithoutValidation("Last-Event-ID", streamCursor);
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!opened.IsSuccess || opened.Value is null)
|
||||||
|
{
|
||||||
|
yield return RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||||
|
opened.Error,
|
||||||
|
opened.Message,
|
||||||
|
opened.RetryAfterSeconds);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using HttpResponseMessage response = opened.Value;
|
||||||
|
if (!string.Equals(
|
||||||
|
response.Content.Headers.ContentType?.MediaType,
|
||||||
|
"text/event-stream",
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
yield return RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an invalid event-stream content type.");
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using Stream source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||||
|
using SseLineReader reader = new(source);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
SseReadResult? read = null;
|
||||||
|
RendezvousClientResult<SessionStreamEvent>? readFailure = null;
|
||||||
|
bool cancelled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
read = await ReadEventAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
cancelled = true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or JsonException or InvalidDataException)
|
||||||
|
{
|
||||||
|
readFailure = RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an invalid or oversized event stream.");
|
||||||
|
}
|
||||||
|
if (cancelled)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
if (readFailure is not null)
|
||||||
|
{
|
||||||
|
yield return readFailure;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read!.EndOfStream)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
yield return read.Result!;
|
||||||
|
if (!read.Result!.IsSuccess)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SseReadResult> ReadEventAsync(
|
||||||
|
SseLineReader reader,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? eventName = null;
|
||||||
|
string? id = null;
|
||||||
|
string? data = null;
|
||||||
|
int bytes = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
string? line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (line is null)
|
||||||
|
{
|
||||||
|
return eventName is null && id is null && data is null
|
||||||
|
? SseReadResult.End
|
||||||
|
: throw new InvalidDataException("The final SSE event was incomplete.");
|
||||||
|
}
|
||||||
|
bytes += Encoding.UTF8.GetByteCount(line) + 1;
|
||||||
|
if (bytes > ContractLimits.SessionStreamEventMaxBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("The SSE event exceeded the contract limit.");
|
||||||
|
}
|
||||||
|
if (line.Length == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("event: ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
eventName = line[7..];
|
||||||
|
}
|
||||||
|
else if (line.StartsWith("id: ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
id = line[4..];
|
||||||
|
}
|
||||||
|
else if (line.StartsWith("data: ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
data = line[6..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionStreamEvent? item = data is null
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Deserialize<SessionStreamEvent>(data, ContractJson.Options);
|
||||||
|
if (item is null
|
||||||
|
|| !string.Equals(item.Cursor, id, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(eventName, EventName(item.Kind), StringComparison.Ordinal)
|
||||||
|
|| !IsValidShape(item))
|
||||||
|
{
|
||||||
|
return new(false, RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an invalid event envelope."));
|
||||||
|
}
|
||||||
|
return new(false, RendezvousClientResult.Success(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EventName(SessionStreamEventKind kind) => kind switch
|
||||||
|
{
|
||||||
|
SessionStreamEventKind.SessionUpsert => "session_upsert",
|
||||||
|
SessionStreamEventKind.SessionRemove => "session_remove",
|
||||||
|
SessionStreamEventKind.Reset => "reset",
|
||||||
|
SessionStreamEventKind.Keepalive => "keepalive",
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool IsValidShape(SessionStreamEvent item) =>
|
||||||
|
item.ContractVersion == ContractLimits.ContractVersion
|
||||||
|
&& !string.IsNullOrWhiteSpace(item.Cursor)
|
||||||
|
&& ContractValidation.IsCursorValid(item.Cursor)
|
||||||
|
&& (item.Kind == SessionStreamEventKind.SessionUpsert
|
||||||
|
&& item.Session is not null
|
||||||
|
&& IsValidListing(item.Session)
|
||||||
|
&& item.ListingId is null
|
||||||
|
|| item.Kind == SessionStreamEventKind.SessionRemove
|
||||||
|
&& item.Session is null
|
||||||
|
&& item.ListingId.HasValue
|
||||||
|
&& item.ListingId.Value.Value != Guid.Empty
|
||||||
|
|| item.Kind is SessionStreamEventKind.Reset or SessionStreamEventKind.Keepalive
|
||||||
|
&& item.Session is null
|
||||||
|
&& item.ListingId is null);
|
||||||
|
|
||||||
|
private static bool IsValidListing(SessionListing listing) =>
|
||||||
|
listing.ContractVersion == ContractLimits.ContractVersion
|
||||||
|
&& listing.ListingId.Value != Guid.Empty
|
||||||
|
&& !string.IsNullOrWhiteSpace(listing.GameId.Value)
|
||||||
|
&& !string.IsNullOrWhiteSpace(listing.EnvironmentId.Value)
|
||||||
|
&& !string.IsNullOrWhiteSpace(listing.RegionId.Value)
|
||||||
|
&& listing.ProtocolVersion != 0
|
||||||
|
&& ContractValidation.IsBuildVersionValid(listing.BuildVersion)
|
||||||
|
&& ContractValidation.IsDisplayNameValid(listing.DisplayName)
|
||||||
|
&& listing.Visibility == ListingVisibility.Public
|
||||||
|
&& Enum.IsDefined(typeof(PublisherTrustMode), listing.PublisherTrustMode)
|
||||||
|
&& ContractValidation.IsCapacityValid(listing.Capacity)
|
||||||
|
&& ContractValidation.IsMetadataValid(listing.Metadata)
|
||||||
|
&& (listing.DedicatedFallback is null
|
||||||
|
|| ContractValidation.IsNetworkEndpointValid(listing.DedicatedFallback));
|
||||||
|
|
||||||
|
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
|
||||||
|
|
||||||
|
private sealed class SseReadResult
|
||||||
|
{
|
||||||
|
public SseReadResult(
|
||||||
|
bool endOfStream,
|
||||||
|
RendezvousClientResult<SessionStreamEvent>? result)
|
||||||
|
{
|
||||||
|
EndOfStream = endOfStream;
|
||||||
|
Result = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool EndOfStream { get; }
|
||||||
|
public RendezvousClientResult<SessionStreamEvent>? Result { get; }
|
||||||
|
public static SseReadResult End { get; } = new(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SseLineReader(Stream source) : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly UTF8Encoding Utf8 = new(false, true);
|
||||||
|
private readonly byte[] _buffer = new byte[4096];
|
||||||
|
private readonly MemoryStream _line = new();
|
||||||
|
private int _offset;
|
||||||
|
private int _count;
|
||||||
|
|
||||||
|
public async Task<string?> ReadLineAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (_offset >= _count)
|
||||||
|
{
|
||||||
|
_count = await source.ReadAsync(
|
||||||
|
_buffer.AsMemory(),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
_offset = 0;
|
||||||
|
if (_count == 0)
|
||||||
|
{
|
||||||
|
if (_line.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return TakeLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte value = _buffer[_offset++];
|
||||||
|
if (value == (byte)'\n')
|
||||||
|
{
|
||||||
|
return TakeLine();
|
||||||
|
}
|
||||||
|
if (_line.Length >= ContractLimits.SessionStreamEventMaxBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("An SSE line exceeded the contract limit.");
|
||||||
|
}
|
||||||
|
_line.WriteByte(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _line.Dispose();
|
||||||
|
|
||||||
|
private string TakeLine()
|
||||||
|
{
|
||||||
|
byte[] bytes = _line.ToArray();
|
||||||
|
_line.SetLength(0);
|
||||||
|
int length = bytes.Length > 0 && bytes[^1] == (byte)'\r'
|
||||||
|
? bytes.Length - 1
|
||||||
|
: bytes.Length;
|
||||||
|
return Utf8.GetString(bytes, 0, length);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
".NETStandard,Version=v2.1": {
|
".NETStandard,Version=v2.1": {
|
||||||
"LiteNetLib": {
|
"LiteNetLib": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[2.1.4, )",
|
"requested": "[2.1.4, 2.1.4]",
|
||||||
"resolved": "2.1.4",
|
"resolved": "2.1.4",
|
||||||
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
|
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public static class ContractLimits
|
|||||||
public const int ContractVersion = 1;
|
public const int ContractVersion = 1;
|
||||||
public const int HttpRequestMaxBytes = 16 * 1024;
|
public const int HttpRequestMaxBytes = 16 * 1024;
|
||||||
public const int BrowserResponseMaxBytes = 256 * 1024;
|
public const int BrowserResponseMaxBytes = 256 * 1024;
|
||||||
|
public const int SessionStreamEventMaxBytes = 32 * 1024;
|
||||||
public const int UdpDatagramMaxBytes = 1_200;
|
public const int UdpDatagramMaxBytes = 1_200;
|
||||||
public const int MetadataMaxBytes = 4 * 1024;
|
public const int MetadataMaxBytes = 4 * 1024;
|
||||||
public const int MetadataMaxKeys = 32;
|
public const int MetadataMaxKeys = 32;
|
||||||
|
|||||||
@@ -5,9 +5,13 @@
|
|||||||
<RootNamespace>FinalFactory.Rendezvous.Contracts</RootNamespace>
|
<RootNamespace>FinalFactory.Rendezvous.Contracts</RootNamespace>
|
||||||
<IsPackable>true</IsPackable>
|
<IsPackable>true</IsPackable>
|
||||||
<PackageId>FinalFactory.Rendezvous.Contracts</PackageId>
|
<PackageId>FinalFactory.Rendezvous.Contracts</PackageId>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<Description>Versioned transport-neutral contracts for Final Factory Rendezvous.</Description>
|
<Description>Versioned transport-neutral contracts for Final Factory Rendezvous.</Description>
|
||||||
|
<PackageTags>final-factory;multiplayer;contracts;godot</PackageTags>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="System.Text.Json" />
|
<PackageReference Include="System.Text.Json" />
|
||||||
|
<None Update="README.md" Pack="true" PackagePath="\" />
|
||||||
|
<None Include="../../CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ public sealed class UpdateSessionRequest
|
|||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string LeaseToken { get; set; } = string.Empty;
|
public string LeaseToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public RegionId? RegionId { get; set; }
|
||||||
|
public uint? ProtocolVersion { get; set; }
|
||||||
|
public ListingVisibility? Visibility { get; set; }
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string BuildVersion { get; set; } = string.Empty;
|
public string BuildVersion { get; set; } = string.Empty;
|
||||||
|
|
||||||
@@ -194,6 +198,32 @@ public sealed class BrowseSessionsResponse
|
|||||||
public List<SessionListing> Items { get; set; } = [];
|
public List<SessionListing> Items { get; set; } = [];
|
||||||
|
|
||||||
public string? NextCursor { get; set; }
|
public string? NextCursor { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string StreamCursor { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SessionStreamEventKind
|
||||||
|
{
|
||||||
|
SessionUpsert = 1,
|
||||||
|
SessionRemove = 2,
|
||||||
|
Reset = 3,
|
||||||
|
Keepalive = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SessionStreamEvent
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public SessionStreamEventKind Kind { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string Cursor { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public SessionListing? Session { get; set; }
|
||||||
|
public SessionListingId? ListingId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class GetSessionResponse
|
public sealed class GetSessionResponse
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# FinalFactory.Rendezvous.Contracts
|
||||||
|
|
||||||
|
Transport-neutral v1 HTTP/UDP contract types for Final Factory Rendezvous.
|
||||||
|
The package targets `netstandard2.1`, contains no Godot or LiteNetLib dependency,
|
||||||
|
and is versioned with the Client package and server release.
|
||||||
|
|
||||||
|
Compatibility and migration policy is maintained in the repository's
|
||||||
|
`docs/releases/README.md` document.
|
||||||
@@ -12,6 +12,8 @@ internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Val
|
|||||||
internal sealed class SessionBrowserService(
|
internal sealed class SessionBrowserService(
|
||||||
IEphemeralRendezvousStore store,
|
IEphemeralRendezvousStore store,
|
||||||
SessionBrowserCursorCodec cursors,
|
SessionBrowserCursorCodec cursors,
|
||||||
|
SessionStreamCursorCodec streamCursors,
|
||||||
|
SessionChangeJournal changes,
|
||||||
IWallClock clock)
|
IWallClock clock)
|
||||||
{
|
{
|
||||||
public BrowserServiceResult<BrowseSessionsResponse> Browse(
|
public BrowserServiceResult<BrowseSessionsResponse> Browse(
|
||||||
@@ -45,9 +47,25 @@ internal sealed class SessionBrowserService(
|
|||||||
request.PageSize + 1,
|
request.PageSize + 1,
|
||||||
after,
|
after,
|
||||||
request.ExcludeFull);
|
request.ExcludeFull);
|
||||||
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
|
StoreResult<IReadOnlyList<StoredListing>> found = default!;
|
||||||
query,
|
long streamRevision = 0;
|
||||||
cancellationToken);
|
bool stableSnapshot = false;
|
||||||
|
for (int attempt = 0; attempt < 3; attempt++)
|
||||||
|
{
|
||||||
|
long before = changes.CurrentRevision;
|
||||||
|
found = store.BrowseVisibleListings(query, cancellationToken);
|
||||||
|
long afterRevision = changes.CurrentRevision;
|
||||||
|
if (before == afterRevision)
|
||||||
|
{
|
||||||
|
streamRevision = afterRevision;
|
||||||
|
stableSnapshot = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stableSnapshot)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.ServiceUnavailable);
|
||||||
|
}
|
||||||
if (!found.Succeeded || found.Value is null)
|
if (!found.Succeeded || found.Value is null)
|
||||||
{
|
{
|
||||||
return new(found.Code == StoreResultCode.ServiceUnavailable
|
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||||
@@ -65,7 +83,12 @@ internal sealed class SessionBrowserService(
|
|||||||
string? nextCursor = hasMore
|
string? nextCursor = hasMore
|
||||||
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
|
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
|
||||||
: null;
|
: null;
|
||||||
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
|
BrowseSessionsResponse response = new()
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
NextCursor = nextCursor,
|
||||||
|
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
|
||||||
|
};
|
||||||
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
|
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
|
||||||
<= ContractLimits.BrowserResponseMaxBytes)
|
<= ContractLimits.BrowserResponseMaxBytes)
|
||||||
{
|
{
|
||||||
@@ -76,7 +99,10 @@ internal sealed class SessionBrowserService(
|
|||||||
hasMore = true;
|
hasMore = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
|
return new(RendezvousErrorCode.None, new BrowseSessionsResponse
|
||||||
|
{
|
||||||
|
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public BrowserServiceResult<GetSessionResponse> Get(
|
public BrowserServiceResult<GetSessionResponse> Get(
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed record SessionChangeJournalOptions
|
||||||
|
{
|
||||||
|
public int ReplayCapacity { get; init; } = 4096;
|
||||||
|
public int MaximumSubscribers { get; init; } = 256;
|
||||||
|
public int MaximumSubscribersPerTenant { get; init; } = 64;
|
||||||
|
public int MaximumBatchSize { get; init; } = 128;
|
||||||
|
public TimeSpan CoalesceInterval { get; init; } = TimeSpan.FromMilliseconds(50);
|
||||||
|
public TimeSpan KeepaliveInterval { get; init; } = TimeSpan.FromSeconds(15);
|
||||||
|
public TimeSpan MaximumConnectionDuration { get; init; } = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (ReplayCapacity is < 64 or > 65_536
|
||||||
|
|| MaximumSubscribers is < 1 or > 4096
|
||||||
|
|| MaximumSubscribersPerTenant < 1
|
||||||
|
|| MaximumSubscribersPerTenant > MaximumSubscribers
|
||||||
|
|| MaximumBatchSize is < 1 or > 1024
|
||||||
|
|| CoalesceInterval < TimeSpan.Zero
|
||||||
|
|| CoalesceInterval > TimeSpan.FromSeconds(1)
|
||||||
|
|| KeepaliveInterval < TimeSpan.FromSeconds(1)
|
||||||
|
|| KeepaliveInterval > TimeSpan.FromMinutes(1)
|
||||||
|
|| MaximumConnectionDuration < KeepaliveInterval
|
||||||
|
|| MaximumConnectionDuration > TimeSpan.FromMinutes(30))
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(SessionChangeJournalOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SessionChange(
|
||||||
|
long Revision,
|
||||||
|
SessionListingProjection? Before,
|
||||||
|
SessionListingProjection? After)
|
||||||
|
{
|
||||||
|
public SessionListingId ListingId => (After ?? Before)!.Listing.ListingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SessionChangeBatch(
|
||||||
|
long CurrentRevision,
|
||||||
|
bool RequiresReset,
|
||||||
|
IReadOnlyList<SessionChange> Changes);
|
||||||
|
|
||||||
|
internal sealed class SessionListingProjection
|
||||||
|
{
|
||||||
|
private SessionListingProjection(SessionListing listing, bool visible)
|
||||||
|
{
|
||||||
|
Listing = listing;
|
||||||
|
Visible = visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionListing Listing { get; }
|
||||||
|
public bool Visible { get; }
|
||||||
|
|
||||||
|
public static SessionListingProjection From(StoredListing stored) => new(
|
||||||
|
new SessionListing
|
||||||
|
{
|
||||||
|
ListingId = stored.Definition.ListingId,
|
||||||
|
GameId = stored.Definition.Scope.GameId,
|
||||||
|
EnvironmentId = stored.Definition.Scope.EnvironmentId,
|
||||||
|
RegionId = stored.Definition.RegionId,
|
||||||
|
ProtocolVersion = stored.Definition.ProtocolVersion,
|
||||||
|
BuildVersion = stored.Definition.BuildVersion,
|
||||||
|
DisplayName = stored.Definition.DisplayName,
|
||||||
|
Visibility = stored.Definition.Visibility,
|
||||||
|
PublisherTrustMode = stored.Definition.TrustMode,
|
||||||
|
Capacity = new SessionCapacity
|
||||||
|
{
|
||||||
|
CurrentPlayers = stored.Definition.CurrentPlayers,
|
||||||
|
MaximumPlayers = stored.Definition.MaximumPlayers,
|
||||||
|
},
|
||||||
|
Metadata = new Dictionary<string, string>(stored.Definition.Metadata, StringComparer.Ordinal),
|
||||||
|
DedicatedFallback = StoredListing.CopyEndpoint(stored.Definition.DedicatedFallback),
|
||||||
|
},
|
||||||
|
stored.HasFreshPresence && stored.Definition.Visibility == ListingVisibility.Public);
|
||||||
|
|
||||||
|
public bool Matches(VisibleListingQuery query) => Visible
|
||||||
|
&& Listing.GameId == query.Scope.GameId
|
||||||
|
&& Listing.EnvironmentId == query.Scope.EnvironmentId
|
||||||
|
&& Listing.ProtocolVersion == query.ProtocolVersion
|
||||||
|
&& (!query.RegionId.HasValue || Listing.RegionId == query.RegionId.Value)
|
||||||
|
&& (!query.ExcludeFull
|
||||||
|
|| Listing.Capacity.CurrentPlayers < Listing.Capacity.MaximumPlayers);
|
||||||
|
|
||||||
|
public static bool Equivalent(SessionListingProjection? left, SessionListingProjection? right)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(left, right))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (left is null || right is null || left.Visible != right.Visible)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionListing a = left.Listing;
|
||||||
|
SessionListing b = right.Listing;
|
||||||
|
return a.ListingId == b.ListingId
|
||||||
|
&& a.GameId == b.GameId
|
||||||
|
&& a.EnvironmentId == b.EnvironmentId
|
||||||
|
&& a.RegionId == b.RegionId
|
||||||
|
&& a.ProtocolVersion == b.ProtocolVersion
|
||||||
|
&& string.Equals(a.BuildVersion, b.BuildVersion, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(a.DisplayName, b.DisplayName, StringComparison.Ordinal)
|
||||||
|
&& a.Visibility == b.Visibility
|
||||||
|
&& a.PublisherTrustMode == b.PublisherTrustMode
|
||||||
|
&& a.Capacity.CurrentPlayers == b.Capacity.CurrentPlayers
|
||||||
|
&& a.Capacity.MaximumPlayers == b.Capacity.MaximumPlayers
|
||||||
|
&& a.Metadata.Count == b.Metadata.Count
|
||||||
|
&& a.Metadata.All(item => b.Metadata.TryGetValue(item.Key, out string? value)
|
||||||
|
&& string.Equals(item.Value, value, StringComparison.Ordinal))
|
||||||
|
&& EndpointEquals(a.DedicatedFallback, b.DedicatedFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool EndpointEquals(NetworkEndpoint? left, NetworkEndpoint? right) =>
|
||||||
|
left is null && right is null
|
||||||
|
|| left is not null && right is not null
|
||||||
|
&& left.AddressFamily == right.AddressFamily
|
||||||
|
&& string.Equals(left.Address, right.Address, StringComparison.Ordinal)
|
||||||
|
&& left.Port == right.Port;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionChangeJournal
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly SessionChangeJournalOptions _options;
|
||||||
|
private readonly Queue<SessionChange> _changes = [];
|
||||||
|
private TaskCompletionSource<long> _changed = NewSignal();
|
||||||
|
private long _revision;
|
||||||
|
private int _subscribers;
|
||||||
|
private readonly Dictionary<TenantScope, int> _subscribersByTenant = [];
|
||||||
|
|
||||||
|
public SessionChangeJournal(SessionChangeJournalOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionChangeJournalOptions Options => _options;
|
||||||
|
|
||||||
|
public long CurrentRevision
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _revision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Publish(StoredListing? before, StoredListing? after)
|
||||||
|
{
|
||||||
|
SessionListingProjection? previous = before is null ? null : SessionListingProjection.From(before);
|
||||||
|
SessionListingProjection? current = after is null ? null : SessionListingProjection.From(after);
|
||||||
|
if (SessionListingProjection.Equivalent(previous, current)
|
||||||
|
|| previous is { Visible: false } && current is null
|
||||||
|
|| previous is null && current is { Visible: false })
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskCompletionSource<long> signal;
|
||||||
|
long revision;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
revision = ++_revision;
|
||||||
|
_changes.Enqueue(new SessionChange(revision, previous, current));
|
||||||
|
while (_changes.Count > _options.ReplayCapacity)
|
||||||
|
{
|
||||||
|
_changes.Dequeue();
|
||||||
|
}
|
||||||
|
signal = _changed;
|
||||||
|
_changed = NewSignal();
|
||||||
|
}
|
||||||
|
signal.TrySetResult(revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionChangeBatch ReadAfter(long revision)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
long oldest = _changes.TryPeek(out SessionChange? first)
|
||||||
|
? first.Revision
|
||||||
|
: _revision + 1;
|
||||||
|
if (revision < oldest - 1 || revision > _revision)
|
||||||
|
{
|
||||||
|
return new(_revision, true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionChange[] changes = _changes
|
||||||
|
.Where(change => change.Revision > revision)
|
||||||
|
.Take(_options.MaximumBatchSize)
|
||||||
|
.ToArray();
|
||||||
|
return new(_revision, false, changes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> WaitForChangeAsync(
|
||||||
|
long revision,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Task<long> signal;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_revision > revision)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
signal = _changed.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await signal.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TrySubscribe(TenantScope scope, out IDisposable? lease)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_subscribers >= _options.MaximumSubscribers
|
||||||
|
|| _subscribersByTenant.GetValueOrDefault(scope)
|
||||||
|
>= _options.MaximumSubscribersPerTenant)
|
||||||
|
{
|
||||||
|
lease = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_subscribers++;
|
||||||
|
_subscribersByTenant[scope] = _subscribersByTenant.GetValueOrDefault(scope) + 1;
|
||||||
|
lease = new Subscription(this, scope);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Release(TenantScope scope)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_subscribers--;
|
||||||
|
int remaining = _subscribersByTenant[scope] - 1;
|
||||||
|
if (remaining == 0)
|
||||||
|
{
|
||||||
|
_subscribersByTenant.Remove(scope);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_subscribersByTenant[scope] = remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskCompletionSource<long> NewSignal() => new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
private sealed class Subscription(
|
||||||
|
SessionChangeJournal owner,
|
||||||
|
TenantScope scope) : IDisposable
|
||||||
|
{
|
||||||
|
private SessionChangeJournal? _owner = owner;
|
||||||
|
|
||||||
|
public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Release(scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed class SessionStreamCursorCodec : IDisposable
|
||||||
|
{
|
||||||
|
private const string Prefix = "rvs1";
|
||||||
|
private readonly EphemeralCursorProtector _protector = new();
|
||||||
|
|
||||||
|
public string Encode(VisibleListingQuery query, long revision, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(revision);
|
||||||
|
SessionStreamCursorPayload payload = new()
|
||||||
|
{
|
||||||
|
GameId = query.Scope.GameId.Value,
|
||||||
|
EnvironmentId = query.Scope.EnvironmentId.Value,
|
||||||
|
ProtocolVersion = query.ProtocolVersion,
|
||||||
|
RegionId = query.RegionId?.Value,
|
||||||
|
ExcludeFull = query.ExcludeFull,
|
||||||
|
Revision = revision,
|
||||||
|
ExpiresAtUnixSeconds = now.AddMinutes(10).ToUnixTimeSeconds(),
|
||||||
|
};
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _protector.Protect(Prefix, encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDecode(
|
||||||
|
string? cursor,
|
||||||
|
VisibleListingQuery query,
|
||||||
|
DateTimeOffset now,
|
||||||
|
out long revision)
|
||||||
|
{
|
||||||
|
revision = 0;
|
||||||
|
if (cursor is null || !_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionStreamCursorPayload? payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.Deserialize<SessionStreamCursorPayload>(
|
||||||
|
encodedPayload,
|
||||||
|
ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload is null
|
||||||
|
|| payload.Revision < 0
|
||||||
|
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||||
|
|| !string.Equals(payload.GameId, query.Scope.GameId.Value, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(payload.EnvironmentId, query.Scope.EnvironmentId.Value, StringComparison.Ordinal)
|
||||||
|
|| payload.ProtocolVersion != query.ProtocolVersion
|
||||||
|
|| !string.Equals(payload.RegionId, query.RegionId?.Value, StringComparison.Ordinal)
|
||||||
|
|| payload.ExcludeFull != query.ExcludeFull)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
revision = payload.Revision;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _protector.Dispose();
|
||||||
|
|
||||||
|
public override string ToString() => "[SessionStreamCursorCodec: key and cursors redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionStreamCursorPayload
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public string GameId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string EnvironmentId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public uint ProtocolVersion { get; set; }
|
||||||
|
|
||||||
|
public string? RegionId { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool ExcludeFull { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public long Revision { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public long ExpiresAtUnixSeconds { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed record SessionStreamReadResult(
|
||||||
|
bool RequiresReset,
|
||||||
|
IReadOnlyList<SessionStreamEvent> Events);
|
||||||
|
|
||||||
|
internal sealed class SessionStreamSubscription : IDisposable
|
||||||
|
{
|
||||||
|
private IDisposable? _lease;
|
||||||
|
|
||||||
|
public SessionStreamSubscription(
|
||||||
|
VisibleListingQuery query,
|
||||||
|
long revision,
|
||||||
|
bool requiresReset,
|
||||||
|
IDisposable lease)
|
||||||
|
{
|
||||||
|
Query = query;
|
||||||
|
Revision = revision;
|
||||||
|
RequiresReset = requiresReset;
|
||||||
|
_lease = lease;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VisibleListingQuery Query { get; }
|
||||||
|
public long Revision { get; set; }
|
||||||
|
public bool RequiresReset { get; set; }
|
||||||
|
|
||||||
|
public void Dispose() => Interlocked.Exchange(ref _lease, null)?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionStreamService(
|
||||||
|
SessionChangeJournal changes,
|
||||||
|
SessionStreamCursorCodec cursors,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public BrowserServiceResult<SessionStreamSubscription> Subscribe(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
string? cursor)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = Validate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
VisibleListingQuery query = new(
|
||||||
|
new TenantScope(request.GameId, request.EnvironmentId),
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.RegionId,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
ExcludeFull: request.ExcludeFull);
|
||||||
|
if (!changes.TrySubscribe(query.Scope, out IDisposable? lease) || lease is null)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.CapacityExceeded);
|
||||||
|
}
|
||||||
|
bool validCursor = cursors.TryDecode(cursor, query, clock.UtcNow, out long revision);
|
||||||
|
if (!validCursor)
|
||||||
|
{
|
||||||
|
revision = changes.CurrentRevision;
|
||||||
|
}
|
||||||
|
return new(RendezvousErrorCode.None, new SessionStreamSubscription(
|
||||||
|
query,
|
||||||
|
revision,
|
||||||
|
requiresReset: !validCursor,
|
||||||
|
lease));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionStreamReadResult Read(SessionStreamSubscription subscription)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(subscription);
|
||||||
|
if (subscription.RequiresReset)
|
||||||
|
{
|
||||||
|
subscription.RequiresReset = false;
|
||||||
|
return new(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionChangeBatch batch = changes.ReadAfter(subscription.Revision);
|
||||||
|
if (batch.RequiresReset)
|
||||||
|
{
|
||||||
|
subscription.Revision = batch.CurrentRevision;
|
||||||
|
return new(true, []);
|
||||||
|
}
|
||||||
|
if (batch.Changes.Count == 0)
|
||||||
|
{
|
||||||
|
return new(false, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<SessionListingId, PendingDelta> coalesced = [];
|
||||||
|
foreach (SessionChange change in batch.Changes)
|
||||||
|
{
|
||||||
|
bool beforeMatches = change.Before?.Matches(subscription.Query) == true;
|
||||||
|
bool afterMatches = change.After?.Matches(subscription.Query) == true;
|
||||||
|
if (!beforeMatches && !afterMatches)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
coalesced[change.ListingId] = afterMatches
|
||||||
|
? new(change.Revision, SessionStreamEventKind.SessionUpsert, change.After!.Listing)
|
||||||
|
: new(change.Revision, SessionStreamEventKind.SessionRemove, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.Revision = batch.Changes[^1].Revision;
|
||||||
|
SessionStreamEvent[] events = coalesced
|
||||||
|
.OrderBy(static item => item.Value.Revision)
|
||||||
|
.Select(item => ToEvent(item.Key, item.Value, subscription.Query))
|
||||||
|
.ToArray();
|
||||||
|
return new(false, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> WaitForChangeAsync(
|
||||||
|
SessionStreamSubscription subscription,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
bool changed = await changes.WaitForChangeAsync(
|
||||||
|
subscription.Revision,
|
||||||
|
changes.Options.KeepaliveInterval,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (changed && changes.Options.CoalesceInterval > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
await Task.Delay(changes.Options.CoalesceInterval, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionStreamEvent ResetEvent(SessionStreamSubscription subscription) => new()
|
||||||
|
{
|
||||||
|
Kind = SessionStreamEventKind.Reset,
|
||||||
|
Cursor = cursors.Encode(subscription.Query, subscription.Revision, clock.UtcNow),
|
||||||
|
};
|
||||||
|
|
||||||
|
public SessionStreamEvent KeepaliveEvent(SessionStreamSubscription subscription) => new()
|
||||||
|
{
|
||||||
|
Kind = SessionStreamEventKind.Keepalive,
|
||||||
|
Cursor = cursors.Encode(subscription.Query, subscription.Revision, clock.UtcNow),
|
||||||
|
};
|
||||||
|
|
||||||
|
public TimeSpan MaximumConnectionDuration => changes.Options.MaximumConnectionDuration;
|
||||||
|
|
||||||
|
private SessionStreamEvent ToEvent(
|
||||||
|
SessionListingId listingId,
|
||||||
|
PendingDelta delta,
|
||||||
|
VisibleListingQuery query) => new()
|
||||||
|
{
|
||||||
|
Kind = delta.Kind,
|
||||||
|
Cursor = cursors.Encode(query, delta.Revision, clock.UtcNow),
|
||||||
|
Session = delta.Session,
|
||||||
|
ListingId = delta.Kind == SessionStreamEventKind.SessionRemove ? listingId : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
return string.IsNullOrEmpty(request.GameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||||
|
|| request.ProtocolVersion == 0
|
||||||
|
|| request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value)
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PendingDelta(
|
||||||
|
long Revision,
|
||||||
|
SessionStreamEventKind Kind,
|
||||||
|
SessionListing? Session);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Deployment;
|
namespace FinalFactory.Rendezvous.Server.Deployment;
|
||||||
|
|
||||||
internal sealed partial class GracefulDrainService : IHostedService, IDisposable
|
internal sealed class GracefulDrainService : IHostedService, IDisposable
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||||
private readonly InMemoryEphemeralRendezvousStore _store;
|
private readonly InMemoryEphemeralRendezvousStore _store;
|
||||||
@@ -84,15 +84,19 @@ internal sealed partial class GracefulDrainService : IHostedService, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[LoggerMessage(
|
private static readonly Action<ILogger, int, Exception?> DrainStarted = LoggerMessage.Define<int>(
|
||||||
EventId = 1,
|
LogLevel.Information,
|
||||||
Level = LogLevel.Information,
|
new EventId(1, nameof(LogDrainStarted)),
|
||||||
Message = "Graceful drain started with a {DrainDeadlineSeconds}-second deadline")]
|
"Graceful drain started with a {DrainDeadlineSeconds}-second deadline");
|
||||||
private static partial void LogDrainStarted(ILogger logger, int drainDeadlineSeconds);
|
|
||||||
|
|
||||||
[LoggerMessage(
|
private static readonly Action<ILogger, double, Exception?> DrainFinished = LoggerMessage.Define<double>(
|
||||||
EventId = 2,
|
LogLevel.Information,
|
||||||
Level = LogLevel.Information,
|
new EventId(2, nameof(LogDrainFinished)),
|
||||||
Message = "Graceful drain finished after {ElapsedMilliseconds:F0} ms; ephemeral state was cleared")]
|
"Graceful drain finished after {ElapsedMilliseconds:F0} ms; ephemeral state was cleared");
|
||||||
private static partial void LogDrainFinished(ILogger logger, double elapsedMilliseconds);
|
|
||||||
|
private static void LogDrainStarted(ILogger logger, int drainDeadlineSeconds) =>
|
||||||
|
DrainStarted(logger, drainDeadlineSeconds, null);
|
||||||
|
|
||||||
|
private static void LogDrainFinished(ILogger logger, double elapsedMilliseconds) =>
|
||||||
|
DrainFinished(logger, elapsedMilliseconds, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<AssemblyName>FinalFactory.Rendezvous.Server</AssemblyName>
|
<AssemblyName>FinalFactory.Rendezvous.Server</AssemblyName>
|
||||||
<RootNamespace>FinalFactory.Rendezvous.Server</RootNamespace>
|
<RootNamespace>FinalFactory.Rendezvous.Server</RootNamespace>
|
||||||
|
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
|
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
|
||||||
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../docs/api</OpenApiDocumentsDirectory>
|
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../docs/api</OpenApiDocumentsDirectory>
|
||||||
@@ -14,4 +15,77 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||||
|
<_Parameter1>RendezvousMinimumClientVersion</_Parameter1>
|
||||||
|
<_Parameter2>$(MinimumClientVersion)</_Parameter2>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||||
|
<_Parameter1>RendezvousMaximumClientMajorVersion</_Parameter1>
|
||||||
|
<_Parameter2>$(MaximumClientMajorVersion)</_Parameter2>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||||
|
<_Parameter1>RendezvousUdpContractVersion</_Parameter1>
|
||||||
|
<_Parameter2>$(UdpContractVersion)</_Parameter2>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||||
|
<_Parameter1>RendezvousConnectionTicketFormatVersion</_Parameter1>
|
||||||
|
<_Parameter2>$(ConnectionTicketFormatVersion)</_Parameter2>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||||
|
<_Parameter1>RendezvousLiteNetLibMajorVersion</_Parameter1>
|
||||||
|
<_Parameter2>$(LiteNetLibMajorVersion)</_Parameter2>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Roslyn and source generators consume syntax trees in item order. Keep
|
||||||
|
this ordinal manifest explicit so clean builds are byte reproducible. -->
|
||||||
|
<Compile Include="Abuse/AbuseProtectionOptions.cs" />
|
||||||
|
<Compile Include="Abuse/AbuseProtectionService.cs" />
|
||||||
|
<Compile Include="Abuse/HttpAbuseProtectionMiddleware.cs" />
|
||||||
|
<Compile Include="Abuse/TrustedProxyForwarding.cs" />
|
||||||
|
<Compile Include="Browser/EphemeralCursorProtector.cs" />
|
||||||
|
<Compile Include="Browser/SessionBrowserCursorCodec.cs" />
|
||||||
|
<Compile Include="Browser/SessionBrowserService.cs" />
|
||||||
|
<Compile Include="Browser/SessionChangeJournal.cs" />
|
||||||
|
<Compile Include="Browser/SessionStreamCursorCodec.cs" />
|
||||||
|
<Compile Include="Browser/SessionStreamService.cs" />
|
||||||
|
<Compile Include="ConnectionOutcomes/ConnectionOutcomeService.cs" />
|
||||||
|
<Compile Include="Deployment/DeploymentOptions.cs" />
|
||||||
|
<Compile Include="Deployment/GracefulDrainService.cs" />
|
||||||
|
<Compile Include="Http/ContractEndpoints.cs" />
|
||||||
|
<Compile Include="Http/RendezvousExceptionHandler.cs" />
|
||||||
|
<Compile Include="JoinAttempts/JoinAttemptCursorCodec.cs" />
|
||||||
|
<Compile Include="JoinAttempts/JoinAttemptService.cs" />
|
||||||
|
<Compile Include="Observability/AuditOptions.cs" />
|
||||||
|
<Compile Include="Observability/AuditTrail.cs" />
|
||||||
|
<Compile Include="Observability/HealthEndpoints.cs" />
|
||||||
|
<Compile Include="Observability/RendezvousReadiness.cs" />
|
||||||
|
<Compile Include="Observability/RendezvousTelemetry.cs" />
|
||||||
|
<Compile Include="Observability/TelemetryMiddleware.cs" />
|
||||||
|
<Compile Include="Operations/OperatorEndpoints.cs" />
|
||||||
|
<Compile Include="Operations/OperatorModels.cs" />
|
||||||
|
<Compile Include="Operations/OperatorService.cs" />
|
||||||
|
<Compile Include="Operations/ReleaseCompatibility.cs" />
|
||||||
|
<Compile Include="Program.cs" />
|
||||||
|
<Compile Include="Properties/AssemblyInfo.cs" />
|
||||||
|
<Compile Include="Provisioning/GamePolicy.cs" />
|
||||||
|
<Compile Include="Provisioning/GamePolicyRegistry.cs" />
|
||||||
|
<Compile Include="Provisioning/PrincipalCredentialService.cs" />
|
||||||
|
<Compile Include="Provisioning/Principals.cs" />
|
||||||
|
<Compile Include="Provisioning/ProvisioningOptions.cs" />
|
||||||
|
<Compile Include="Provisioning/ProvisioningRuntime.cs" />
|
||||||
|
<Compile Include="Provisioning/PublisherAuthorizationService.cs" />
|
||||||
|
<Compile Include="Provisioning/SecretProviders.cs" />
|
||||||
|
<Compile Include="Provisioning/SigningKeyRing.cs" />
|
||||||
|
<Compile Include="Sessions/EphemeralCapabilityIssuer.cs" />
|
||||||
|
<Compile Include="Sessions/SessionLeaseService.cs" />
|
||||||
|
<Compile Include="State/EphemeralStateContracts.cs" />
|
||||||
|
<Compile Include="State/InMemoryEphemeralRendezvousStore.cs" />
|
||||||
|
<Compile Include="State/StoreResultMapping.cs" />
|
||||||
|
<Compile Include="Transport/LiteNetNatRequestCodec.cs" />
|
||||||
|
<Compile Include="Transport/NatMediationProcessor.cs" />
|
||||||
|
<Compile Include="Transport/UdpMediatorOptions.cs" />
|
||||||
|
<Compile Include="Transport/UdpMediatorService.cs" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
using FinalFactory.Rendezvous.Server.Abuse;
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
using FinalFactory.Rendezvous.Server.Browser;
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
@@ -69,6 +70,12 @@ internal static class ContractEndpoints
|
|||||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("BrowseSessions");
|
.WithName("BrowseSessions");
|
||||||
|
sessions.MapGet("/stream", StreamSessions)
|
||||||
|
.Produces<SessionStreamEvent>(StatusCodes.Status200OK, contentType: "text/event-stream")
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
.WithName("StreamSessions");
|
||||||
sessions.MapGet("/{listingId}", GetSession)
|
sessions.MapGet("/{listingId}", GetSession)
|
||||||
.Produces<GetSessionResponse>()
|
.Produces<GetSessionResponse>()
|
||||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
@@ -349,6 +356,123 @@ internal static class ContractEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> StreamSessions(
|
||||||
|
[FromQuery] int contractVersion,
|
||||||
|
[FromQuery] string gameId,
|
||||||
|
[FromQuery] string environmentId,
|
||||||
|
[FromQuery] uint protocolVersion,
|
||||||
|
[FromQuery] string? regionId,
|
||||||
|
[FromQuery] bool? excludeFull,
|
||||||
|
[FromQuery] string? streamCursor,
|
||||||
|
[FromHeader(Name = "Last-Event-ID")] string? lastEventId,
|
||||||
|
[FromServices] SessionStreamService streams,
|
||||||
|
[FromServices] AbuseProtectionService abuseProtection,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||||
|
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|
||||||
|
|| regionId is not null && !RegionId.TryParse(regionId, out _))
|
||||||
|
{
|
||||||
|
return Error(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
if (!TryAcquireIdentity(
|
||||||
|
abuseProtection,
|
||||||
|
httpContext,
|
||||||
|
"StreamSessions",
|
||||||
|
Tenant(parsedGameId, parsedEnvironmentId),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||||
|
{
|
||||||
|
return RateLimited(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (abuseLease)
|
||||||
|
{
|
||||||
|
BrowserServiceResult<SessionStreamSubscription> subscribed = streams.Subscribe(new()
|
||||||
|
{
|
||||||
|
ContractVersion = contractVersion,
|
||||||
|
GameId = parsedGameId,
|
||||||
|
EnvironmentId = parsedEnvironmentId,
|
||||||
|
ProtocolVersion = protocolVersion,
|
||||||
|
RegionId = regionId is null ? null : new RegionId(regionId),
|
||||||
|
ExcludeFull = excludeFull ?? false,
|
||||||
|
}, string.IsNullOrEmpty(lastEventId) ? streamCursor : lastEventId);
|
||||||
|
if (!subscribed.Succeeded || subscribed.Value is null)
|
||||||
|
{
|
||||||
|
return Error(subscribed.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
using SessionStreamSubscription subscription = subscribed.Value;
|
||||||
|
using CancellationTokenSource duration = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
cancellationToken);
|
||||||
|
duration.CancelAfter(streams.MaximumConnectionDuration);
|
||||||
|
HttpResponse response = httpContext.Response;
|
||||||
|
response.StatusCode = StatusCodes.Status200OK;
|
||||||
|
response.ContentType = "text/event-stream";
|
||||||
|
response.Headers.CacheControl = "no-cache, no-store";
|
||||||
|
response.Headers["X-Accel-Buffering"] = "no";
|
||||||
|
await response.StartAsync(duration.Token).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!duration.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
SessionStreamReadResult read = streams.Read(subscription);
|
||||||
|
if (read.RequiresReset)
|
||||||
|
{
|
||||||
|
await WriteSseAsync(response, streams.ResetEvent(subscription), duration.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (read.Events.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (SessionStreamEvent item in read.Events)
|
||||||
|
{
|
||||||
|
await WriteSseAsync(response, item, duration.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool changed = await streams.WaitForChangeAsync(subscription, duration.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!changed)
|
||||||
|
{
|
||||||
|
await WriteSseAsync(
|
||||||
|
response,
|
||||||
|
streams.KeepaliveEvent(subscription),
|
||||||
|
duration.Token).ConfigureAwait(false);
|
||||||
|
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (duration.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
return Results.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteSseAsync(
|
||||||
|
HttpResponse response,
|
||||||
|
SessionStreamEvent item,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string eventName = item.Kind switch
|
||||||
|
{
|
||||||
|
SessionStreamEventKind.SessionUpsert => "session_upsert",
|
||||||
|
SessionStreamEventKind.SessionRemove => "session_remove",
|
||||||
|
SessionStreamEventKind.Reset => "reset",
|
||||||
|
_ => "keepalive",
|
||||||
|
};
|
||||||
|
string data = JsonSerializer.Serialize(item, ContractJson.Options);
|
||||||
|
await response.WriteAsync(
|
||||||
|
$"id: {item.Cursor}\nevent: {eventName}\ndata: {data}\n\n",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult GetSession(
|
private static IResult GetSession(
|
||||||
SessionListingId listingId,
|
SessionListingId listingId,
|
||||||
[FromQuery] int contractVersion,
|
[FromQuery] int contractVersion,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Diagnostics;
|
|||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Http;
|
namespace FinalFactory.Rendezvous.Server.Http;
|
||||||
|
|
||||||
internal sealed partial class RendezvousExceptionHandler(
|
internal sealed class RendezvousExceptionHandler(
|
||||||
ILogger<RendezvousExceptionHandler> logger) : IExceptionHandler
|
ILogger<RendezvousExceptionHandler> logger) : IExceptionHandler
|
||||||
{
|
{
|
||||||
public async ValueTask<bool> TryHandleAsync(
|
public async ValueTask<bool> TryHandleAsync(
|
||||||
@@ -51,13 +51,15 @@ internal sealed partial class RendezvousExceptionHandler(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
[LoggerMessage(
|
private static readonly Action<ILogger, string, int, string, Exception?> RequestFailure =
|
||||||
EventId = 200,
|
LoggerMessage.Define<string, int, string>(
|
||||||
Level = LogLevel.Warning,
|
LogLevel.Warning,
|
||||||
Message = "Request failed with {FailureKind} and HTTP status {StatusCode}; correlation {CorrelationId}")]
|
new EventId(200, nameof(LogRequestFailure)),
|
||||||
private static partial void LogRequestFailure(
|
"Request failed with {FailureKind} and HTTP status {StatusCode}; correlation {CorrelationId}");
|
||||||
|
|
||||||
|
private static void LogRequestFailure(
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
string failureKind,
|
string failureKind,
|
||||||
int statusCode,
|
int statusCode,
|
||||||
string correlationId);
|
string correlationId) => RequestFailure(logger, failureKind, statusCode, correlationId, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Observability;
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
internal sealed partial class AuditTrail
|
internal sealed class AuditTrail
|
||||||
{
|
{
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly LinkedList<AuditEntry> _entries = [];
|
private readonly LinkedList<AuditEntry> _entries = [];
|
||||||
@@ -57,7 +57,6 @@ internal sealed partial class AuditTrail
|
|||||||
_telemetry.RecordAudit(action, result);
|
_telemetry.RecordAudit(action, result);
|
||||||
LogOperatorAction(
|
LogOperatorAction(
|
||||||
_logger,
|
_logger,
|
||||||
entry.Timestamp,
|
|
||||||
entry.ActorFingerprint,
|
entry.ActorFingerprint,
|
||||||
action,
|
action,
|
||||||
result,
|
result,
|
||||||
@@ -105,19 +104,28 @@ internal sealed partial class AuditTrail
|
|||||||
return Convert.ToHexString(digest.AsSpan(0, 12));
|
return Convert.ToHexString(digest.AsSpan(0, 12));
|
||||||
}
|
}
|
||||||
|
|
||||||
[LoggerMessage(
|
private static readonly Action<ILogger, string, string, string, string, string, string, Exception?>
|
||||||
EventId = 100,
|
OperatorAction = LoggerMessage.Define<string, string, string, string, string, string>(
|
||||||
Level = LogLevel.Information,
|
LogLevel.Information,
|
||||||
Message = "Operator audit at {Timestamp}: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}")]
|
new EventId(100, nameof(LogOperatorAction)),
|
||||||
private static partial void LogOperatorAction(
|
"Operator audit: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}");
|
||||||
|
|
||||||
|
private static void LogOperatorAction(
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
DateTimeOffset timestamp,
|
|
||||||
string actorFingerprint,
|
string actorFingerprint,
|
||||||
string action,
|
string action,
|
||||||
string result,
|
string result,
|
||||||
string targetKind,
|
string targetKind,
|
||||||
string targetFingerprint,
|
string targetFingerprint,
|
||||||
string correlationId);
|
string correlationId) => OperatorAction(
|
||||||
|
logger,
|
||||||
|
actorFingerprint,
|
||||||
|
action,
|
||||||
|
result,
|
||||||
|
targetKind,
|
||||||
|
targetFingerprint,
|
||||||
|
correlationId,
|
||||||
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record AuditEntry(
|
internal sealed record AuditEntry(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ namespace FinalFactory.Rendezvous.Server.Operations;
|
|||||||
internal sealed record OperatorStatusResponse
|
internal sealed record OperatorStatusResponse
|
||||||
{
|
{
|
||||||
public required string Status { get; init; }
|
public required string Status { get; init; }
|
||||||
|
public required OperatorCompatibilityResponse Compatibility { get; init; }
|
||||||
public required OperatorReadinessResponse Readiness { get; init; }
|
public required OperatorReadinessResponse Readiness { get; init; }
|
||||||
public required OperatorStoreResponse Store { get; init; }
|
public required OperatorStoreResponse Store { get; init; }
|
||||||
public required IReadOnlyList<OperatorTenantResponse> Tenants { get; init; }
|
public required IReadOnlyList<OperatorTenantResponse> Tenants { get; init; }
|
||||||
@@ -10,6 +11,18 @@ internal sealed record OperatorStatusResponse
|
|||||||
public required IReadOnlyDictionary<string, long> AuditCounts { get; init; }
|
public required IReadOnlyDictionary<string, long> AuditCounts { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record OperatorCompatibilityResponse
|
||||||
|
{
|
||||||
|
public required string ServerVersion { get; init; }
|
||||||
|
public required string MinimumClientVersion { get; init; }
|
||||||
|
public required int MaximumClientMajorVersion { get; init; }
|
||||||
|
public required IReadOnlyList<int> HttpContractVersions { get; init; }
|
||||||
|
public required IReadOnlyList<int> UdpContractVersions { get; init; }
|
||||||
|
public required IReadOnlyList<int> ConnectionTicketFormatVersions { get; init; }
|
||||||
|
public required int LiteNetLibMajorVersion { get; init; }
|
||||||
|
public required string GameplayProtocolCompatibility { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed record OperatorReadinessResponse
|
internal sealed record OperatorReadinessResponse
|
||||||
{
|
{
|
||||||
public required bool HttpListener { get; init; }
|
public required bool HttpListener { get; init; }
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ internal sealed class OperatorService(
|
|||||||
return new OperatorStatusResponse
|
return new OperatorStatusResponse
|
||||||
{
|
{
|
||||||
Status = readinessSnapshot.IsReady ? "ready" : "not-ready",
|
Status = readinessSnapshot.IsReady ? "ready" : "not-ready",
|
||||||
|
Compatibility = ReleaseCompatibility.CreateResponse(),
|
||||||
Readiness = new OperatorReadinessResponse
|
Readiness = new OperatorReadinessResponse
|
||||||
{
|
{
|
||||||
HttpListener = readinessSnapshot.HttpListenerReady,
|
HttpListener = readinessSnapshot.HttpListenerReady,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Operations;
|
||||||
|
|
||||||
|
internal static class ReleaseCompatibility
|
||||||
|
{
|
||||||
|
private static readonly Assembly ServerAssembly = typeof(ReleaseCompatibility).Assembly;
|
||||||
|
|
||||||
|
internal static string MinimumClientVersion => Metadata("RendezvousMinimumClientVersion");
|
||||||
|
internal static int MaximumClientMajorVersion => MetadataInteger("RendezvousMaximumClientMajorVersion");
|
||||||
|
internal static int UdpContractVersion => MetadataInteger("RendezvousUdpContractVersion");
|
||||||
|
internal static int ConnectionTicketFormatVersion => MetadataInteger("RendezvousConnectionTicketFormatVersion");
|
||||||
|
internal static int LiteNetLibMajorVersion => MetadataInteger("RendezvousLiteNetLibMajorVersion");
|
||||||
|
|
||||||
|
internal static OperatorCompatibilityResponse CreateResponse() => new()
|
||||||
|
{
|
||||||
|
ServerVersion = ServerAssembly
|
||||||
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||||
|
.InformationalVersion.Split('+', 2)[0]
|
||||||
|
?? ServerAssembly.GetName().Version?.ToString(3)
|
||||||
|
?? "unknown",
|
||||||
|
MinimumClientVersion = MinimumClientVersion,
|
||||||
|
MaximumClientMajorVersion = MaximumClientMajorVersion,
|
||||||
|
HttpContractVersions = [ContractLimits.ContractVersion],
|
||||||
|
UdpContractVersions = [UdpContractVersion],
|
||||||
|
ConnectionTicketFormatVersions = [ConnectionTicketFormatVersion],
|
||||||
|
LiteNetLibMajorVersion = LiteNetLibMajorVersion,
|
||||||
|
GameplayProtocolCompatibility = "exact-per-tenant",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Metadata(string key) => ServerAssembly
|
||||||
|
.GetCustomAttributes<AssemblyMetadataAttribute>()
|
||||||
|
.Single(attribute => string.Equals(attribute.Key, key, StringComparison.Ordinal))
|
||||||
|
.Value
|
||||||
|
?? throw new InvalidOperationException($"Assembly metadata {key} has no value.");
|
||||||
|
|
||||||
|
private static int MetadataInteger(string key) => int.Parse(
|
||||||
|
Metadata(key),
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
@@ -255,6 +255,7 @@ builder.Services.Configure<HostOptions>(options =>
|
|||||||
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
|
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
|
||||||
|
|
||||||
SystemRendezvousClock rendezvousClock = new();
|
SystemRendezvousClock rendezvousClock = new();
|
||||||
|
SessionChangeJournal sessionChanges = new(new SessionChangeJournalOptions());
|
||||||
EphemeralStoreOptions stateOptions = new()
|
EphemeralStoreOptions stateOptions = new()
|
||||||
{
|
{
|
||||||
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
|
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
|
||||||
@@ -262,8 +263,10 @@ EphemeralStoreOptions stateOptions = new()
|
|||||||
InMemoryEphemeralRendezvousStore stateStore = new(
|
InMemoryEphemeralRendezvousStore stateStore = new(
|
||||||
stateOptions,
|
stateOptions,
|
||||||
rendezvousClock,
|
rendezvousClock,
|
||||||
rendezvousClock);
|
rendezvousClock,
|
||||||
|
sessionChanges);
|
||||||
builder.Services.AddSingleton(stateStore);
|
builder.Services.AddSingleton(stateStore);
|
||||||
|
builder.Services.AddSingleton(sessionChanges);
|
||||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
||||||
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
|
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
|
||||||
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
|
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
|
||||||
@@ -297,7 +300,9 @@ else
|
|||||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
builder.Services.AddSingleton<SessionLeaseService>();
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
||||||
builder.Services.AddSingleton<SessionBrowserService>();
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamService>();
|
||||||
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||||
builder.Services.AddSingleton<JoinAttemptService>();
|
builder.Services.AddSingleton<JoinAttemptService>();
|
||||||
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
|
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
||||||
|
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Capacity")]
|
||||||
|
|||||||
@@ -240,7 +240,15 @@ internal sealed class SessionLeaseService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
StoredListing ownedListing = listing!;
|
StoredListing ownedListing = listing!;
|
||||||
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
|
PublisherAuthorizationResult authorized = authorization.Authorize(
|
||||||
|
principal,
|
||||||
|
ownedListing.Definition.Scope.GameId,
|
||||||
|
ownedListing.Definition.Scope.EnvironmentId,
|
||||||
|
request.RegionId ?? ownedListing.Definition.RegionId,
|
||||||
|
request.ProtocolVersion ?? ownedListing.Definition.ProtocolVersion,
|
||||||
|
request.Visibility ?? ownedListing.Definition.Visibility,
|
||||||
|
request.Metadata,
|
||||||
|
clock.UtcNow);
|
||||||
if (!authorized.IsAllowed || authorized.Context is null)
|
if (!authorized.IsAllowed || authorized.Context is null)
|
||||||
{
|
{
|
||||||
return new(MapAuthorization(authorized.Error));
|
return new(MapAuthorization(authorized.Error));
|
||||||
@@ -261,7 +269,10 @@ internal sealed class SessionLeaseService(
|
|||||||
request.Capacity.CurrentPlayers,
|
request.Capacity.CurrentPlayers,
|
||||||
request.Capacity.MaximumPlayers,
|
request.Capacity.MaximumPlayers,
|
||||||
request.Metadata,
|
request.Metadata,
|
||||||
request.DedicatedFallback), cancellationToken);
|
request.DedicatedFallback,
|
||||||
|
request.RegionId,
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.Visibility), cancellationToken);
|
||||||
return updated.Succeeded
|
return updated.Succeeded
|
||||||
? new(RendezvousErrorCode.None, true)
|
? new(RendezvousErrorCode.None, true)
|
||||||
: new(updated.Code.ToContractError());
|
: new(updated.Code.ToContractError());
|
||||||
@@ -391,6 +402,9 @@ internal sealed class SessionLeaseService(
|
|||||||
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||||
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||||
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||||
|
|| request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value)
|
||||||
|
|| request.ProtocolVersion.HasValue && request.ProtocolVersion.Value == 0
|
||||||
|
|| request.Visibility.HasValue && !Enum.IsDefined(request.Visibility.Value)
|
||||||
|| request.DedicatedFallback is not null
|
|| request.DedicatedFallback is not null
|
||||||
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
|
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
|
||||||
? RendezvousErrorCode.InvalidRequest
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
|||||||
@@ -228,7 +228,10 @@ internal sealed record UpdateListingCommand(
|
|||||||
int CurrentPlayers,
|
int CurrentPlayers,
|
||||||
int MaximumPlayers,
|
int MaximumPlayers,
|
||||||
IReadOnlyDictionary<string, string> Metadata,
|
IReadOnlyDictionary<string, string> Metadata,
|
||||||
NetworkEndpoint? DedicatedFallback);
|
NetworkEndpoint? DedicatedFallback,
|
||||||
|
RegionId? RegionId = null,
|
||||||
|
uint? ProtocolVersion = null,
|
||||||
|
ListingVisibility? Visibility = null);
|
||||||
|
|
||||||
internal sealed record DeleteListingCommand(
|
internal sealed record DeleteListingCommand(
|
||||||
SessionListingId ListingId,
|
SessionListingId ListingId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.State;
|
namespace FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
@@ -8,19 +9,33 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly EphemeralStoreOptions _options;
|
private readonly EphemeralStoreOptions _options;
|
||||||
private readonly IMonotonicClock _monotonicClock;
|
private readonly IMonotonicClock _monotonicClock;
|
||||||
|
private readonly SessionChangeJournal? _sessionChanges;
|
||||||
private readonly DateTimeOffset _wallOrigin;
|
private readonly DateTimeOffset _wallOrigin;
|
||||||
private readonly TimeSpan _monotonicOrigin;
|
private readonly TimeSpan _monotonicOrigin;
|
||||||
private readonly Dictionary<SessionListingId, ListingEntry> _listings = [];
|
private readonly Dictionary<SessionListingId, ListingEntry> _listings = [];
|
||||||
|
private readonly Dictionary<string, int> _listingCountsByOwner = new(StringComparer.Ordinal);
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<SessionListingId>, long> _listingExpiries = new();
|
||||||
|
private readonly HashSet<SessionListingId> _scheduledListingExpiries = [];
|
||||||
private readonly Dictionary<LeaseId, SessionListingId> _leases = [];
|
private readonly Dictionary<LeaseId, SessionListingId> _leases = [];
|
||||||
private readonly Dictionary<MediationHandle, SessionListingId> _presenceHandles = [];
|
private readonly Dictionary<MediationHandle, SessionListingId> _presenceHandles = [];
|
||||||
private readonly Dictionary<MediationHandle, PresenceEntry> _presence = [];
|
private readonly Dictionary<MediationHandle, PresenceEntry> _presence = [];
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<MediationHandle>, long> _presenceExpiries = new();
|
||||||
|
private readonly HashSet<MediationHandle> _scheduledPresenceExpiries = [];
|
||||||
private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = [];
|
private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = [];
|
||||||
private readonly PriorityQueue<AttemptExpiry, long> _attemptExpiries = new();
|
private readonly Dictionary<TenantScope, int> _attemptCountsByScope = [];
|
||||||
|
private readonly Dictionary<SessionListingId, HashSet<JoinAttemptId>> _attemptsByListing = [];
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<JoinAttemptId>, long> _attemptExpiries = new();
|
||||||
private readonly Dictionary<JoinAttemptId, OutcomeReportEntry> _outcomeReports = [];
|
private readonly Dictionary<JoinAttemptId, OutcomeReportEntry> _outcomeReports = [];
|
||||||
|
private readonly Dictionary<SessionListingId, HashSet<JoinAttemptId>> _outcomesByListing = [];
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<JoinAttemptId>, long> _outcomeExpiries = new();
|
||||||
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
|
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
|
||||||
private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal);
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<string>, long> _idempotencyExpiries = new();
|
||||||
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<string>, long> _replayExpiries = new();
|
||||||
private readonly Dictionary<string, TimeSpan> _revocations = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, TimeSpan> _revocations = new(StringComparer.Ordinal);
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<string>, long> _revocationExpiries = new();
|
||||||
|
private readonly HashSet<string> _scheduledRevocationExpiries = new(StringComparer.Ordinal);
|
||||||
private TimeSpan? _drainDeadline;
|
private TimeSpan? _drainDeadline;
|
||||||
private TimeSpan _nextUdpMaintenance;
|
private TimeSpan _nextUdpMaintenance;
|
||||||
private long _maintenanceSweepCount;
|
private long _maintenanceSweepCount;
|
||||||
@@ -32,7 +47,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
public InMemoryEphemeralRendezvousStore(
|
public InMemoryEphemeralRendezvousStore(
|
||||||
EphemeralStoreOptions options,
|
EphemeralStoreOptions options,
|
||||||
IWallClock wallClock,
|
IWallClock wallClock,
|
||||||
IMonotonicClock monotonicClock)
|
IMonotonicClock monotonicClock,
|
||||||
|
SessionChangeJournal? sessionChanges = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
ArgumentNullException.ThrowIfNull(wallClock);
|
ArgumentNullException.ThrowIfNull(wallClock);
|
||||||
@@ -40,6 +56,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
options.Validate();
|
options.Validate();
|
||||||
_options = options;
|
_options = options;
|
||||||
_monotonicClock = monotonicClock;
|
_monotonicClock = monotonicClock;
|
||||||
|
_sessionChanges = sessionChanges;
|
||||||
_wallOrigin = wallClock.UtcNow;
|
_wallOrigin = wallClock.UtcNow;
|
||||||
_monotonicOrigin = monotonicClock.Elapsed;
|
_monotonicOrigin = monotonicClock.Elapsed;
|
||||||
InstanceId = Guid.NewGuid();
|
InstanceId = Guid.NewGuid();
|
||||||
@@ -47,6 +64,22 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
public Guid InstanceId { get; }
|
public Guid InstanceId { get; }
|
||||||
internal long MaintenanceSweepCount => Interlocked.Read(ref _maintenanceSweepCount);
|
internal long MaintenanceSweepCount => Interlocked.Read(ref _maintenanceSweepCount);
|
||||||
|
internal int ScheduledExpiryEntryCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _listingExpiries.Count
|
||||||
|
+ _presenceExpiries.Count
|
||||||
|
+ _attemptExpiries.Count
|
||||||
|
+ _outcomeExpiries.Count
|
||||||
|
+ _idempotencyExpiries.Count
|
||||||
|
+ _replayExpiries.Count
|
||||||
|
+ _revocationExpiries.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsAvailable
|
public bool IsAvailable
|
||||||
{
|
{
|
||||||
@@ -161,10 +194,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
if (_listings.Count >= _options.MaxListings
|
if (_listings.Count >= _options.MaxListings
|
||||||
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|
||||||
|| _listings.Values.Count(entry => string.Equals(
|
|| _listingCountsByOwner.GetValueOrDefault(command.Listing.OwnerSubject)
|
||||||
entry.Definition.OwnerSubject,
|
>= command.OwnerListingLimit)
|
||||||
command.Listing.OwnerSubject,
|
|
||||||
StringComparison.Ordinal)) >= command.OwnerListingLimit)
|
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.CapacityExceeded);
|
return new(StoreResultCode.CapacityExceeded);
|
||||||
}
|
}
|
||||||
@@ -183,13 +214,24 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
WallDeadline(now, _options.LeaseLifetime),
|
WallDeadline(now, _options.LeaseLifetime),
|
||||||
version: 1);
|
version: 1);
|
||||||
_listings.Add(frozen.ListingId, entry);
|
_listings.Add(frozen.ListingId, entry);
|
||||||
|
ScheduleMutableDeadline(
|
||||||
|
_listingExpiries,
|
||||||
|
_scheduledListingExpiries,
|
||||||
|
frozen.ListingId,
|
||||||
|
entry.LeaseDeadline);
|
||||||
|
_listingCountsByOwner[frozen.OwnerSubject] =
|
||||||
|
_listingCountsByOwner.GetValueOrDefault(frozen.OwnerSubject) + 1;
|
||||||
_leases.Add(frozen.LeaseId, frozen.ListingId);
|
_leases.Add(frozen.LeaseId, frozen.ListingId);
|
||||||
_presenceHandles.Add(frozen.HostPresenceHandle, frozen.ListingId);
|
_presenceHandles.Add(frozen.HostPresenceHandle, frozen.ListingId);
|
||||||
_idempotency.Add(idempotencyKey, new(
|
IdempotencyEntry idempotency = new(
|
||||||
command.RequestFingerprint,
|
command.RequestFingerprint,
|
||||||
frozen.ListingId,
|
frozen.ListingId,
|
||||||
now + _options.IdempotencyLifetime));
|
now + _options.IdempotencyLifetime);
|
||||||
return new(StoreResultCode.Success, Snapshot(entry));
|
_idempotency.Add(idempotencyKey, idempotency);
|
||||||
|
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline);
|
||||||
|
StoredListing created = Snapshot(entry);
|
||||||
|
_sessionChanges?.Publish(null, created);
|
||||||
|
return new(StoreResultCode.Success, created);
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
public StoreResult<StoredListing> RenewLease(
|
public StoreResult<StoredListing> RenewLease(
|
||||||
@@ -241,6 +283,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|
||||||
|| command.CurrentPlayers < 0
|
|| command.CurrentPlayers < 0
|
||||||
|| command.CurrentPlayers > command.MaximumPlayers
|
|| command.CurrentPlayers > command.MaximumPlayers
|
||||||
|
|| command.RegionId.HasValue && string.IsNullOrEmpty(command.RegionId.Value.Value)
|
||||||
|
|| command.ProtocolVersion.HasValue && command.ProtocolVersion.Value == 0
|
||||||
|
|| command.Visibility.HasValue && !Enum.IsDefined(command.Visibility.Value)
|
||||||
|| !ContractValidation.IsMetadataValid(command.Metadata)
|
|| !ContractValidation.IsMetadataValid(command.Metadata)
|
||||||
|| command.DedicatedFallback is not null
|
|| command.DedicatedFallback is not null
|
||||||
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback))
|
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback))
|
||||||
@@ -266,8 +311,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return new(StoreResultCode.NotFound);
|
return new(StoreResultCode.NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StoredListing before = Snapshot(entry);
|
||||||
entry.Definition = StoredListing.Freeze(entry.Definition with
|
entry.Definition = StoredListing.Freeze(entry.Definition with
|
||||||
{
|
{
|
||||||
|
RegionId = command.RegionId ?? entry.Definition.RegionId,
|
||||||
|
ProtocolVersion = command.ProtocolVersion ?? entry.Definition.ProtocolVersion,
|
||||||
|
Visibility = command.Visibility ?? entry.Definition.Visibility,
|
||||||
BuildVersion = command.BuildVersion,
|
BuildVersion = command.BuildVersion,
|
||||||
DisplayName = command.DisplayName,
|
DisplayName = command.DisplayName,
|
||||||
CurrentPlayers = command.CurrentPlayers,
|
CurrentPlayers = command.CurrentPlayers,
|
||||||
@@ -276,7 +325,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
DedicatedFallback = command.DedicatedFallback,
|
DedicatedFallback = command.DedicatedFallback,
|
||||||
});
|
});
|
||||||
entry.Version++;
|
entry.Version++;
|
||||||
return new(StoreResultCode.Success, Snapshot(entry));
|
StoredListing after = Snapshot(entry);
|
||||||
|
_sessionChanges?.Publish(before, after);
|
||||||
|
return new(StoreResultCode.Success, after);
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
public StoreResult<bool> DeleteListing(
|
public StoreResult<bool> DeleteListing(
|
||||||
@@ -346,11 +397,24 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return new(StoreResultCode.CapacityExceeded);
|
return new(StoreResultCode.CapacityExceeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
_presence[command.Handle] = new(
|
StoredListing before = Snapshot(entry);
|
||||||
|
bool isNewPresence = !_presence.ContainsKey(command.Handle);
|
||||||
|
PresenceEntry presence = new(
|
||||||
command.PublicEndpoint,
|
command.PublicEndpoint,
|
||||||
command.LocalEndpoint,
|
command.LocalEndpoint,
|
||||||
now + _options.PresenceLifetime);
|
now + _options.PresenceLifetime);
|
||||||
return new(StoreResultCode.Success, Snapshot(entry));
|
_presence[command.Handle] = presence;
|
||||||
|
if (isNewPresence)
|
||||||
|
{
|
||||||
|
ScheduleMutableDeadline(
|
||||||
|
_presenceExpiries,
|
||||||
|
_scheduledPresenceExpiries,
|
||||||
|
command.Handle,
|
||||||
|
presence.Deadline);
|
||||||
|
}
|
||||||
|
StoredListing after = Snapshot(entry);
|
||||||
|
_sessionChanges?.Publish(before, after);
|
||||||
|
return new(StoreResultCode.Success, after);
|
||||||
}, cancellationToken, eagerCleanup: false);
|
}, cancellationToken, eagerCleanup: false);
|
||||||
|
|
||||||
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
|
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
|
||||||
@@ -444,8 +508,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
if (_attempts.Count >= _options.MaxJoinAttempts
|
if (_attempts.Count >= _options.MaxJoinAttempts
|
||||||
|| _outcomeReports.Count >= _options.MaxOutcomeReports
|
|| _outcomeReports.Count >= _options.MaxOutcomeReports
|
||||||
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|
||||||
|| _attempts.Values.Count(entry => entry.Command.Scope == command.Scope)
|
|| _attemptCountsByScope.GetValueOrDefault(command.Scope) >= command.ScopeAttemptLimit)
|
||||||
>= command.ScopeAttemptLimit)
|
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.CapacityExceeded);
|
return new(StoreResultCode.CapacityExceeded);
|
||||||
}
|
}
|
||||||
@@ -461,19 +524,25 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
now + _options.JoinAttemptLifetime,
|
now + _options.JoinAttemptLifetime,
|
||||||
WallDeadline(now, _options.JoinAttemptLifetime));
|
WallDeadline(now, _options.JoinAttemptLifetime));
|
||||||
_attempts.Add(command.AttemptId, attempt);
|
_attempts.Add(command.AttemptId, attempt);
|
||||||
_attemptExpiries.Enqueue(
|
AddToIndex(_attemptsByListing, command.ListingId, command.AttemptId);
|
||||||
new AttemptExpiry(command.AttemptId, attempt.Deadline),
|
_attemptCountsByScope[command.Scope] =
|
||||||
attempt.Deadline.Ticks);
|
_attemptCountsByScope.GetValueOrDefault(command.Scope) + 1;
|
||||||
_outcomeReports.Add(command.AttemptId, new(
|
EnqueueDeadline(_attemptExpiries, command.AttemptId, attempt.Deadline);
|
||||||
|
OutcomeReportEntry outcome = new(
|
||||||
command.ListingId,
|
command.ListingId,
|
||||||
command.ClientSubject,
|
command.ClientSubject,
|
||||||
command.ClientCapabilityFingerprint,
|
command.ClientCapabilityFingerprint,
|
||||||
now + _options.JoinAttemptLifetime + _options.IdempotencyLifetime));
|
now + _options.JoinAttemptLifetime + _options.IdempotencyLifetime);
|
||||||
|
_outcomeReports.Add(command.AttemptId, outcome);
|
||||||
|
AddToIndex(_outcomesByListing, command.ListingId, command.AttemptId);
|
||||||
|
EnqueueDeadline(_outcomeExpiries, command.AttemptId, outcome.Deadline);
|
||||||
_attemptHandles.Add(command.MediationHandle, command.AttemptId);
|
_attemptHandles.Add(command.MediationHandle, command.AttemptId);
|
||||||
_idempotency.Add(idempotencyKey, new(
|
IdempotencyEntry idempotency = new(
|
||||||
command.RequestFingerprint,
|
command.RequestFingerprint,
|
||||||
command.AttemptId,
|
command.AttemptId,
|
||||||
now + _options.IdempotencyLifetime));
|
now + _options.IdempotencyLifetime);
|
||||||
|
_idempotency.Add(idempotencyKey, idempotency);
|
||||||
|
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline);
|
||||||
return new(StoreResultCode.Success, Snapshot(attempt));
|
return new(StoreResultCode.Success, Snapshot(attempt));
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
@@ -757,7 +826,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
throw new ArgumentOutOfRangeException(nameof(consumption), "Replay lifetime exceeds the configured ceiling.");
|
throw new ArgumentOutOfRangeException(nameof(consumption), "Replay lifetime exceeds the configured ceiling.");
|
||||||
}
|
}
|
||||||
|
|
||||||
_replay.Add(key, now + lifetime);
|
TimeSpan deadline = now + lifetime;
|
||||||
|
_replay.Add(key, deadline);
|
||||||
|
EnqueueDeadline(_replayExpiries, key, deadline);
|
||||||
return new(StoreResultCode.Success, true);
|
return new(StoreResultCode.Success, true);
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
@@ -794,7 +865,24 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
+ _presence.Count
|
+ _presence.Count
|
||||||
+ _attempts.Count
|
+ _attempts.Count
|
||||||
+ _outcomeReports.Count;
|
+ _outcomeReports.Count;
|
||||||
_revocations[subject] = now + lifetime;
|
TimeSpan deadline = now + lifetime;
|
||||||
|
bool isNewRevocation = !_revocations.TryGetValue(subject, out TimeSpan existingDeadline);
|
||||||
|
if (!isNewRevocation && existingDeadline > deadline)
|
||||||
|
{
|
||||||
|
// A repeated operator action may extend protection but cannot silently
|
||||||
|
// shorten an already-authoritative security revocation.
|
||||||
|
deadline = existingDeadline;
|
||||||
|
}
|
||||||
|
|
||||||
|
_revocations[subject] = deadline;
|
||||||
|
if (isNewRevocation)
|
||||||
|
{
|
||||||
|
ScheduleMutableDeadline(
|
||||||
|
_revocationExpiries,
|
||||||
|
_scheduledRevocationExpiries,
|
||||||
|
subject,
|
||||||
|
deadline);
|
||||||
|
}
|
||||||
SessionListingId[] listings = _listings
|
SessionListingId[] listings = _listings
|
||||||
.Where(item => string.Equals(item.Value.Definition.OwnerSubject, subject, StringComparison.Ordinal))
|
.Where(item => string.Equals(item.Value.Definition.OwnerSubject, subject, StringComparison.Ordinal))
|
||||||
.Select(static item => item.Key)
|
.Select(static item => item.Key)
|
||||||
@@ -818,7 +906,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
foreach (JoinAttemptId attemptId in outcomeReports)
|
foreach (JoinAttemptId attemptId in outcomeReports)
|
||||||
{
|
{
|
||||||
_outcomeReports.Remove(attemptId);
|
RemoveOutcome(attemptId);
|
||||||
}
|
}
|
||||||
|
|
||||||
int activeResourcesAfter = _listings.Count
|
int activeResourcesAfter = _listings.Count
|
||||||
@@ -911,63 +999,47 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
ClearActiveState();
|
ClearActiveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
_expiryChurn += RemoveExpired(_revocations, now);
|
_expiryChurn += RemoveExpiredMutableDeadlines(
|
||||||
_expiryChurn += RemoveExpired(_replay, now);
|
_revocations,
|
||||||
string[] expiredIdempotency = _idempotency
|
_revocationExpiries,
|
||||||
.Where(item => item.Value.Deadline <= now)
|
_scheduledRevocationExpiries,
|
||||||
.Select(static item => item.Key)
|
now);
|
||||||
.ToArray();
|
_expiryChurn += RemoveExpiredDeadlines(_replay, _replayExpiries, now);
|
||||||
_expiryChurn += expiredIdempotency.Length;
|
_expiryChurn += RemoveExpiredIdempotency(now);
|
||||||
foreach (string key in expiredIdempotency)
|
_expiryChurn += RemoveExpiredPresence(now);
|
||||||
{
|
|
||||||
_idempotency.Remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
MediationHandle[] expiredPresence = _presence
|
|
||||||
.Where(item => item.Value.Deadline <= now)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray();
|
|
||||||
_expiryChurn += expiredPresence.Length;
|
|
||||||
foreach (MediationHandle handle in expiredPresence)
|
|
||||||
{
|
|
||||||
_presence.Remove(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
_expiryChurn += RemoveExpiredAttempts(now);
|
_expiryChurn += RemoveExpiredAttempts(now);
|
||||||
|
_expiryChurn += RemoveExpiredOutcomes(now);
|
||||||
JoinAttemptId[] expiredOutcomes = _outcomeReports
|
_expiryChurn += RemoveExpiredListings(now);
|
||||||
.Where(item => item.Value.Deadline <= now)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray();
|
|
||||||
_expiryChurn += expiredOutcomes.Length;
|
|
||||||
foreach (JoinAttemptId attemptId in expiredOutcomes)
|
|
||||||
{
|
|
||||||
_outcomeReports.Remove(attemptId);
|
|
||||||
}
|
|
||||||
|
|
||||||
SessionListingId[] expiredListings = _listings
|
|
||||||
.Where(item => item.Value.LeaseDeadline <= now)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray();
|
|
||||||
_expiryChurn += expiredListings.Length;
|
|
||||||
foreach (SessionListingId listingId in expiredListings)
|
|
||||||
{
|
|
||||||
RemoveListing(listingId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ClearActiveState()
|
private void ClearActiveState()
|
||||||
{
|
{
|
||||||
|
StoredListing[] removedListings = _listings.Values.Select(Snapshot).ToArray();
|
||||||
_listings.Clear();
|
_listings.Clear();
|
||||||
|
_listingCountsByOwner.Clear();
|
||||||
|
_listingExpiries.Clear();
|
||||||
|
_scheduledListingExpiries.Clear();
|
||||||
_leases.Clear();
|
_leases.Clear();
|
||||||
_presenceHandles.Clear();
|
_presenceHandles.Clear();
|
||||||
_presence.Clear();
|
_presence.Clear();
|
||||||
|
_presenceExpiries.Clear();
|
||||||
|
_scheduledPresenceExpiries.Clear();
|
||||||
_attempts.Clear();
|
_attempts.Clear();
|
||||||
|
_attemptCountsByScope.Clear();
|
||||||
|
_attemptsByListing.Clear();
|
||||||
_attemptExpiries.Clear();
|
_attemptExpiries.Clear();
|
||||||
_outcomeReports.Clear();
|
_outcomeReports.Clear();
|
||||||
|
_outcomesByListing.Clear();
|
||||||
|
_outcomeExpiries.Clear();
|
||||||
_attemptHandles.Clear();
|
_attemptHandles.Clear();
|
||||||
_idempotency.Clear();
|
_idempotency.Clear();
|
||||||
|
_idempotencyExpiries.Clear();
|
||||||
_replay.Clear();
|
_replay.Clear();
|
||||||
|
_replayExpiries.Clear();
|
||||||
|
foreach (StoredListing listing in removedListings)
|
||||||
|
{
|
||||||
|
_sessionChanges?.Publish(listing, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveListing(SessionListingId listingId)
|
private void RemoveListing(SessionListingId listingId)
|
||||||
@@ -977,25 +1049,28 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StoredListing removed = Snapshot(listing);
|
||||||
_leases.Remove(listing.Definition.LeaseId);
|
_leases.Remove(listing.Definition.LeaseId);
|
||||||
|
DecrementCount(_listingCountsByOwner, listing.Definition.OwnerSubject);
|
||||||
_presenceHandles.Remove(listing.Definition.HostPresenceHandle);
|
_presenceHandles.Remove(listing.Definition.HostPresenceHandle);
|
||||||
_presence.Remove(listing.Definition.HostPresenceHandle);
|
_presence.Remove(listing.Definition.HostPresenceHandle);
|
||||||
foreach (JoinAttemptId attemptId in _attempts
|
if (_attemptsByListing.TryGetValue(listingId, out HashSet<JoinAttemptId>? attempts))
|
||||||
.Where(item => item.Value.Command.ListingId == listingId)
|
{
|
||||||
.Select(static item => item.Key)
|
foreach (JoinAttemptId attemptId in attempts.ToArray())
|
||||||
.ToArray())
|
|
||||||
{
|
{
|
||||||
RemoveAttempt(attemptId);
|
RemoveAttempt(attemptId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
foreach (JoinAttemptId attemptId in _outcomeReports
|
|
||||||
.Where(item => item.Value.ListingId == listingId)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray())
|
|
||||||
{
|
|
||||||
_outcomeReports.Remove(attemptId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_outcomesByListing.TryGetValue(listingId, out HashSet<JoinAttemptId>? outcomes))
|
||||||
|
{
|
||||||
|
foreach (JoinAttemptId attemptId in outcomes.ToArray())
|
||||||
|
{
|
||||||
|
RemoveOutcome(attemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_sessionChanges?.Publish(removed, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveAttempt(JoinAttemptId attemptId)
|
private void RemoveAttempt(JoinAttemptId attemptId)
|
||||||
@@ -1003,20 +1078,75 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
if (_attempts.Remove(attemptId, out AttemptEntry? attempt))
|
if (_attempts.Remove(attemptId, out AttemptEntry? attempt))
|
||||||
{
|
{
|
||||||
_attemptHandles.Remove(attempt.Command.MediationHandle);
|
_attemptHandles.Remove(attempt.Command.MediationHandle);
|
||||||
|
DecrementCount(_attemptCountsByScope, attempt.Command.Scope);
|
||||||
|
RemoveFromIndex(_attemptsByListing, attempt.Command.ListingId, attemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveOutcome(JoinAttemptId attemptId)
|
||||||
|
{
|
||||||
|
if (_outcomeReports.Remove(attemptId, out OutcomeReportEntry? outcome))
|
||||||
|
{
|
||||||
|
RemoveFromIndex(_outcomesByListing, outcome.ListingId, attemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddToIndex<TKey>(
|
||||||
|
Dictionary<TKey, HashSet<JoinAttemptId>> index,
|
||||||
|
TKey key,
|
||||||
|
JoinAttemptId attemptId)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
if (!index.TryGetValue(key, out HashSet<JoinAttemptId>? values))
|
||||||
|
{
|
||||||
|
values = [];
|
||||||
|
index.Add(key, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.Add(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveFromIndex<TKey>(
|
||||||
|
Dictionary<TKey, HashSet<JoinAttemptId>> index,
|
||||||
|
TKey key,
|
||||||
|
JoinAttemptId attemptId)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
if (index.TryGetValue(key, out HashSet<JoinAttemptId>? values)
|
||||||
|
&& values.Remove(attemptId)
|
||||||
|
&& values.Count == 0)
|
||||||
|
{
|
||||||
|
index.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DecrementCount<TKey>(Dictionary<TKey, int> counts, TKey key)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
int remaining = counts[key] - 1;
|
||||||
|
if (remaining == 0)
|
||||||
|
{
|
||||||
|
counts.Remove(key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
counts[key] = remaining;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int RemoveExpiredAttempts(TimeSpan now)
|
private int RemoveExpiredAttempts(TimeSpan now)
|
||||||
{
|
{
|
||||||
int removed = 0;
|
int removed = 0;
|
||||||
while (_attemptExpiries.TryPeek(out AttemptExpiry candidate, out long deadlineTicks)
|
while (_attemptExpiries.TryPeek(
|
||||||
|
out DeadlineEntry<JoinAttemptId> candidate,
|
||||||
|
out long deadlineTicks)
|
||||||
&& deadlineTicks <= now.Ticks)
|
&& deadlineTicks <= now.Ticks)
|
||||||
{
|
{
|
||||||
_attemptExpiries.Dequeue();
|
_attemptExpiries.Dequeue();
|
||||||
if (_attempts.TryGetValue(candidate.AttemptId, out AttemptEntry? current)
|
if (_attempts.TryGetValue(candidate.Key, out AttemptEntry? current)
|
||||||
&& current.Deadline == candidate.Deadline)
|
&& current.Deadline == candidate.Deadline)
|
||||||
{
|
{
|
||||||
RemoveAttempt(candidate.AttemptId);
|
RemoveAttempt(candidate.Key);
|
||||||
removed++;
|
removed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1024,6 +1154,190 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int RemoveExpiredListings(TimeSpan now)
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (_listingExpiries.TryPeek(
|
||||||
|
out DeadlineEntry<SessionListingId> candidate,
|
||||||
|
out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
_listingExpiries.Dequeue();
|
||||||
|
_scheduledListingExpiries.Remove(candidate.Key);
|
||||||
|
if (!_listings.TryGetValue(candidate.Key, out ListingEntry? current))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.LeaseDeadline > now)
|
||||||
|
{
|
||||||
|
ScheduleMutableDeadline(
|
||||||
|
_listingExpiries,
|
||||||
|
_scheduledListingExpiries,
|
||||||
|
candidate.Key,
|
||||||
|
current.LeaseDeadline);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RemoveListing(candidate.Key);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int RemoveExpiredPresence(TimeSpan now)
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (_presenceExpiries.TryPeek(
|
||||||
|
out DeadlineEntry<MediationHandle> candidate,
|
||||||
|
out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
_presenceExpiries.Dequeue();
|
||||||
|
_scheduledPresenceExpiries.Remove(candidate.Key);
|
||||||
|
if (!_presence.TryGetValue(candidate.Key, out PresenceEntry? current))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Deadline > now)
|
||||||
|
{
|
||||||
|
ScheduleMutableDeadline(
|
||||||
|
_presenceExpiries,
|
||||||
|
_scheduledPresenceExpiries,
|
||||||
|
candidate.Key,
|
||||||
|
current.Deadline);
|
||||||
|
}
|
||||||
|
else if (_presence.Remove(candidate.Key))
|
||||||
|
{
|
||||||
|
if (_presenceHandles.TryGetValue(candidate.Key, out SessionListingId listingId)
|
||||||
|
&& _listings.TryGetValue(listingId, out ListingEntry? listing))
|
||||||
|
{
|
||||||
|
StoredListing after = Snapshot(listing);
|
||||||
|
_sessionChanges?.Publish(after with { HasFreshPresence = true }, after);
|
||||||
|
}
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int RemoveExpiredOutcomes(TimeSpan now)
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (_outcomeExpiries.TryPeek(
|
||||||
|
out DeadlineEntry<JoinAttemptId> candidate,
|
||||||
|
out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
_outcomeExpiries.Dequeue();
|
||||||
|
if (_outcomeReports.TryGetValue(candidate.Key, out OutcomeReportEntry? current)
|
||||||
|
&& current.Deadline == candidate.Deadline)
|
||||||
|
{
|
||||||
|
RemoveOutcome(candidate.Key);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int RemoveExpiredIdempotency(TimeSpan now)
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (_idempotencyExpiries.TryPeek(
|
||||||
|
out DeadlineEntry<string> candidate,
|
||||||
|
out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
_idempotencyExpiries.Dequeue();
|
||||||
|
if (_idempotency.TryGetValue(candidate.Key, out IdempotencyEntry? current)
|
||||||
|
&& current.Deadline == candidate.Deadline
|
||||||
|
&& _idempotency.Remove(candidate.Key))
|
||||||
|
{
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int RemoveExpiredDeadlines<TKey>(
|
||||||
|
Dictionary<TKey, TimeSpan> entries,
|
||||||
|
PriorityQueue<DeadlineEntry<TKey>, long> expiries,
|
||||||
|
TimeSpan now)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (expiries.TryPeek(out DeadlineEntry<TKey> candidate, out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
expiries.Dequeue();
|
||||||
|
if (entries.TryGetValue(candidate.Key, out TimeSpan current)
|
||||||
|
&& current == candidate.Deadline
|
||||||
|
&& entries.Remove(candidate.Key))
|
||||||
|
{
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int RemoveExpiredMutableDeadlines<TKey>(
|
||||||
|
Dictionary<TKey, TimeSpan> entries,
|
||||||
|
PriorityQueue<DeadlineEntry<TKey>, long> expiries,
|
||||||
|
HashSet<TKey> scheduled,
|
||||||
|
TimeSpan now)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (expiries.TryPeek(out DeadlineEntry<TKey> candidate, out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
expiries.Dequeue();
|
||||||
|
scheduled.Remove(candidate.Key);
|
||||||
|
if (!entries.TryGetValue(candidate.Key, out TimeSpan current))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current > now)
|
||||||
|
{
|
||||||
|
ScheduleMutableDeadline(expiries, scheduled, candidate.Key, current);
|
||||||
|
}
|
||||||
|
else if (entries.Remove(candidate.Key))
|
||||||
|
{
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ScheduleMutableDeadline<TKey>(
|
||||||
|
PriorityQueue<DeadlineEntry<TKey>, long> expiries,
|
||||||
|
HashSet<TKey> scheduled,
|
||||||
|
TKey key,
|
||||||
|
TimeSpan deadline)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
if (scheduled.Add(key))
|
||||||
|
{
|
||||||
|
EnqueueDeadline(expiries, key, deadline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnqueueDeadline<TKey>(
|
||||||
|
PriorityQueue<DeadlineEntry<TKey>, long> expiries,
|
||||||
|
TKey key,
|
||||||
|
TimeSpan deadline)
|
||||||
|
where TKey : notnull =>
|
||||||
|
expiries.Enqueue(new(key, deadline), deadline.Ticks);
|
||||||
|
|
||||||
private bool HandleExists(MediationHandle handle) =>
|
private bool HandleExists(MediationHandle handle) =>
|
||||||
_presenceHandles.ContainsKey(handle) || _attemptHandles.ContainsKey(handle);
|
_presenceHandles.ContainsKey(handle) || _attemptHandles.ContainsKey(handle);
|
||||||
|
|
||||||
@@ -1063,20 +1377,6 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
IsCancelled = entry.IsCancelled,
|
IsCancelled = entry.IsCancelled,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static int RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
|
|
||||||
{
|
|
||||||
string[] expired = entries
|
|
||||||
.Where(item => item.Value <= now)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray();
|
|
||||||
foreach (string key in expired)
|
|
||||||
{
|
|
||||||
entries.Remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return expired.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateListing(ListingDefinition listing)
|
private static void ValidateListing(ListingDefinition listing)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(listing);
|
ArgumentNullException.ThrowIfNull(listing);
|
||||||
@@ -1219,7 +1519,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
public bool IsCancelled { get; set; }
|
public bool IsCancelled { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly record struct AttemptExpiry(JoinAttemptId AttemptId, TimeSpan Deadline);
|
private readonly record struct DeadlineEntry<TKey>(TKey Key, TimeSpan Deadline)
|
||||||
|
where TKey : notnull;
|
||||||
|
|
||||||
private sealed class OutcomeReportEntry(
|
private sealed class OutcomeReportEntry(
|
||||||
SessionListingId listingId,
|
SessionListingId listingId,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Transport;
|
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
|
||||||
internal sealed partial class UdpMediatorService : BackgroundService
|
internal sealed class UdpMediatorService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly ILogger<UdpMediatorService> _logger;
|
private readonly ILogger<UdpMediatorService> _logger;
|
||||||
private readonly UdpMediatorOptions _options;
|
private readonly UdpMediatorOptions _options;
|
||||||
@@ -133,20 +133,23 @@ internal sealed partial class UdpMediatorService : BackgroundService
|
|||||||
manager?.Stop();
|
manager?.Stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
[LoggerMessage(
|
private static readonly Action<ILogger, IPAddress, int, Exception?> MediatorListening =
|
||||||
EventId = 1,
|
LoggerMessage.Define<IPAddress, int>(
|
||||||
Level = LogLevel.Information,
|
LogLevel.Information,
|
||||||
Message = "UDP mediator listening on {ListenAddress}:{ListenPort}")]
|
new EventId(1, nameof(LogMediatorListening)),
|
||||||
private static partial void LogMediatorListening(
|
"UDP mediator listening on {ListenAddress}:{ListenPort}");
|
||||||
|
|
||||||
|
private static readonly Action<ILogger, Exception?> MediatorStopped = LoggerMessage.Define(
|
||||||
|
LogLevel.Information,
|
||||||
|
new EventId(2, nameof(LogMediatorStopped)),
|
||||||
|
"UDP mediator stopped");
|
||||||
|
|
||||||
|
private static void LogMediatorListening(
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
IPAddress listenAddress,
|
IPAddress listenAddress,
|
||||||
int listenPort);
|
int listenPort) => MediatorListening(logger, listenAddress, listenPort, null);
|
||||||
|
|
||||||
[LoggerMessage(
|
private static void LogMediatorStopped(ILogger logger) => MediatorStopped(logger, null);
|
||||||
EventId = 2,
|
|
||||||
Level = LogLevel.Information,
|
|
||||||
Message = "UDP mediator stopped")]
|
|
||||||
private static partial void LogMediatorStopped(ILogger logger);
|
|
||||||
|
|
||||||
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
|
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"net10.0": {
|
"net10.0": {
|
||||||
"LiteNetLib": {
|
"LiteNetLib": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[2.1.4, )",
|
"requested": "[2.1.4, 2.1.4]",
|
||||||
"resolved": "2.1.4",
|
"resolved": "2.1.4",
|
||||||
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
|
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ authenticated direct peer, and answers a bounded ping/echo/ack/completion exchan
|
|||||||
LiteNetLib socket, proves direct traffic, reports the typed outcome, and exits.
|
LiteNetLib socket, proves direct traffic, reports the typed outcome, and exits.
|
||||||
|
|
||||||
Run `dotnet run --project src/FinalFactory.Rendezvous.TestClient -- --help` for
|
Run `dotnet run --project src/FinalFactory.Rendezvous.TestClient -- --help` for
|
||||||
the complete option reference. A typical script-mode invocation is:
|
the complete option reference. The repository's
|
||||||
|
[start-to-finish guide](../../docs/integration/test-client.md) provides an
|
||||||
|
executable local Compose setup, safe failure drill, JSON automation, and a
|
||||||
|
phase-by-phase diagnostic table. A typical deployment invocation is:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<credential from the deployment boundary>'
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<credential from the deployment boundary>'
|
||||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||||
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
|
host --service https://rendezvous.example/ --mediator rendezvous.example:9050 \
|
||||||
--game space-game --environment development --region local --protocol 1 \
|
--game space-game --environment production --region eu-central --protocol 1 \
|
||||||
--script --json --exit-after-echo
|
--script --json --exit-after-echo
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
{
|
{
|
||||||
TestClientMode.Host => RunHostAsync(options, output, cancellationToken),
|
TestClientMode.Host => RunHostAsync(options, output, cancellationToken),
|
||||||
TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken),
|
TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken),
|
||||||
|
TestClientMode.Watch => RunWatchAsync(options, output, cancellationToken),
|
||||||
TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken),
|
TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken),
|
||||||
_ => Task.FromResult(TestClientExitCode.Usage),
|
_ => Task.FromResult(TestClientExitCode.Usage),
|
||||||
};
|
};
|
||||||
@@ -114,11 +115,12 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
listingId: session.ListingId.ToString(),
|
listingId: session.ListingId.ToString(),
|
||||||
displayName: options.DisplayName);
|
displayName: options.DisplayName);
|
||||||
echo = new DirectEchoProtocol(events.GameplayEvents, host: true);
|
echo = new DirectEchoProtocol(events.GameplayEvents, host: true);
|
||||||
echo.ExchangeCompleted += _ => output.Write(
|
echo.ExchangeCompleted += peer => output.Write(
|
||||||
"host.direct-traffic",
|
"host.direct-traffic",
|
||||||
"verified",
|
"verified",
|
||||||
phase: "direct-traffic",
|
phase: "direct-traffic",
|
||||||
endpointType: "peer-to-peer");
|
endpointType: "peer-to-peer",
|
||||||
|
addressFamily: AddressFamilyName(peer.Address));
|
||||||
coordinator = new RendezvousHostCoordinator(
|
coordinator = new RendezvousHostCoordinator(
|
||||||
manager,
|
manager,
|
||||||
events,
|
events,
|
||||||
@@ -485,6 +487,7 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
"connected",
|
"connected",
|
||||||
phase: "direct-connection",
|
phase: "direct-connection",
|
||||||
endpointType: endpointType,
|
endpointType: endpointType,
|
||||||
|
addressFamily: AddressFamilyName(peer.Address),
|
||||||
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
|
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
|
||||||
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
|
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
|
||||||
echo.BeginJoin(peer);
|
echo.BeginJoin(peer);
|
||||||
@@ -507,7 +510,8 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
"join.direct-traffic",
|
"join.direct-traffic",
|
||||||
"verified",
|
"verified",
|
||||||
phase: "direct-traffic",
|
phase: "direct-traffic",
|
||||||
endpointType: endpointType);
|
endpointType: endpointType,
|
||||||
|
addressFamily: AddressFamilyName(peer.Address));
|
||||||
peer.Disconnect();
|
peer.Disconnect();
|
||||||
manager.PollEvents();
|
manager.PollEvents();
|
||||||
return TestClientExitCode.Success;
|
return TestClientExitCode.Success;
|
||||||
@@ -550,6 +554,178 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<TestClientExitCode> RunWatchAsync(
|
||||||
|
TestClientOptions options,
|
||||||
|
TestClientOutput output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource watch = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
cancellationToken);
|
||||||
|
watch.CancelAfter(options.RunDuration ?? options.OperationTimeout);
|
||||||
|
using HttpClient http = CreateHttpClient(options);
|
||||||
|
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
|
||||||
|
BrowseSessionsRequest request = BrowseRequest(options);
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> snapshot = await browser.BrowseAsync(
|
||||||
|
request,
|
||||||
|
watch.Token).ConfigureAwait(false);
|
||||||
|
if (!snapshot.IsSuccess || snapshot.Value is null)
|
||||||
|
{
|
||||||
|
WriteServiceFailure(output, "watch.snapshot", "directory", snapshot);
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
output.Write(
|
||||||
|
"watch.snapshot",
|
||||||
|
"complete",
|
||||||
|
phase: "directory",
|
||||||
|
count: snapshot.Value.Items.Count);
|
||||||
|
string cursor = options.ExerciseReset
|
||||||
|
? CorruptCursor(snapshot.Value.StreamCursor)
|
||||||
|
: snapshot.Value.StreamCursor;
|
||||||
|
output.Write("watch.stream", "started", phase: "live-directory");
|
||||||
|
SessionStreamEvent? expectedReplay = null;
|
||||||
|
bool reconnectExerciseCompleted = false;
|
||||||
|
int emptyConnections = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
string connectionCursor = cursor;
|
||||||
|
bool receivedEvent = false;
|
||||||
|
bool deliberateReconnect = false;
|
||||||
|
await foreach (RendezvousClientResult<SessionStreamEvent> result in browser
|
||||||
|
.StreamAsync(request, cursor, watch.Token)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
WriteServiceFailure(output, "watch.stream", "live-directory", result);
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
receivedEvent = true;
|
||||||
|
SessionStreamEvent item = result.Value;
|
||||||
|
if (expectedReplay is not null)
|
||||||
|
{
|
||||||
|
if (!SameStreamEvent(expectedReplay, item))
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"watch.reconnect",
|
||||||
|
"failed",
|
||||||
|
"The reconnect did not replay the expected ordered event.",
|
||||||
|
phase: "live-directory");
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
output.Write("watch.reconnect", "verified", phase: "live-directory");
|
||||||
|
expectedReplay = null;
|
||||||
|
reconnectExerciseCompleted = true;
|
||||||
|
cursor = item.Cursor;
|
||||||
|
if (options.Script)
|
||||||
|
{
|
||||||
|
return TestClientExitCode.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cursor = item.Cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (item.Kind)
|
||||||
|
{
|
||||||
|
case SessionStreamEventKind.SessionUpsert when item.Session is not null:
|
||||||
|
output.Write(
|
||||||
|
"watch.session-upsert",
|
||||||
|
"available",
|
||||||
|
phase: "live-directory",
|
||||||
|
listingId: item.Session.ListingId.ToString(),
|
||||||
|
displayName: item.Session.DisplayName);
|
||||||
|
break;
|
||||||
|
case SessionStreamEventKind.SessionRemove when item.ListingId.HasValue:
|
||||||
|
output.Write(
|
||||||
|
"watch.session-remove",
|
||||||
|
"removed",
|
||||||
|
phase: "live-directory",
|
||||||
|
listingId: item.ListingId.Value.ToString());
|
||||||
|
break;
|
||||||
|
case SessionStreamEventKind.Reset:
|
||||||
|
output.Write("watch.reset", "required", phase: "live-directory");
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> refreshed = await browser.BrowseAsync(
|
||||||
|
request,
|
||||||
|
watch.Token).ConfigureAwait(false);
|
||||||
|
if (!refreshed.IsSuccess || refreshed.Value is null)
|
||||||
|
{
|
||||||
|
WriteServiceFailure(output, "watch.snapshot", "directory", refreshed);
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
output.Write(
|
||||||
|
"watch.snapshot",
|
||||||
|
"refreshed",
|
||||||
|
phase: "directory",
|
||||||
|
count: refreshed.Value.Items.Count);
|
||||||
|
return TestClientExitCode.Success;
|
||||||
|
case SessionStreamEventKind.Keepalive:
|
||||||
|
output.Write("watch.keepalive", "alive", phase: "live-directory");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool listingDelta = item.Kind is SessionStreamEventKind.SessionUpsert
|
||||||
|
or SessionStreamEventKind.SessionRemove;
|
||||||
|
if (options.ExerciseReconnect
|
||||||
|
&& !reconnectExerciseCompleted
|
||||||
|
&& listingDelta
|
||||||
|
&& expectedReplay is null)
|
||||||
|
{
|
||||||
|
expectedReplay = item;
|
||||||
|
cursor = connectionCursor;
|
||||||
|
deliberateReconnect = true;
|
||||||
|
output.Write("watch.reconnect", "started", phase: "live-directory");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (options.Script && listingDelta)
|
||||||
|
{
|
||||||
|
return TestClientExitCode.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliberateReconnect)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emptyConnections = receivedEvent ? 0 : emptyConnections + 1;
|
||||||
|
if (emptyConnections >= 3)
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"watch.reconnect",
|
||||||
|
"failed",
|
||||||
|
"The stream closed repeatedly without an event; use bounded polling fallback.",
|
||||||
|
phase: "live-directory");
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
output.Write("watch.reconnect", "required", phase: "live-directory");
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(250), watch.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
output.Write("watch.complete", "complete", phase: "lifecycle");
|
||||||
|
return TestClientExitCode.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameStreamEvent(SessionStreamEvent expected, SessionStreamEvent actual) =>
|
||||||
|
expected.Kind == actual.Kind
|
||||||
|
&& string.Equals(expected.Cursor, actual.Cursor, StringComparison.Ordinal)
|
||||||
|
&& expected.ListingId == actual.ListingId
|
||||||
|
&& expected.Session?.ListingId == actual.Session?.ListingId;
|
||||||
|
|
||||||
|
private static string CorruptCursor(string cursor)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(cursor))
|
||||||
|
{
|
||||||
|
return "invalid-stream-cursor";
|
||||||
|
}
|
||||||
|
char replacement = cursor[^1] == 'a' ? 'b' : 'a';
|
||||||
|
return cursor[..^1] + replacement;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<SessionSelection> SelectListingAsync(
|
private static async Task<SessionSelection> SelectListingAsync(
|
||||||
TestClientOptions options,
|
TestClientOptions options,
|
||||||
TestClientOutput output,
|
TestClientOutput output,
|
||||||
@@ -765,6 +941,9 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
return privateAddress ? "private" : "public";
|
return privateAddress ? "private" : "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string AddressFamilyName(IPAddress address) =>
|
||||||
|
address.AddressFamily == AddressFamily.InterNetworkV6 ? "ipv6" : "ipv4";
|
||||||
|
|
||||||
private static long ToMilliseconds(TimeSpan elapsed) =>
|
private static long ToMilliseconds(TimeSpan elapsed) =>
|
||||||
(long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds));
|
(long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds));
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ internal enum TestClientMode
|
|||||||
{
|
{
|
||||||
Host,
|
Host,
|
||||||
Browse,
|
Browse,
|
||||||
|
Watch,
|
||||||
Join,
|
Join,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ internal sealed class TestClientOptions
|
|||||||
internal bool Script { get; init; }
|
internal bool Script { get; init; }
|
||||||
internal bool Json { get; init; }
|
internal bool Json { get; init; }
|
||||||
internal bool ExitAfterEcho { get; init; }
|
internal bool ExitAfterEcho { get; init; }
|
||||||
|
internal bool ExerciseReconnect { get; init; }
|
||||||
|
internal bool ExerciseReset { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class TestClientParseResult
|
internal sealed class TestClientParseResult
|
||||||
@@ -63,6 +66,7 @@ internal static class TestClientOptionParser
|
|||||||
Usage:
|
Usage:
|
||||||
rendezvous-test-client host [options]
|
rendezvous-test-client host [options]
|
||||||
rendezvous-test-client browse [options]
|
rendezvous-test-client browse [options]
|
||||||
|
rendezvous-test-client watch [options]
|
||||||
rendezvous-test-client join [options]
|
rendezvous-test-client join [options]
|
||||||
|
|
||||||
Common options:
|
Common options:
|
||||||
@@ -88,6 +92,11 @@ internal static class TestClientOptionParser
|
|||||||
--run-seconds NUMBER Stop after 1-86400 seconds
|
--run-seconds NUMBER Stop after 1-86400 seconds
|
||||||
--exit-after-echo Stop after an authenticated ping/echo/ack exchange
|
--exit-after-echo Stop after an authenticated ping/echo/ack exchange
|
||||||
|
|
||||||
|
Watch options:
|
||||||
|
--run-seconds NUMBER Stop after 1-86400 seconds
|
||||||
|
--exercise-reconnect Disconnect after an update and verify ordered replay
|
||||||
|
--exercise-reset Corrupt the snapshot cursor and verify reset/refresh
|
||||||
|
|
||||||
Join options:
|
Join options:
|
||||||
--listing UUID Join an exact listing; otherwise browse/select
|
--listing UUID Join an exact listing; otherwise browse/select
|
||||||
|
|
||||||
@@ -129,6 +138,8 @@ internal static class TestClientOptionParser
|
|||||||
bool script = false;
|
bool script = false;
|
||||||
bool json = false;
|
bool json = false;
|
||||||
bool exitAfterEcho = false;
|
bool exitAfterEcho = false;
|
||||||
|
bool exerciseReconnect = false;
|
||||||
|
bool exerciseReset = false;
|
||||||
HashSet<string> seen = new(StringComparer.Ordinal);
|
HashSet<string> seen = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
for (int index = 1; index < args.Length; index++)
|
for (int index = 1; index < args.Length; index++)
|
||||||
@@ -138,7 +149,8 @@ internal static class TestClientOptionParser
|
|||||||
{
|
{
|
||||||
return TestClientParseResult.Help();
|
return TestClientParseResult.Help();
|
||||||
}
|
}
|
||||||
if (option is "--script" or "--json" or "--exit-after-echo")
|
if (option is "--script" or "--json" or "--exit-after-echo"
|
||||||
|
or "--exercise-reconnect" or "--exercise-reset")
|
||||||
{
|
{
|
||||||
if (!seen.Add(option))
|
if (!seen.Add(option))
|
||||||
{
|
{
|
||||||
@@ -147,6 +159,8 @@ internal static class TestClientOptionParser
|
|||||||
script |= option == "--script";
|
script |= option == "--script";
|
||||||
json |= option == "--json";
|
json |= option == "--json";
|
||||||
exitAfterEcho |= option == "--exit-after-echo";
|
exitAfterEcho |= option == "--exit-after-echo";
|
||||||
|
exerciseReconnect |= option == "--exercise-reconnect";
|
||||||
|
exerciseReset |= option == "--exercise-reset";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!option.StartsWith("--", StringComparison.Ordinal)
|
if (!option.StartsWith("--", StringComparison.Ordinal)
|
||||||
@@ -279,8 +293,11 @@ internal static class TestClientOptionParser
|
|||||||
return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
|
return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
|
||||||
}
|
}
|
||||||
if (listingId.HasValue && mode != TestClientMode.Join
|
if (listingId.HasValue && mode != TestClientMode.Join
|
||||||
|| runSeconds.HasValue && mode != TestClientMode.Host
|
|| runSeconds.HasValue && mode is not (TestClientMode.Host or TestClientMode.Watch)
|
||||||
|| exitAfterEcho && mode != TestClientMode.Host
|
|| exitAfterEcho && mode != TestClientMode.Host
|
||||||
|
|| exerciseReconnect && mode != TestClientMode.Watch
|
||||||
|
|| exerciseReset && mode != TestClientMode.Watch
|
||||||
|
|| exerciseReconnect && exerciseReset
|
||||||
|| metadata.Count > 0 && mode != TestClientMode.Host
|
|| metadata.Count > 0 && mode != TestClientMode.Host
|
||||||
|| dedicatedFallback is not null && mode != TestClientMode.Host
|
|| dedicatedFallback is not null && mode != TestClientMode.Host
|
||||||
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
|
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
|
||||||
@@ -316,6 +333,8 @@ internal static class TestClientOptionParser
|
|||||||
Script = script,
|
Script = script,
|
||||||
Json = json,
|
Json = json,
|
||||||
ExitAfterEcho = exitAfterEcho,
|
ExitAfterEcho = exitAfterEcho,
|
||||||
|
ExerciseReconnect = exerciseReconnect,
|
||||||
|
ExerciseReset = exerciseReset,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
|||||||
string? displayName = null,
|
string? displayName = null,
|
||||||
string? outcome = null,
|
string? outcome = null,
|
||||||
string? endpointType = null,
|
string? endpointType = null,
|
||||||
|
string? addressFamily = null,
|
||||||
int? count = null,
|
int? count = null,
|
||||||
long? elapsedMilliseconds = null,
|
long? elapsedMilliseconds = null,
|
||||||
string? message = null) => WriteCore(
|
string? message = null) => WriteCore(
|
||||||
@@ -37,6 +38,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
|||||||
DisplayName = SafeText(displayName),
|
DisplayName = SafeText(displayName),
|
||||||
Outcome = SafeToken(outcome),
|
Outcome = SafeToken(outcome),
|
||||||
EndpointType = SafeToken(endpointType),
|
EndpointType = SafeToken(endpointType),
|
||||||
|
AddressFamily = SafeToken(addressFamily),
|
||||||
Count = count,
|
Count = count,
|
||||||
ElapsedMilliseconds = elapsedMilliseconds,
|
ElapsedMilliseconds = elapsedMilliseconds,
|
||||||
Message = SafeText(message),
|
Message = SafeText(message),
|
||||||
@@ -92,6 +94,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
|||||||
Append(line, "name", item.DisplayName, quote: true);
|
Append(line, "name", item.DisplayName, quote: true);
|
||||||
Append(line, "outcome", item.Outcome);
|
Append(line, "outcome", item.Outcome);
|
||||||
Append(line, "endpoint", item.EndpointType);
|
Append(line, "endpoint", item.EndpointType);
|
||||||
|
Append(line, "addressFamily", item.AddressFamily);
|
||||||
if (item.Count.HasValue)
|
if (item.Count.HasValue)
|
||||||
{
|
{
|
||||||
Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
@@ -174,6 +177,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
|||||||
public string? DisplayName { get; init; }
|
public string? DisplayName { get; init; }
|
||||||
public string? Outcome { get; init; }
|
public string? Outcome { get; init; }
|
||||||
public string? EndpointType { get; init; }
|
public string? EndpointType { get; init; }
|
||||||
|
public string? AddressFamily { get; init; }
|
||||||
public int? Count { get; init; }
|
public int? Count { get; init; }
|
||||||
public long? ElapsedMilliseconds { get; init; }
|
public long? ElapsedMilliseconds { get; init; }
|
||||||
public string? Message { get; init; }
|
public string? Message { get; init; }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"net8.0": {
|
"net8.0": {
|
||||||
"LiteNetLib": {
|
"LiteNetLib": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[2.1.4, )",
|
"requested": "[2.1.4, 2.1.4]",
|
||||||
"resolved": "2.1.4",
|
"resolved": "2.1.4",
|
||||||
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
|
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
|
||||||
},
|
},
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"type": "Project",
|
"type": "Project",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
|
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
|
||||||
"LiteNetLib": "[2.1.4, )"
|
"LiteNetLib": "[2.1.4, 2.1.4]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"finalfactory.rendezvous.contracts": {
|
"finalfactory.rendezvous.contracts": {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
namespace FinalFactory.Rendezvous.Capacity;
|
||||||
|
|
||||||
|
internal sealed record CapacityOptions
|
||||||
|
{
|
||||||
|
public required string Profile { get; init; }
|
||||||
|
public required int Listings { get; init; }
|
||||||
|
public required int Attempts { get; init; }
|
||||||
|
public required int Samples { get; init; }
|
||||||
|
public required int SoakCycles { get; init; }
|
||||||
|
public required int SoakSeconds { get; init; }
|
||||||
|
public string? OutputPath { get; init; }
|
||||||
|
|
||||||
|
public static CapacityOptions Parse(string[] args)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> values = ParseArguments(args);
|
||||||
|
string profile = values.GetValueOrDefault("--profile") ?? "quick";
|
||||||
|
(int listings, int attempts, int samples, int soakCycles, int soakSeconds) = profile switch
|
||||||
|
{
|
||||||
|
"quick" => (1_000, 500, 100, 20, 0),
|
||||||
|
"candidate" => (25_000, 10_000, 1_000, 1_000, 300),
|
||||||
|
_ => throw new ArgumentException("--profile must be 'quick' or 'candidate'."),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
Profile = profile,
|
||||||
|
Listings = ParsePositive(values.GetValueOrDefault("--listings"), listings, "--listings"),
|
||||||
|
Attempts = ParsePositive(values.GetValueOrDefault("--attempts"), attempts, "--attempts"),
|
||||||
|
Samples = ParsePositive(values.GetValueOrDefault("--samples"), samples, "--samples"),
|
||||||
|
SoakCycles = ParsePositive(
|
||||||
|
values.GetValueOrDefault("--soak-cycles"),
|
||||||
|
soakCycles,
|
||||||
|
"--soak-cycles"),
|
||||||
|
SoakSeconds = ParseNonNegative(
|
||||||
|
values.GetValueOrDefault("--soak-seconds"),
|
||||||
|
soakSeconds,
|
||||||
|
"--soak-seconds"),
|
||||||
|
OutputPath = values.GetValueOrDefault("--output"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseArguments(string[] args)
|
||||||
|
{
|
||||||
|
HashSet<string> allowed =
|
||||||
|
[
|
||||||
|
"--profile",
|
||||||
|
"--listings",
|
||||||
|
"--attempts",
|
||||||
|
"--samples",
|
||||||
|
"--soak-cycles",
|
||||||
|
"--soak-seconds",
|
||||||
|
"--output",
|
||||||
|
];
|
||||||
|
Dictionary<string, string> values = new(StringComparer.Ordinal);
|
||||||
|
for (int index = 0; index < args.Length; index += 2)
|
||||||
|
{
|
||||||
|
string option = args[index];
|
||||||
|
if (!allowed.Contains(option))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Unknown option: {option}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index == args.Length - 1
|
||||||
|
|| args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"{option} requires a value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.TryAdd(option, args[index + 1]))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"{option} may be supplied only once.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParsePositive(string? value, int fallback, string option) =>
|
||||||
|
value is null
|
||||||
|
? fallback
|
||||||
|
: int.TryParse(value, out int parsed) && parsed > 0
|
||||||
|
? parsed
|
||||||
|
: throw new ArgumentException($"{option} must be a positive integer.");
|
||||||
|
|
||||||
|
private static int ParseNonNegative(string? value, int fallback, string option) =>
|
||||||
|
value is null
|
||||||
|
? fallback
|
||||||
|
: int.TryParse(value, out int parsed) && parsed >= 0
|
||||||
|
? parsed
|
||||||
|
: throw new ArgumentException($"{option} must be a non-negative integer.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
namespace FinalFactory.Rendezvous.Capacity;
|
||||||
|
|
||||||
|
internal sealed record CapacityReport
|
||||||
|
{
|
||||||
|
public required int SchemaVersion { get; init; }
|
||||||
|
public required string EvidenceVersion { get; init; }
|
||||||
|
public required DateTimeOffset GeneratedAt { get; init; }
|
||||||
|
public required string Profile { get; init; }
|
||||||
|
public required RuntimeEvidence Runtime { get; init; }
|
||||||
|
public required CapacityTargets Targets { get; init; }
|
||||||
|
public required IReadOnlyList<CapacityMeasurement> Measurements { get; init; }
|
||||||
|
public required StateEvidence State { get; init; }
|
||||||
|
public required IReadOnlyList<string> Failures { get; init; }
|
||||||
|
public required bool Passed { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RuntimeEvidence(
|
||||||
|
string Framework,
|
||||||
|
string OperatingSystem,
|
||||||
|
string Kernel,
|
||||||
|
string Architecture,
|
||||||
|
string CpuModel,
|
||||||
|
int ProcessorCount,
|
||||||
|
string CpuAffinity,
|
||||||
|
string CpuQuota,
|
||||||
|
string MemoryLimit,
|
||||||
|
string GarbageCollector,
|
||||||
|
string CommitSha,
|
||||||
|
string TreeState,
|
||||||
|
string Command,
|
||||||
|
string ImageDigest,
|
||||||
|
string WorkloadSeed,
|
||||||
|
double CapacityPhaseAverageCpuPercent,
|
||||||
|
long PeakWorkingSetBytes,
|
||||||
|
long ManagedBytesAfterCleanup);
|
||||||
|
|
||||||
|
internal sealed record CapacityTargets(
|
||||||
|
int VisibleListings,
|
||||||
|
int ActiveJoinAttempts,
|
||||||
|
int CoreControlOperationsPerSecond,
|
||||||
|
int CoreMediationOperationsPerSecond,
|
||||||
|
double CoreControlP95Milliseconds,
|
||||||
|
double CoreMediationP95Milliseconds,
|
||||||
|
double MaximumAverageCpuPercent,
|
||||||
|
long MaximumWorkingSetBytes,
|
||||||
|
int SoakCycles,
|
||||||
|
int SoakDurationSeconds);
|
||||||
|
|
||||||
|
internal sealed record CapacityMeasurement(
|
||||||
|
string Operation,
|
||||||
|
int Samples,
|
||||||
|
double P50Milliseconds,
|
||||||
|
double P95Milliseconds,
|
||||||
|
double P99Milliseconds,
|
||||||
|
double OperationsPerSecond,
|
||||||
|
double MinimumOperationsPerSecond,
|
||||||
|
double BudgetMilliseconds,
|
||||||
|
bool Passed);
|
||||||
|
|
||||||
|
internal sealed record StateEvidence(
|
||||||
|
int PeakListings,
|
||||||
|
int PeakAttempts,
|
||||||
|
int PeakReplayMarkers,
|
||||||
|
int FinalListings,
|
||||||
|
int FinalAttempts,
|
||||||
|
int FinalReplayMarkers,
|
||||||
|
long ExpiryChurn,
|
||||||
|
long MaintenanceSweeps,
|
||||||
|
int SoakCyclesCompleted,
|
||||||
|
double SoakDurationSeconds,
|
||||||
|
int SoakPeakScheduledExpiryEntries,
|
||||||
|
long SoakManagedGrowthBytes,
|
||||||
|
int SoakHandleGrowth,
|
||||||
|
bool RestartStartedEmpty,
|
||||||
|
bool OverloadWasTyped,
|
||||||
|
bool RecoverySucceeded);
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Capacity;
|
||||||
|
|
||||||
|
internal static class CapacityRunner
|
||||||
|
{
|
||||||
|
private static readonly TenantScope Scope = new(new("space-game"), new("production"));
|
||||||
|
private const uint ProtocolVersion = 1;
|
||||||
|
|
||||||
|
public static Task<CapacityReport> RunAsync(CapacityOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
Process process = Process.GetCurrentProcess();
|
||||||
|
TimeSpan cpuBefore = process.TotalProcessorTime;
|
||||||
|
Stopwatch capacityPhaseTime = Stopwatch.StartNew();
|
||||||
|
List<string> failures = [];
|
||||||
|
List<CapacityMeasurement> measurements = [];
|
||||||
|
ManualClock clock = new();
|
||||||
|
InMemoryEphemeralRendezvousStore store = CreateStore(options, clock);
|
||||||
|
List<StoredListing> listings = new(options.Listings);
|
||||||
|
List<CreateJoinAttemptCommand> attempts = new(options.Attempts);
|
||||||
|
int registrationSamples = Math.Min(options.Samples, options.Listings);
|
||||||
|
int attemptSamples = Math.Min(options.Samples, options.Attempts);
|
||||||
|
|
||||||
|
for (int index = 0; index < options.Listings - registrationSamples; index++)
|
||||||
|
{
|
||||||
|
listings.Add(CreateVisibleListing(store, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"registration-and-presence",
|
||||||
|
registrationSamples,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 200,
|
||||||
|
index => listings.Add(CreateVisibleListing(
|
||||||
|
store,
|
||||||
|
options.Listings - registrationSamples + index))));
|
||||||
|
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"lease-renewal",
|
||||||
|
registrationSamples,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 200,
|
||||||
|
index =>
|
||||||
|
{
|
||||||
|
StoredListing listing = listings[index];
|
||||||
|
StoreResult<StoredListing> renewed = store.RenewLease(new(
|
||||||
|
listing.Definition.ListingId,
|
||||||
|
listing.Definition.LeaseId,
|
||||||
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
|
listing.Version));
|
||||||
|
RequireSuccess(renewed, "renewal");
|
||||||
|
listings[index] = renewed.Value!;
|
||||||
|
}));
|
||||||
|
|
||||||
|
int browseSamples = Math.Min(options.Samples, 250);
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"visible-session-browse",
|
||||||
|
browseSamples,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 200,
|
||||||
|
_ => RequireSuccess(
|
||||||
|
store.BrowseVisibleListings(new(
|
||||||
|
Scope,
|
||||||
|
ProtocolVersion,
|
||||||
|
new RegionId("eu-central"),
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
ExcludeFull: true)),
|
||||||
|
"browse")));
|
||||||
|
|
||||||
|
for (int index = 0; index < options.Attempts - attemptSamples; index++)
|
||||||
|
{
|
||||||
|
CreateJoinAttemptCommand command = CreateAttempt(index, listings[index % listings.Count]);
|
||||||
|
RequireSuccess(store.CreateJoinAttempt(command), "join issuance");
|
||||||
|
attempts.Add(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"join-attempt-issuance",
|
||||||
|
attemptSamples,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 200,
|
||||||
|
index =>
|
||||||
|
{
|
||||||
|
int sequence = options.Attempts - attemptSamples + index;
|
||||||
|
CreateJoinAttemptCommand command = CreateAttempt(
|
||||||
|
sequence,
|
||||||
|
listings[sequence % listings.Count]);
|
||||||
|
RequireSuccess(store.CreateJoinAttempt(command), "join issuance");
|
||||||
|
attempts.Add(command);
|
||||||
|
}));
|
||||||
|
|
||||||
|
int punchSamples = Math.Min(options.Samples, attempts.Count);
|
||||||
|
measurements.Add(MeasureConcurrentPunch(store, attempts, punchSamples));
|
||||||
|
EphemeralStoreSnapshot peak = store.GetSnapshot();
|
||||||
|
|
||||||
|
StoreResult<StoredJoinAttempt> overloaded = store.CreateJoinAttempt(
|
||||||
|
CreateAttempt(options.Attempts + 1, listings[^1]));
|
||||||
|
bool overloadWasTyped = peak.ActiveJoinAttempts == options.Attempts
|
||||||
|
&& overloaded.Code == StoreResultCode.CapacityExceeded;
|
||||||
|
if (!overloadWasTyped)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"Expected typed CapacityExceeded at {options.Attempts} active attempts, "
|
||||||
|
+ $"observed count={peak.ActiveJoinAttempts}, result={overloaded.Code}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int revocationSamples = Math.Min(Math.Max(1, options.Samples / 20), listings.Count / 2);
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"principal-revocation",
|
||||||
|
revocationSamples,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 50,
|
||||||
|
index => RequireSuccess(
|
||||||
|
store.RevokePrincipal(
|
||||||
|
listings[index].Definition.OwnerSubject,
|
||||||
|
TimeSpan.FromMinutes(1)),
|
||||||
|
"principal revocation")));
|
||||||
|
|
||||||
|
using (RendezvousTelemetry telemetry = new(store))
|
||||||
|
{
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"telemetry-recording",
|
||||||
|
Math.Max(100, options.Samples),
|
||||||
|
budgetMilliseconds: 1,
|
||||||
|
minimumOperationsPerSecond: 10_000,
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
telemetry.RecordHttp("browse", 200, 1);
|
||||||
|
telemetry.RecordUdp("contribution", "accepted", 1);
|
||||||
|
telemetry.RecordPairingLatency(2);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(61));
|
||||||
|
EphemeralStoreSnapshot? afterCoincidentExpiry = null;
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"coincident-listing-attempt-expiry",
|
||||||
|
samples: 1,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 0,
|
||||||
|
_ => afterCoincidentExpiry = store.GetSnapshot()));
|
||||||
|
if (afterCoincidentExpiry!.ActiveJoinAttempts != 0
|
||||||
|
|| afterCoincidentExpiry.ActiveListings != 0)
|
||||||
|
{
|
||||||
|
failures.Add("Coincident 60-second cleanup retained expired listings or join attempts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(90));
|
||||||
|
_ = store.GetSnapshot();
|
||||||
|
StoredListing recoveryListing = CreateVisibleListing(store, options.Listings + 1);
|
||||||
|
StoreResult<StoredJoinAttempt> recovered = store.CreateJoinAttempt(
|
||||||
|
CreateAttempt(options.Attempts + 2, recoveryListing));
|
||||||
|
bool recoverySucceeded = recovered.Succeeded;
|
||||||
|
if (!recoverySucceeded)
|
||||||
|
{
|
||||||
|
failures.Add($"Store did not recover after attempt expiry: {recovered.Code}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(151));
|
||||||
|
EphemeralStoreSnapshot final = store.GetSnapshot();
|
||||||
|
if (final.ActiveListings != 0
|
||||||
|
|| final.ActiveJoinAttempts != 0
|
||||||
|
|| final.ReplayMarkers != 0
|
||||||
|
|| final.IdempotencyEntries != 0
|
||||||
|
|| final.RetainedOutcomeReports != 0)
|
||||||
|
{
|
||||||
|
failures.Add("Expiry cleanup left active or retained state after every configured deadline.");
|
||||||
|
}
|
||||||
|
|
||||||
|
capacityPhaseTime.Stop();
|
||||||
|
process.Refresh();
|
||||||
|
double capacityPhaseCpuPercent = 100
|
||||||
|
* (process.TotalProcessorTime - cpuBefore).TotalSeconds
|
||||||
|
/ Math.Max(capacityPhaseTime.Elapsed.TotalSeconds * Environment.ProcessorCount, 0.000_001);
|
||||||
|
if (options.Profile == "candidate" && capacityPhaseCpuPercent > 70)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"Capacity-phase CPU {capacityPhaseCpuPercent:F1}% exceeded the 70% candidate budget.");
|
||||||
|
}
|
||||||
|
|
||||||
|
SoakEvidence soak = RunAcceleratedSoak(options, failures);
|
||||||
|
ManualClock restartClock = new();
|
||||||
|
EphemeralStoreSnapshot restarted = CreateStore(options, restartClock).GetSnapshot();
|
||||||
|
bool restartStartedEmpty = restarted.ActiveListings == 0
|
||||||
|
&& restarted.ActiveJoinAttempts == 0
|
||||||
|
&& restarted.ReplayMarkers == 0;
|
||||||
|
if (!restartStartedEmpty)
|
||||||
|
{
|
||||||
|
failures.Add("A restarted store did not begin empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (CapacityMeasurement measurement in measurements.Where(static item => !item.Passed))
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"{measurement.Operation} missed its budget: p95={measurement.P95Milliseconds:F3} ms, "
|
||||||
|
+ $"rate={measurement.OperationsPerSecond:F1}/s.");
|
||||||
|
}
|
||||||
|
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
process.Refresh();
|
||||||
|
long managedAfterCleanup = GC.GetTotalMemory(forceFullCollection: true);
|
||||||
|
long memoryBudget = 1_610_612_736;
|
||||||
|
if (process.PeakWorkingSet64 > memoryBudget)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"Peak working set {process.PeakWorkingSet64} exceeded the 1.5 GiB profile budget.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.Profile == "candidate" && Environment.ProcessorCount != 2)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"Candidate evidence must expose exactly two CPUs; runtime exposed "
|
||||||
|
+ $"{Environment.ProcessorCount}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
CapacityReport report = new()
|
||||||
|
{
|
||||||
|
SchemaVersion = 2,
|
||||||
|
EvidenceVersion = "v2",
|
||||||
|
GeneratedAt = DateTimeOffset.UtcNow,
|
||||||
|
Profile = options.Profile,
|
||||||
|
Runtime = new(
|
||||||
|
RuntimeInformation.FrameworkDescription,
|
||||||
|
RuntimeInformation.OSDescription,
|
||||||
|
Environment.OSVersion.VersionString,
|
||||||
|
RuntimeInformation.ProcessArchitecture.ToString(),
|
||||||
|
ReadCpuModel(),
|
||||||
|
Environment.ProcessorCount,
|
||||||
|
Environment.GetEnvironmentVariable("RENDEZVOUS_EVIDENCE_CPUSET") ?? "unrestricted",
|
||||||
|
ReadCgroupValue("/sys/fs/cgroup/cpu.max"),
|
||||||
|
ReadCgroupValue("/sys/fs/cgroup/memory.max"),
|
||||||
|
GCSettings.IsServerGC ? "server" : "workstation",
|
||||||
|
Environment.GetEnvironmentVariable("RENDEZVOUS_EVIDENCE_COMMIT") ?? "unrecorded",
|
||||||
|
Environment.GetEnvironmentVariable("RENDEZVOUS_EVIDENCE_TREE_STATE") ?? "unrecorded",
|
||||||
|
Environment.GetEnvironmentVariable("RENDEZVOUS_EVIDENCE_COMMAND") ?? "unrecorded",
|
||||||
|
Environment.GetEnvironmentVariable("RENDEZVOUS_EVIDENCE_IMAGE_DIGEST") ?? "not-containerized",
|
||||||
|
"fixed-sequences-random-identifiers",
|
||||||
|
capacityPhaseCpuPercent,
|
||||||
|
process.PeakWorkingSet64,
|
||||||
|
managedAfterCleanup),
|
||||||
|
Targets = new(
|
||||||
|
options.Listings,
|
||||||
|
options.Attempts,
|
||||||
|
200,
|
||||||
|
2_000,
|
||||||
|
200,
|
||||||
|
100,
|
||||||
|
70,
|
||||||
|
memoryBudget,
|
||||||
|
options.SoakCycles,
|
||||||
|
options.SoakSeconds),
|
||||||
|
Measurements = measurements,
|
||||||
|
State = new(
|
||||||
|
peak.ActiveListings,
|
||||||
|
peak.ActiveJoinAttempts,
|
||||||
|
peak.ReplayMarkers,
|
||||||
|
final.ActiveListings,
|
||||||
|
final.ActiveJoinAttempts,
|
||||||
|
final.ReplayMarkers,
|
||||||
|
final.ExpiryChurn,
|
||||||
|
final.MaintenanceSweeps,
|
||||||
|
soak.Cycles,
|
||||||
|
soak.Duration.TotalSeconds,
|
||||||
|
soak.PeakScheduledExpiryEntries,
|
||||||
|
soak.ManagedGrowthBytes,
|
||||||
|
soak.HandleGrowth,
|
||||||
|
restartStartedEmpty,
|
||||||
|
overloadWasTyped,
|
||||||
|
recoverySucceeded),
|
||||||
|
Failures = failures,
|
||||||
|
Passed = failures.Count == 0,
|
||||||
|
};
|
||||||
|
return Task.FromResult(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InMemoryEphemeralRendezvousStore CreateStore(
|
||||||
|
CapacityOptions options,
|
||||||
|
ManualClock clock) => new(
|
||||||
|
new EphemeralStoreOptions
|
||||||
|
{
|
||||||
|
MaxListings = options.Listings,
|
||||||
|
MaxPresenceBindings = options.Listings,
|
||||||
|
MaxJoinAttempts = options.Attempts,
|
||||||
|
MaxOutcomeReports = options.Attempts,
|
||||||
|
MaxIdempotencyEntries = options.Listings + options.Attempts + 1,
|
||||||
|
},
|
||||||
|
clock,
|
||||||
|
clock);
|
||||||
|
|
||||||
|
private static StoredListing CreateVisibleListing(
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
int sequence)
|
||||||
|
{
|
||||||
|
string owner = $"publisher-{sequence}";
|
||||||
|
SecretFingerprint leaseFingerprint = new($"lease-{sequence}");
|
||||||
|
SecretFingerprint presenceFingerprint = new($"presence-{sequence}");
|
||||||
|
ListingDefinition definition = new()
|
||||||
|
{
|
||||||
|
ListingId = new(Guid.NewGuid()),
|
||||||
|
LeaseId = new(Guid.NewGuid()),
|
||||||
|
Scope = Scope,
|
||||||
|
OwnerSubject = owner,
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = ProtocolVersion,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = $"Capacity host {sequence}",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
TrustMode = PublisherTrustMode.ManagedDedicated,
|
||||||
|
CurrentPlayers = 1,
|
||||||
|
MaximumPlayers = 8,
|
||||||
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal),
|
||||||
|
LeaseFingerprint = leaseFingerprint,
|
||||||
|
HostPresenceHandle = new(Guid.NewGuid()),
|
||||||
|
HostPresenceFingerprint = presenceFingerprint,
|
||||||
|
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
};
|
||||||
|
StoreResult<StoredListing> created = store.CreateListing(new(
|
||||||
|
$"register-{sequence}",
|
||||||
|
$"register-request-{sequence}",
|
||||||
|
definition));
|
||||||
|
RequireSuccess(created, "registration");
|
||||||
|
StoreResult<StoredListing> bound = store.BindHostPresence(new(
|
||||||
|
definition.HostPresenceHandle,
|
||||||
|
presenceFingerprint,
|
||||||
|
PublicEndpoint(10_000 + sequence % 50_000),
|
||||||
|
null));
|
||||||
|
RequireSuccess(bound, "host presence");
|
||||||
|
return bound.Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateJoinAttemptCommand CreateAttempt(int sequence, StoredListing listing) => new()
|
||||||
|
{
|
||||||
|
IdempotencyOwner = $"client-{sequence}",
|
||||||
|
IdempotencyKey = $"join-{sequence}",
|
||||||
|
RequestFingerprint = $"join-request-{sequence}",
|
||||||
|
ClientSubject = $"client-{sequence}",
|
||||||
|
AttemptId = new(Guid.NewGuid()),
|
||||||
|
MediationHandle = new(Guid.NewGuid()),
|
||||||
|
Scope = Scope,
|
||||||
|
ListingId = listing.Definition.ListingId,
|
||||||
|
ProtocolVersion = ProtocolVersion,
|
||||||
|
HostCapabilityFingerprint = new($"host-capability-{sequence}"),
|
||||||
|
ClientCapabilityFingerprint = new($"client-capability-{sequence}"),
|
||||||
|
ConnectionTicketFingerprint = new($"ticket-{sequence}"),
|
||||||
|
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static CapacityMeasurement MeasureConcurrentPunch(
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
List<CreateJoinAttemptCommand> attempts,
|
||||||
|
int samples)
|
||||||
|
{
|
||||||
|
ConcurrentBag<double> latencies = [];
|
||||||
|
Stopwatch total = Stopwatch.StartNew();
|
||||||
|
Parallel.ForEach(
|
||||||
|
Enumerable.Range(0, samples),
|
||||||
|
new ParallelOptions { MaxDegreeOfParallelism = Math.Min(64, Environment.ProcessorCount * 4) },
|
||||||
|
index =>
|
||||||
|
{
|
||||||
|
CreateJoinAttemptCommand attempt = attempts[index];
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
RequireSuccess(store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Host,
|
||||||
|
attempt.HostCapabilityFingerprint,
|
||||||
|
PublicEndpoint(20_000 + index % 20_000),
|
||||||
|
null)), "host punch");
|
||||||
|
RequireSuccess(store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
attempt.ClientCapabilityFingerprint,
|
||||||
|
PublicEndpoint(40_000 + index % 20_000),
|
||||||
|
null)), "client punch");
|
||||||
|
RequireSuccess(store.ConsumeIntroduction(attempt.MediationHandle), "introduction");
|
||||||
|
latencies.Add(elapsed.Elapsed.TotalMilliseconds);
|
||||||
|
});
|
||||||
|
total.Stop();
|
||||||
|
return BuildMeasurement(
|
||||||
|
"simultaneous-punch-pairing",
|
||||||
|
latencies.ToArray(),
|
||||||
|
total.Elapsed,
|
||||||
|
budgetMilliseconds: 100,
|
||||||
|
minimumOperationsPerSecond: 2_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CapacityMeasurement Measure(
|
||||||
|
string operation,
|
||||||
|
int samples,
|
||||||
|
double budgetMilliseconds,
|
||||||
|
double minimumOperationsPerSecond,
|
||||||
|
Action<int> action)
|
||||||
|
{
|
||||||
|
double[] latencies = new double[samples];
|
||||||
|
Stopwatch total = Stopwatch.StartNew();
|
||||||
|
for (int index = 0; index < samples; index++)
|
||||||
|
{
|
||||||
|
long started = Stopwatch.GetTimestamp();
|
||||||
|
action(index);
|
||||||
|
latencies[index] = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
total.Stop();
|
||||||
|
return BuildMeasurement(
|
||||||
|
operation,
|
||||||
|
latencies,
|
||||||
|
total.Elapsed,
|
||||||
|
budgetMilliseconds,
|
||||||
|
minimumOperationsPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CapacityMeasurement BuildMeasurement(
|
||||||
|
string operation,
|
||||||
|
double[] latencies,
|
||||||
|
TimeSpan elapsed,
|
||||||
|
double budgetMilliseconds,
|
||||||
|
double minimumOperationsPerSecond)
|
||||||
|
{
|
||||||
|
Array.Sort(latencies);
|
||||||
|
double operationsPerSecond = latencies.Length / Math.Max(elapsed.TotalSeconds, 0.000_001);
|
||||||
|
double p95 = Percentile(latencies, 0.95);
|
||||||
|
return new(
|
||||||
|
operation,
|
||||||
|
latencies.Length,
|
||||||
|
Percentile(latencies, 0.50),
|
||||||
|
p95,
|
||||||
|
Percentile(latencies, 0.99),
|
||||||
|
operationsPerSecond,
|
||||||
|
minimumOperationsPerSecond,
|
||||||
|
budgetMilliseconds,
|
||||||
|
p95 <= budgetMilliseconds && operationsPerSecond >= minimumOperationsPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Percentile(double[] sorted, double percentile)
|
||||||
|
{
|
||||||
|
int index = Math.Clamp((int)Math.Ceiling(sorted.Length * percentile) - 1, 0, sorted.Length - 1);
|
||||||
|
return sorted[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SoakEvidence RunAcceleratedSoak(
|
||||||
|
CapacityOptions options,
|
||||||
|
List<string> failures)
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
long managedBefore = GC.GetTotalMemory(forceFullCollection: true);
|
||||||
|
int handlesBefore = Process.GetCurrentProcess().HandleCount;
|
||||||
|
ManualClock clock = new();
|
||||||
|
CapacityOptions soakOptions = options with { Listings = 100, Attempts = 100 };
|
||||||
|
InMemoryEphemeralRendezvousStore store = CreateStore(soakOptions, clock);
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
int cycle = 0;
|
||||||
|
int peakScheduledExpiryEntries = 0;
|
||||||
|
while (cycle < options.SoakCycles
|
||||||
|
|| elapsed.Elapsed < TimeSpan.FromSeconds(options.SoakSeconds))
|
||||||
|
{
|
||||||
|
StoredListing listing = CreateVisibleListing(store, cycle);
|
||||||
|
for (int refresh = 0; refresh < 10; refresh++)
|
||||||
|
{
|
||||||
|
clock.Advance(TimeSpan.FromTicks(1));
|
||||||
|
listing = RequireSuccess(store.RenewLease(new(
|
||||||
|
listing.Definition.ListingId,
|
||||||
|
listing.Definition.LeaseId,
|
||||||
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
|
listing.Version)), "soak lease refresh");
|
||||||
|
listing = RequireSuccess(store.BindHostPresence(new(
|
||||||
|
listing.Definition.HostPresenceHandle,
|
||||||
|
listing.Definition.HostPresenceFingerprint,
|
||||||
|
PublicEndpoint(10_000 + cycle % 50_000),
|
||||||
|
null)), "soak presence refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateJoinAttemptCommand attempt = CreateAttempt(cycle, listing);
|
||||||
|
RequireSuccess(store.CreateJoinAttempt(attempt), "soak join issuance");
|
||||||
|
RequireSuccess(store.ConsumeReplay(new("capacity-soak", $"replay-{cycle}")), "soak replay");
|
||||||
|
peakScheduledExpiryEntries = Math.Max(
|
||||||
|
peakScheduledExpiryEntries,
|
||||||
|
store.ScheduledExpiryEntryCount);
|
||||||
|
if (store.ScheduledExpiryEntryCount > 7)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"Mutable deadline refresh grew the expiry queue to "
|
||||||
|
+ $"{store.ScheduledExpiryEntryCount} entries for one lifecycle.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(151));
|
||||||
|
EphemeralStoreSnapshot snapshot = store.GetSnapshot();
|
||||||
|
if (snapshot.ActiveListings != 0
|
||||||
|
|| snapshot.ActiveJoinAttempts != 0
|
||||||
|
|| snapshot.ReplayMarkers != 0
|
||||||
|
|| snapshot.IdempotencyEntries != 0
|
||||||
|
|| snapshot.RetainedOutcomeReports != 0)
|
||||||
|
{
|
||||||
|
failures.Add($"Accelerated soak retained state after cycle {cycle}.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
cycle++;
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed.Stop();
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
long managedGrowth = GC.GetTotalMemory(forceFullCollection: true) - managedBefore;
|
||||||
|
int handleGrowth = Process.GetCurrentProcess().HandleCount - handlesBefore;
|
||||||
|
if (managedGrowth > 67_108_864)
|
||||||
|
{
|
||||||
|
failures.Add($"Soak retained {managedGrowth} managed bytes; budget is 64 MiB.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handleGrowth > 8)
|
||||||
|
{
|
||||||
|
failures.Add($"Soak retained {handleGrowth} process handles; budget is 8.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(cycle, elapsed.Elapsed, peakScheduledExpiryEntries, managedGrowth, handleGrowth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ObservedEndpoint PublicEndpoint(int port) =>
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.10", port);
|
||||||
|
|
||||||
|
private static T RequireSuccess<T>(StoreResult<T> result, string operation)
|
||||||
|
{
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{operation} failed with {result.Code}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadCpuModel()
|
||||||
|
{
|
||||||
|
const string cpuInfoPath = "/proc/cpuinfo";
|
||||||
|
if (!File.Exists(cpuInfoPath))
|
||||||
|
{
|
||||||
|
return "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
string? model = File.ReadLines(cpuInfoPath)
|
||||||
|
.FirstOrDefault(static line => line.StartsWith("model name", StringComparison.Ordinal));
|
||||||
|
int separator = model?.IndexOf(':') ?? -1;
|
||||||
|
return separator >= 0 ? model![(separator + 1)..].Trim() : "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadCgroupValue(string path) =>
|
||||||
|
File.Exists(path) ? File.ReadAllText(path).Trim() : "not-enforced";
|
||||||
|
|
||||||
|
private sealed class ManualClock : IWallClock, IMonotonicClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow { get; private set; } = DateTimeOffset.UtcNow;
|
||||||
|
public TimeSpan Elapsed { get; private set; }
|
||||||
|
|
||||||
|
public void Advance(TimeSpan duration)
|
||||||
|
{
|
||||||
|
UtcNow += duration;
|
||||||
|
Elapsed += duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct SoakEvidence(
|
||||||
|
int Cycles,
|
||||||
|
TimeSpan Duration,
|
||||||
|
int PeakScheduledExpiryEntries,
|
||||||
|
long ManagedGrowthBytes,
|
||||||
|
int HandleGrowth);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<AssemblyName>FinalFactory.Rendezvous.Capacity</AssemblyName>
|
||||||
|
<RootNamespace>FinalFactory.Rendezvous.Capacity</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="../../src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Capacity;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static async Task<int> Main(string[] args)
|
||||||
|
{
|
||||||
|
CapacityOptions options;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
options = CapacityOptions.Parse(args);
|
||||||
|
}
|
||||||
|
catch (ArgumentException exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(exception.Message);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
CapacityReport report = await CapacityRunner.RunAsync(options).ConfigureAwait(false);
|
||||||
|
string json = JsonSerializer.Serialize(report, JsonOptions);
|
||||||
|
Console.WriteLine(json);
|
||||||
|
if (options.OutputPath is not null)
|
||||||
|
{
|
||||||
|
string fullPath = Path.GetFullPath(options.OutputPath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||||
|
await File.WriteAllTextAsync(fullPath, json + Environment.NewLine).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return report.Passed ? 0 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"dependencies": {
|
||||||
|
"net10.0": {
|
||||||
|
"finalfactory.rendezvous.contracts": {
|
||||||
|
"type": "Project"
|
||||||
|
},
|
||||||
|
"finalfactory.rendezvous.server": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
|
||||||
|
"LiteNetLib": "[2.1.4, 2.1.4]",
|
||||||
|
"Microsoft.AspNetCore.OpenApi": "[10.0.9, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LiteNetLib": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2.1.4, 2.1.4]",
|
||||||
|
"resolved": "2.1.4",
|
||||||
|
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
|
||||||
|
},
|
||||||
|
"Microsoft.AspNetCore.OpenApi": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[10.0.9, )",
|
||||||
|
"resolved": "10.0.9",
|
||||||
|
"contentHash": "1ihb8FO9cGgEK1/m3CTtT/SfnynwmiZib0W2pcDVj3KSWk/Sca4VOXEtaptKQc582zpFrzTFiwkGRCglt6H+WQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.OpenApi": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.OpenApi": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2.7.5, )",
|
||||||
|
"resolved": "2.7.5",
|
||||||
|
"contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,18 +7,25 @@ namespace FinalFactory.Rendezvous.Tests.Browser;
|
|||||||
|
|
||||||
internal sealed class SessionBrowserFixture : IDisposable
|
internal sealed class SessionBrowserFixture : IDisposable
|
||||||
{
|
{
|
||||||
private readonly EphemeralStateFixture _state = new();
|
private readonly EphemeralStateFixture _state;
|
||||||
|
|
||||||
public SessionBrowserFixture()
|
public SessionBrowserFixture()
|
||||||
{
|
{
|
||||||
|
Changes = new(new SessionChangeJournalOptions());
|
||||||
|
_state = new(changes: Changes);
|
||||||
Cursors = new();
|
Cursors = new();
|
||||||
Browser = new(_state.Store, Cursors, _state.Clock);
|
StreamCursors = new();
|
||||||
|
Browser = new(_state.Store, Cursors, StreamCursors, Changes, _state.Clock);
|
||||||
|
Streams = new(Changes, StreamCursors, _state.Clock);
|
||||||
}
|
}
|
||||||
|
|
||||||
public InMemoryEphemeralRendezvousStore Store => _state.Store;
|
public InMemoryEphemeralRendezvousStore Store => _state.Store;
|
||||||
public ManualRendezvousClock Clock => _state.Clock;
|
public ManualRendezvousClock Clock => _state.Clock;
|
||||||
public SessionBrowserCursorCodec Cursors { get; }
|
public SessionBrowserCursorCodec Cursors { get; }
|
||||||
|
public SessionStreamCursorCodec StreamCursors { get; }
|
||||||
|
public SessionChangeJournal Changes { get; }
|
||||||
public SessionBrowserService Browser { get; }
|
public SessionBrowserService Browser { get; }
|
||||||
|
public SessionStreamService Streams { get; }
|
||||||
public TenantScope Scope => _state.Scope;
|
public TenantScope Scope => _state.Scope;
|
||||||
|
|
||||||
public StoredListing Add(
|
public StoredListing Add(
|
||||||
@@ -67,5 +74,9 @@ internal sealed class SessionBrowserFixture : IDisposable
|
|||||||
PageSize = pageSize,
|
PageSize = pageSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
public void Dispose() => Cursors.Dispose();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Cursors.Dispose();
|
||||||
|
StreamCursors.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||||
|
|
||||||
|
public sealed class SessionStreamServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SnapshotPlusUpdateMatchesFreshProjection()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.Add();
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(
|
||||||
|
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||||
|
|
||||||
|
StoreResult<StoredListing> updated = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
displayName: "Updated host",
|
||||||
|
currentPlayers: 4));
|
||||||
|
Assert.True(updated.Succeeded);
|
||||||
|
SessionStreamEvent delta = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, delta.Kind);
|
||||||
|
Assert.Equal("Updated host", delta.Session!.DisplayName);
|
||||||
|
Assert.Equal(4, delta.Session.Capacity.CurrentPlayers);
|
||||||
|
|
||||||
|
BrowseSessionsResponse fresh = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
SessionListing expected = Assert.Single(fresh.Items);
|
||||||
|
Assert.Equal(expected.DisplayName, delta.Session.DisplayName);
|
||||||
|
Assert.Equal(expected.Capacity.CurrentPlayers, delta.Session.Capacity.CurrentPlayers);
|
||||||
|
Assert.DoesNotContain("lease", System.Text.Json.JsonSerializer.Serialize(delta, ContractJson.Options), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresenceStalenessRecoveryAndRevocationProduceRemoveUpsertRemove()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.Add();
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(
|
||||||
|
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||||
|
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(21));
|
||||||
|
AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
SessionStreamEvent stale = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, stale.Kind);
|
||||||
|
Assert.Equal(listing.Definition.ListingId, stale.ListingId);
|
||||||
|
|
||||||
|
StoreResult<StoredListing> rebound = fixture.Store.BindHostPresence(new(
|
||||||
|
listing.Definition.HostPresenceHandle,
|
||||||
|
listing.Definition.HostPresenceFingerprint,
|
||||||
|
new ObservedEndpoint(AddressFamilyKind.Ipv4, "203.0.113.20", 40_020),
|
||||||
|
null));
|
||||||
|
Assert.True(rebound.Succeeded);
|
||||||
|
Assert.Equal(
|
||||||
|
SessionStreamEventKind.SessionUpsert,
|
||||||
|
Assert.Single(fixture.Streams.Read(subscription).Events).Kind);
|
||||||
|
|
||||||
|
Assert.True(fixture.Store.RevokeListing(listing.Definition.ListingId).Succeeded);
|
||||||
|
Assert.Equal(
|
||||||
|
SessionStreamEventKind.SessionRemove,
|
||||||
|
Assert.Single(fixture.Streams.Read(subscription).Events).Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreationAndLeaseExpiryProduceUpsertThenRemove()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(
|
||||||
|
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||||
|
|
||||||
|
StoredListing listing = fixture.Add();
|
||||||
|
SessionStreamEvent created = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, created.Kind);
|
||||||
|
Assert.Equal(listing.Definition.ListingId, created.Session!.ListingId);
|
||||||
|
|
||||||
|
for (int refresh = 0; refresh < 3; refresh++)
|
||||||
|
{
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(19));
|
||||||
|
Assert.True(fixture.Store.BindHostPresence(new(
|
||||||
|
listing.Definition.HostPresenceHandle,
|
||||||
|
listing.Definition.HostPresenceFingerprint,
|
||||||
|
new ObservedEndpoint(AddressFamilyKind.Ipv4, "203.0.113.20", 40_020),
|
||||||
|
null)).Succeeded);
|
||||||
|
}
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(4));
|
||||||
|
AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
|
||||||
|
SessionStreamEvent expired = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, expired.Kind);
|
||||||
|
Assert.Equal(listing.Definition.ListingId, expired.ListingId);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.NotFound,
|
||||||
|
fixture.Store.GetListing(listing.Definition.ListingId, requireFreshPresence: false).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScopeProtocolRegionAndFullFiltersNeverLeak()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
request.ExcludeFull = true;
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(
|
||||||
|
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||||
|
|
||||||
|
fixture.Add(scope: new(new("other-game"), fixture.Scope.EnvironmentId));
|
||||||
|
fixture.Add(protocolVersion: 99);
|
||||||
|
fixture.Add(regionId: new("other-region"));
|
||||||
|
fixture.Add(currentPlayers: 8, maximumPlayers: 8);
|
||||||
|
Assert.Empty(fixture.Streams.Read(subscription).Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VisibilityCompatibilityAndRegionChangesEnterAndLeaveTheFilter()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.Add();
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(
|
||||||
|
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||||
|
|
||||||
|
listing = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
listing.Definition.DisplayName,
|
||||||
|
1,
|
||||||
|
visibility: ListingVisibility.Unlisted)).Value!;
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
|
||||||
|
listing = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
listing.Definition.DisplayName,
|
||||||
|
1,
|
||||||
|
visibility: ListingVisibility.Public)).Value!;
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, SingleKind(fixture, subscription));
|
||||||
|
listing = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
listing.Definition.DisplayName,
|
||||||
|
1,
|
||||||
|
protocolVersion: 99)).Value!;
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
|
||||||
|
listing = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
listing.Definition.DisplayName,
|
||||||
|
1,
|
||||||
|
protocolVersion: 7)).Value!;
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, SingleKind(fixture, subscription));
|
||||||
|
listing = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
listing.Definition.DisplayName,
|
||||||
|
1,
|
||||||
|
regionId: new RegionId("other-region"))).Value!;
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReplayGapAndForeignCursorForceResetAndSubscriberLimitFailsClosed()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new();
|
||||||
|
SessionChangeJournal changes = new(new SessionChangeJournalOptions
|
||||||
|
{
|
||||||
|
ReplayCapacity = 64,
|
||||||
|
MaximumSubscribers = 1,
|
||||||
|
MaximumSubscribersPerTenant = 1,
|
||||||
|
});
|
||||||
|
using SessionStreamCursorCodec cursors = new();
|
||||||
|
SessionStreamService streams = new(changes, cursors, clock);
|
||||||
|
EphemeralStateFixture state = new(changes: changes);
|
||||||
|
StoredListing listing = state.CreateVisibleListing(out _);
|
||||||
|
BrowseSessionsRequest request = new()
|
||||||
|
{
|
||||||
|
GameId = state.Scope.GameId,
|
||||||
|
EnvironmentId = state.Scope.EnvironmentId,
|
||||||
|
ProtocolVersion = listing.Definition.ProtocolVersion,
|
||||||
|
};
|
||||||
|
VisibleListingQuery query = new(state.Scope, listing.Definition.ProtocolVersion, null);
|
||||||
|
string initial = cursors.Encode(query, changes.CurrentRevision, clock.UtcNow);
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(streams.Subscribe(request, initial));
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
streams.Subscribe(request, initial).Error);
|
||||||
|
|
||||||
|
for (int index = 0; index < 65; index++)
|
||||||
|
{
|
||||||
|
listing = state.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
displayName: $"Host {index}",
|
||||||
|
currentPlayers: index % 8)).Value!;
|
||||||
|
}
|
||||||
|
Assert.True(streams.Read(subscription).RequiresReset);
|
||||||
|
subscription.Dispose();
|
||||||
|
|
||||||
|
using SessionStreamSubscription foreign = AssertSuccess(streams.Subscribe(
|
||||||
|
request,
|
||||||
|
"not-a-valid-cursor"));
|
||||||
|
Assert.True(streams.Read(foreign).RequiresReset);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BurstIsBoundedAndCoalescedWithoutLosingFinalState()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new();
|
||||||
|
SessionChangeJournal changes = new(new SessionChangeJournalOptions
|
||||||
|
{
|
||||||
|
ReplayCapacity = 1024,
|
||||||
|
MaximumBatchSize = 128,
|
||||||
|
});
|
||||||
|
using SessionStreamCursorCodec cursors = new();
|
||||||
|
SessionStreamService streams = new(changes, cursors, clock);
|
||||||
|
EphemeralStateFixture state = new(changes: changes);
|
||||||
|
StoredListing listing = state.CreateVisibleListing(out _);
|
||||||
|
BrowseSessionsRequest request = new()
|
||||||
|
{
|
||||||
|
GameId = state.Scope.GameId,
|
||||||
|
EnvironmentId = state.Scope.EnvironmentId,
|
||||||
|
ProtocolVersion = listing.Definition.ProtocolVersion,
|
||||||
|
};
|
||||||
|
VisibleListingQuery query = new(state.Scope, listing.Definition.ProtocolVersion, null);
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(streams.Subscribe(
|
||||||
|
request,
|
||||||
|
cursors.Encode(query, changes.CurrentRevision, clock.UtcNow)));
|
||||||
|
|
||||||
|
for (int index = 0; index < 1000; index++)
|
||||||
|
{
|
||||||
|
listing = state.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
displayName: $"Host {index}",
|
||||||
|
currentPlayers: index % 8)).Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SessionStreamEvent> emitted = [];
|
||||||
|
while (subscription.Revision < changes.CurrentRevision)
|
||||||
|
{
|
||||||
|
SessionStreamReadResult read = streams.Read(subscription);
|
||||||
|
Assert.False(read.RequiresReset);
|
||||||
|
emitted.AddRange(read.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(8, emitted.Count);
|
||||||
|
SessionStreamEvent final = emitted[^1];
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, final.Kind);
|
||||||
|
Assert.Equal("Host 999", final.Session!.DisplayName);
|
||||||
|
Assert.Equal(7, final.Session.Capacity.CurrentPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubscriberLimitIsEnforcedPerTenantAndReleasedOnDispose()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new();
|
||||||
|
SessionChangeJournal changes = new(new SessionChangeJournalOptions
|
||||||
|
{
|
||||||
|
MaximumSubscribers = 2,
|
||||||
|
MaximumSubscribersPerTenant = 1,
|
||||||
|
});
|
||||||
|
using SessionStreamCursorCodec cursors = new();
|
||||||
|
SessionStreamService streams = new(changes, cursors, clock);
|
||||||
|
TenantScope firstScope = new(new("first-game"), new("production"));
|
||||||
|
TenantScope secondScope = new(new("second-game"), new("production"));
|
||||||
|
BrowseSessionsRequest firstRequest = Request(firstScope);
|
||||||
|
BrowseSessionsRequest secondRequest = Request(secondScope);
|
||||||
|
string firstCursor = cursors.Encode(
|
||||||
|
new VisibleListingQuery(firstScope, 7, null),
|
||||||
|
changes.CurrentRevision,
|
||||||
|
clock.UtcNow);
|
||||||
|
string secondCursor = cursors.Encode(
|
||||||
|
new VisibleListingQuery(secondScope, 7, null),
|
||||||
|
changes.CurrentRevision,
|
||||||
|
clock.UtcNow);
|
||||||
|
|
||||||
|
SessionStreamSubscription first = AssertSuccess(streams.Subscribe(firstRequest, firstCursor));
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
streams.Subscribe(firstRequest, firstCursor).Error);
|
||||||
|
using SessionStreamSubscription second = AssertSuccess(
|
||||||
|
streams.Subscribe(secondRequest, secondCursor));
|
||||||
|
first.Dispose();
|
||||||
|
using SessionStreamSubscription replacement = AssertSuccess(
|
||||||
|
streams.Subscribe(firstRequest, firstCursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BrowseSessionsRequest Request(TenantScope scope) => new()
|
||||||
|
{
|
||||||
|
GameId = scope.GameId,
|
||||||
|
EnvironmentId = scope.EnvironmentId,
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static UpdateListingCommand Update(
|
||||||
|
StoredListing listing,
|
||||||
|
string displayName,
|
||||||
|
int currentPlayers,
|
||||||
|
RegionId? regionId = null,
|
||||||
|
uint? protocolVersion = null,
|
||||||
|
ListingVisibility? visibility = null) => new(
|
||||||
|
listing.Definition.ListingId,
|
||||||
|
listing.Definition.LeaseId,
|
||||||
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
|
listing.Definition.BuildVersion,
|
||||||
|
displayName,
|
||||||
|
currentPlayers,
|
||||||
|
listing.Definition.MaximumPlayers,
|
||||||
|
listing.Definition.Metadata,
|
||||||
|
listing.Definition.DedicatedFallback,
|
||||||
|
regionId,
|
||||||
|
protocolVersion,
|
||||||
|
visibility);
|
||||||
|
|
||||||
|
private static SessionStreamEventKind SingleKind(
|
||||||
|
SessionBrowserFixture fixture,
|
||||||
|
SessionStreamSubscription subscription) =>
|
||||||
|
Assert.Single(fixture.Streams.Read(subscription).Events).Kind;
|
||||||
|
|
||||||
|
private static T AssertSuccess<T>(BrowserServiceResult<T> result)
|
||||||
|
{
|
||||||
|
Assert.True(result.Succeeded, result.Error.ToString());
|
||||||
|
return Assert.IsType<T>(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,6 +167,88 @@ public sealed class RendezvousClientBehaviorTests
|
|||||||
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamRejectsMalformedAndOversizedEventEnvelopes()
|
||||||
|
{
|
||||||
|
string[] bodies =
|
||||||
|
[
|
||||||
|
"event: session_upsert\nid: valid-cursor\ndata: {}\n\n",
|
||||||
|
"data: " + new string('x', ContractLimits.SessionStreamEventMaxBytes + 1) + "\n\n",
|
||||||
|
];
|
||||||
|
foreach (string body in bodies)
|
||||||
|
{
|
||||||
|
StringContent content = new(body, Encoding.UTF8, "text/event-stream");
|
||||||
|
ScriptedHandler handler = new(Response(HttpStatusCode.OK, content));
|
||||||
|
using HttpClient httpClient = new(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new("http://rendezvous.test/"),
|
||||||
|
};
|
||||||
|
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||||
|
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||||
|
.StreamAsync(new BrowseSessionsRequest
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
}, "valid-stream-cursor")
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
Assert.True(await events.MoveNextAsync());
|
||||||
|
Assert.False(events.Current.IsSuccess);
|
||||||
|
Assert.Equal(RendezvousErrorCode.InternalError, events.Current.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamRequiresANonEmptySnapshotCursor()
|
||||||
|
{
|
||||||
|
using HttpClient httpClient = new(new ScriptedHandler())
|
||||||
|
{
|
||||||
|
BaseAddress = new("http://rendezvous.test/"),
|
||||||
|
};
|
||||||
|
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||||
|
{
|
||||||
|
await foreach (RendezvousClientResult<SessionStreamEvent> _ in browser.StreamAsync(
|
||||||
|
new BrowseSessionsRequest
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
},
|
||||||
|
string.Empty))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamOpeningIsBoundedByTheConfiguredRequestTimeout()
|
||||||
|
{
|
||||||
|
using HttpClient httpClient = new(new SilentHandler())
|
||||||
|
{
|
||||||
|
BaseAddress = new("http://rendezvous.test/"),
|
||||||
|
};
|
||||||
|
RendezvousSessionBrowserClient browser = new(
|
||||||
|
httpClient,
|
||||||
|
new RendezvousClientOptions
|
||||||
|
{
|
||||||
|
MaximumSafeRetries = 0,
|
||||||
|
RequestTimeout = TimeSpan.FromMilliseconds(20),
|
||||||
|
});
|
||||||
|
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||||
|
.StreamAsync(new BrowseSessionsRequest
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
}, "valid-stream-cursor")
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
Assert.True(await events.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)));
|
||||||
|
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, events.Current.Error);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LeaseMaintainerReportsLeaseLoss()
|
public async Task LeaseMaintainerReportsLeaseLoss()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
using FinalFactory.Rendezvous.Client;
|
using FinalFactory.Rendezvous.Client;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
using FinalFactory.Rendezvous.Server.Abuse;
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
@@ -19,6 +22,49 @@ namespace FinalFactory.Rendezvous.Tests.Client;
|
|||||||
|
|
||||||
public sealed class RendezvousClientIntegrationTests
|
public sealed class RendezvousClientIntegrationTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task RestartReturnsTypedUnavailabilityThenAllowsHostReregistration()
|
||||||
|
{
|
||||||
|
int port = ReserveTcpPort();
|
||||||
|
string address = $"http://127.0.0.1:{port}";
|
||||||
|
using HttpClient client = new() { BaseAddress = new Uri(address) };
|
||||||
|
RendezvousClientOptions noRetry = new()
|
||||||
|
{
|
||||||
|
MaximumSafeRetries = 0,
|
||||||
|
RequestTimeout = TimeSpan.FromSeconds(1),
|
||||||
|
};
|
||||||
|
ClientTestHost first = await ClientTestHost.StartAsync(address);
|
||||||
|
RendezvousPublisherClient publisher = new(client, noRetry);
|
||||||
|
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(100),
|
||||||
|
first.PublisherCredential);
|
||||||
|
PublishedSession initialSession = AssertSuccess(registered);
|
||||||
|
BindPresence(first, initialSession, 41_100);
|
||||||
|
RendezvousSessionBrowserClient browser = new(client, noRetry);
|
||||||
|
BrowseSessionsResponse beforeRestart = AssertSuccess(await browser.BrowseAsync(BrowseRequest()));
|
||||||
|
Assert.Equal(initialSession.ListingId, Assert.Single(beforeRestart.Items).ListingId);
|
||||||
|
Stopwatch restart = Stopwatch.StartNew();
|
||||||
|
await first.DisposeAsync();
|
||||||
|
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> unavailable = await browser.BrowseAsync(BrowseRequest());
|
||||||
|
Assert.False(unavailable.IsSuccess);
|
||||||
|
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, unavailable.Error);
|
||||||
|
|
||||||
|
await using ClientTestHost second = await ClientTestHost.StartAsync(address);
|
||||||
|
RendezvousClientResult<PublishedSession> reregistered = await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(101),
|
||||||
|
second.PublisherCredential);
|
||||||
|
PublishedSession replacementSession = AssertSuccess(reregistered);
|
||||||
|
Assert.NotEqual(initialSession.ListingId, replacementSession.ListingId);
|
||||||
|
BindPresence(second, replacementSession, 41_101);
|
||||||
|
BrowseSessionsResponse afterRestart = AssertSuccess(await browser.BrowseAsync(BrowseRequest()));
|
||||||
|
Assert.Equal(replacementSession.ListingId, Assert.Single(afterRestart.Items).ListingId);
|
||||||
|
Assert.DoesNotContain(afterRestart.Items, item => item.ListingId == initialSession.ListingId);
|
||||||
|
Assert.True(
|
||||||
|
restart.Elapsed < TimeSpan.FromSeconds(5),
|
||||||
|
$"Local restart and host re-registration took {restart.Elapsed}.");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging()
|
public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging()
|
||||||
{
|
{
|
||||||
@@ -96,12 +142,104 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BrowserStreamResetsInvalidCursorReplaysReconnectAndReleasesConnections()
|
||||||
|
{
|
||||||
|
await using ClientTestHost host = await ClientTestHost.StartAsync();
|
||||||
|
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||||
|
RendezvousSessionBrowserClient browser = new(host.HttpClient);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(200),
|
||||||
|
host.PublisherCredential));
|
||||||
|
BindPresence(host, session, 41_200);
|
||||||
|
BrowseSessionsRequest request = BrowseRequest();
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(await browser.BrowseAsync(request));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(snapshot.StreamCursor));
|
||||||
|
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||||
|
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> invalid = browser
|
||||||
|
.StreamAsync(request, CorruptCursor(snapshot.StreamCursor), timeout.Token)
|
||||||
|
.GetAsyncEnumerator(timeout.Token))
|
||||||
|
{
|
||||||
|
Assert.True(await invalid.MoveNextAsync());
|
||||||
|
Assert.Equal(SessionStreamEventKind.Reset, AssertSuccess(invalid.Current).Kind);
|
||||||
|
Assert.False(await invalid.MoveNextAsync());
|
||||||
|
}
|
||||||
|
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||||
|
.StreamAsync(request, snapshot.StreamCursor, timeout.Token)
|
||||||
|
.GetAsyncEnumerator(timeout.Token);
|
||||||
|
Task<bool> upsertPending = events.MoveNextAsync().AsTask();
|
||||||
|
Assert.True((await publisher.UpdateAsync(
|
||||||
|
session,
|
||||||
|
new UpdateSessionRequest
|
||||||
|
{
|
||||||
|
BuildVersion = "2.0.0",
|
||||||
|
DisplayName = "Live update",
|
||||||
|
Capacity = new() { CurrentPlayers = 3, MaximumPlayers = 8 },
|
||||||
|
Metadata = new() { ["mode"] = "online-coop" },
|
||||||
|
},
|
||||||
|
host.PublisherCredential,
|
||||||
|
timeout.Token)).IsSuccess);
|
||||||
|
Assert.True(await upsertPending);
|
||||||
|
SessionStreamEvent upsert = AssertSuccess(events.Current);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, upsert.Kind);
|
||||||
|
Assert.Equal("Live update", upsert.Session!.DisplayName);
|
||||||
|
|
||||||
|
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> replay = browser
|
||||||
|
.StreamAsync(request, snapshot.StreamCursor, timeout.Token)
|
||||||
|
.GetAsyncEnumerator(timeout.Token))
|
||||||
|
{
|
||||||
|
Assert.True(await replay.MoveNextAsync());
|
||||||
|
SessionStreamEvent replayed = AssertSuccess(replay.Current);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, replayed.Kind);
|
||||||
|
Assert.Equal(upsert.Cursor, replayed.Cursor);
|
||||||
|
Assert.Equal("Live update", replayed.Session!.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task<bool> removePending = events.MoveNextAsync().AsTask();
|
||||||
|
Assert.True((await publisher.DeregisterAsync(
|
||||||
|
session,
|
||||||
|
host.PublisherCredential,
|
||||||
|
timeout.Token)).IsSuccess);
|
||||||
|
Assert.True(await removePending);
|
||||||
|
SessionStreamEvent remove = AssertSuccess(events.Current);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, remove.Kind);
|
||||||
|
Assert.Equal(session.ListingId, remove.ListingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CorruptCursor(string cursor)
|
||||||
|
{
|
||||||
|
char replacement = cursor[^1] == 'a' ? 'b' : 'a';
|
||||||
|
return cursor[..^1] + replacement;
|
||||||
|
}
|
||||||
|
|
||||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||||
{
|
{
|
||||||
Assert.True(result.IsSuccess, result.Message);
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
return Assert.IsAssignableFrom<T>(result.Value);
|
return Assert.IsAssignableFrom<T>(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void BindPresence(ClientTestHost host, PublishedSession session, int port)
|
||||||
|
{
|
||||||
|
Assert.True(host.Capabilities.TryFingerprint(
|
||||||
|
session.HostPresenceCapability,
|
||||||
|
out SecretFingerprint fingerprint));
|
||||||
|
Assert.Equal(StoreResultCode.Success, host.Store.BindHostPresence(new(
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
fingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.80", port),
|
||||||
|
null)).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BrowseSessionsRequest BrowseRequest() => new()
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
PageSize = 10,
|
||||||
|
};
|
||||||
|
|
||||||
private static RegisterSessionRequest CreateRegistration(int index) => new()
|
private static RegisterSessionRequest CreateRegistration(int index) => new()
|
||||||
{
|
{
|
||||||
IdempotencyKey = $"sdk-integration-{index}",
|
IdempotencyKey = $"sdk-integration-{index}",
|
||||||
@@ -139,11 +277,12 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
internal EphemeralCapabilityIssuer Capabilities { get; }
|
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||||
internal string PublisherCredential { get; }
|
internal string PublisherCredential { get; }
|
||||||
|
|
||||||
internal static async Task<ClientTestHost> StartAsync()
|
internal static async Task<ClientTestHost> StartAsync(string? bindAddress = null)
|
||||||
{
|
{
|
||||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
EphemeralStoreOptions stateOptions = new();
|
EphemeralStoreOptions stateOptions = new();
|
||||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
|
||||||
EphemeralCapabilityIssuer capabilities = new();
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
ProvisioningTestData.CreateOptions(),
|
ProvisioningTestData.CreateOptions(),
|
||||||
@@ -153,7 +292,7 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
builder.WebHost.UseUrls(bindAddress ?? "http://127.0.0.1:0");
|
||||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||||
ContractJson.Configure(options.SerializerOptions));
|
ContractJson.Configure(options.SerializerOptions));
|
||||||
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||||
@@ -172,7 +311,10 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
builder.Services.AddSingleton<SessionLeaseService>();
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
||||||
|
builder.Services.AddSingleton(changes);
|
||||||
builder.Services.AddSingleton<SessionBrowserService>();
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamService>();
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
app.UseExceptionHandler();
|
app.UseExceptionHandler();
|
||||||
@@ -180,10 +322,10 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
app.MapRendezvousContractEndpoints();
|
app.MapRendezvousContractEndpoints();
|
||||||
await app.StartAsync();
|
await app.StartAsync();
|
||||||
IServer server = app.Services.GetRequiredService<IServer>();
|
IServer server = app.Services.GetRequiredService<IServer>();
|
||||||
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
string serviceAddress = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||||
return new(
|
return new(
|
||||||
app,
|
app,
|
||||||
new HttpClient { BaseAddress = new Uri(address) },
|
new HttpClient { BaseAddress = new Uri(serviceAddress) },
|
||||||
store,
|
store,
|
||||||
capabilities,
|
capabilities,
|
||||||
credential);
|
credential);
|
||||||
@@ -196,4 +338,13 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
await _application.DisposeAsync();
|
await _application.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int ReserveTcpPort()
|
||||||
|
{
|
||||||
|
TcpListener listener = new(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,69 @@
|
|||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
||||||
|
|
||||||
internal static class ContractTestFiles
|
internal static class ContractTestFiles
|
||||||
{
|
{
|
||||||
public static string Read(string fileName) => File
|
public static string Read(string fileName) => File
|
||||||
.ReadAllText(Path.Combine(Directory, fileName))
|
.ReadAllText(Path.Combine(DirectoryFor(fileName), fileName))
|
||||||
.TrimEnd('\r', '\n');
|
.TrimEnd('\r', '\n');
|
||||||
|
|
||||||
public static string Directory
|
public static string Directory
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return VersionedDirectory(Property("RendezvousMajorVersion"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string OpenApiDocument
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
string httpVersion = Property("HttpContractVersion");
|
||||||
|
return Path.Combine(RepositoryRoot, $"docs/api/rendezvous-v{httpVersion}.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DirectoryFor(string fileName)
|
||||||
|
{
|
||||||
|
string property = fileName switch
|
||||||
|
{
|
||||||
|
"client-public-api.txt" or "contracts-public-api.txt" => "RendezvousMajorVersion",
|
||||||
|
"connection-ticket.json" => "ConnectionTicketFormatVersion",
|
||||||
|
_ when fileName.EndsWith(".hex", StringComparison.Ordinal) => "UdpContractVersion",
|
||||||
|
_ when fileName.EndsWith(".json", StringComparison.Ordinal) => "HttpContractVersion",
|
||||||
|
_ => throw new InvalidOperationException($"No contract version dimension maps {fileName}."),
|
||||||
|
};
|
||||||
|
return VersionedDirectory(Property(property));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string VersionedDirectory(string version) => Path.Combine(
|
||||||
|
RepositoryRoot,
|
||||||
|
$"tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v{version}");
|
||||||
|
|
||||||
|
private static string Property(string name)
|
||||||
|
{
|
||||||
|
XDocument versions = XDocument.Load(Path.Combine(RepositoryRoot, "eng/Versions.props"));
|
||||||
|
return versions.Descendants(name).Single().Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RepositoryRoot
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||||
while (directory is not null)
|
while (directory is not null)
|
||||||
{
|
{
|
||||||
string solution = Path.Combine(directory.FullName, "Rendezvous.slnx");
|
if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx")))
|
||||||
if (File.Exists(solution))
|
|
||||||
{
|
{
|
||||||
return Path.Combine(
|
return directory.FullName;
|
||||||
directory.FullName,
|
|
||||||
"tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
directory = directory.Parent;
|
directory = directory.Parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new DirectoryNotFoundException("Could not locate contract test data.");
|
throw new DirectoryNotFoundException("Could not locate repository root.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
"/v1/operator/principals/revoke",
|
"/v1/operator/principals/revoke",
|
||||||
"/v1/operator/status",
|
"/v1/operator/status",
|
||||||
"/v1/sessions",
|
"/v1/sessions",
|
||||||
|
"/v1/sessions/stream",
|
||||||
"/v1/sessions/{listingId}",
|
"/v1/sessions/{listingId}",
|
||||||
"/v1/sessions/{listingId}/join-attempts",
|
"/v1/sessions/{listingId}/join-attempts",
|
||||||
"/v1/sessions/{listingId}/renew",
|
"/v1/sessions/{listingId}/renew",
|
||||||
@@ -40,12 +41,10 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
];
|
];
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GeneratedOpenApiContainsTheFrozenV1Surface()
|
public void GeneratedOpenApiContainsTheFrozenVersionedSurface()
|
||||||
{
|
{
|
||||||
string path = Path.Combine(
|
using JsonDocument document = JsonDocument.Parse(
|
||||||
ContractTestFiles.Directory,
|
File.ReadAllText(ContractTestFiles.OpenApiDocument));
|
||||||
"../../../../../docs/api/rendezvous-v1.json");
|
|
||||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(Path.GetFullPath(path)));
|
|
||||||
JsonElement root = document.RootElement;
|
JsonElement root = document.RootElement;
|
||||||
|
|
||||||
Assert.Equal("3.1.1", root.GetProperty("openapi").GetString());
|
Assert.Equal("3.1.1", root.GetProperty("openapi").GetString());
|
||||||
@@ -70,6 +69,25 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
Assert.DoesNotContain(listingProperties, static property =>
|
Assert.DoesNotContain(listingProperties, static property =>
|
||||||
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|
||||||
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
|
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
|
||||||
|
JsonElement streamProperties = schemas.GetProperty("SessionStreamEvent")
|
||||||
|
.GetProperty("properties");
|
||||||
|
Assert.True(streamProperties.TryGetProperty("contractVersion", out _));
|
||||||
|
Assert.True(streamProperties.TryGetProperty("kind", out _));
|
||||||
|
Assert.True(streamProperties.TryGetProperty("cursor", out _));
|
||||||
|
Assert.True(streamProperties.TryGetProperty("session", out _));
|
||||||
|
Assert.True(streamProperties.TryGetProperty("listingId", out _));
|
||||||
|
Assert.DoesNotContain(streamProperties.EnumerateObject(), static property =>
|
||||||
|
property.Name.Contains("token", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| property.Name.Contains("capability", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| property.Name.Contains("ticket", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| property.Name.Contains("endpoint", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.True(root.GetProperty("paths")
|
||||||
|
.GetProperty("/v1/sessions/stream")
|
||||||
|
.GetProperty("get")
|
||||||
|
.GetProperty("responses")
|
||||||
|
.GetProperty("200")
|
||||||
|
.GetProperty("content")
|
||||||
|
.TryGetProperty("text/event-stream", out _));
|
||||||
JsonElement dedicatedFallback = schemas.GetProperty("SessionListing")
|
JsonElement dedicatedFallback = schemas.GetProperty("SessionListing")
|
||||||
.GetProperty("properties")
|
.GetProperty("properties")
|
||||||
.GetProperty("dedicatedFallback");
|
.GetProperty("dedicatedFallback");
|
||||||
@@ -207,7 +225,7 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.Equal(17, overloadContracts);
|
Assert.Equal(18, overloadContracts);
|
||||||
(string Path, string Method)[] bodyOperations =
|
(string Path, string Method)[] bodyOperations =
|
||||||
[
|
[
|
||||||
("/v1/sessions", "post"),
|
("/v1/sessions", "post"),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using FinalFactory.Rendezvous.Client;
|
using FinalFactory.Rendezvous.Client;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
@@ -22,6 +23,20 @@ public sealed class TraversalTokenCodecTests
|
|||||||
Assert.DoesNotContain(encoded, decoded.ToString(), StringComparison.Ordinal);
|
Assert.DoesNotContain(encoded, decoded.ToString(), StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectionTicketMatchesTheVersionedV1Vector()
|
||||||
|
{
|
||||||
|
using JsonDocument vector = JsonDocument.Parse(ContractTestFiles.Read("connection-ticket.json"));
|
||||||
|
JsonElement root = vector.RootElement;
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse(root.GetProperty("attemptId").GetString()!));
|
||||||
|
string authenticator = root.GetProperty("derivedAuthenticator").GetString()!;
|
||||||
|
|
||||||
|
string ticket = NatIntroductionTokenCodec.Encode(attemptId, authenticator);
|
||||||
|
|
||||||
|
Assert.Equal(root.GetProperty("connectionTicket").GetString(), ticket);
|
||||||
|
Assert.Equal(root.GetProperty("digest").GetString(), NatIntroductionTokenCodec.ComputeDigest(ticket));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IntroductionTokenRejectsNonCanonicalOrAlteredFields()
|
public void IntroductionTokenRejectsNonCanonicalOrAlteredFields()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,6 +61,32 @@ public sealed class ProductionProcessTests
|
|||||||
Assert.InRange(shutdown.Elapsed, TimeSpan.FromMilliseconds(700), TimeSpan.FromSeconds(5));
|
Assert.InRange(shutdown.Elapsed, TimeSpan.FromMilliseconds(700), TimeSpan.FromSeconds(5));
|
||||||
AssertTcpPortIsReleased(httpPort);
|
AssertTcpPortIsReleased(httpPort);
|
||||||
AssertUdpPortIsReleased(udpPort);
|
AssertUdpPortIsReleased(udpPort);
|
||||||
|
|
||||||
|
process.Dispose();
|
||||||
|
process = null;
|
||||||
|
Stopwatch replacementReady = Stopwatch.StartNew();
|
||||||
|
ProcessStartInfo replacementInfo = CreateStartInfo(httpPort, udpPort, secretPath);
|
||||||
|
process = Process.Start(replacementInfo)
|
||||||
|
?? throw new InvalidOperationException("The replacement production process did not start.");
|
||||||
|
Task<string> replacementOutput = process.StandardOutput.ReadToEndAsync();
|
||||||
|
Task<string> replacementError = process.StandardError.ReadToEndAsync();
|
||||||
|
await WaitForReadyAsync(httpPort, process, TimeSpan.FromSeconds(15));
|
||||||
|
Assert.True(
|
||||||
|
replacementReady.Elapsed < TimeSpan.FromSeconds(15),
|
||||||
|
$"Replacement readiness took {replacementReady.Elapsed}.");
|
||||||
|
AssertUdpPortIsBound(udpPort);
|
||||||
|
|
||||||
|
await SendSigtermAsync(process);
|
||||||
|
using CancellationTokenSource replacementTimeout = new(TimeSpan.FromSeconds(6));
|
||||||
|
await process.WaitForExitAsync(replacementTimeout.Token);
|
||||||
|
string replacementFinalOutput = await replacementOutput;
|
||||||
|
string replacementFinalError = await replacementError;
|
||||||
|
Assert.True(
|
||||||
|
process.ExitCode == 0,
|
||||||
|
$"Replacement exited with {process.ExitCode}. "
|
||||||
|
+ $"stdout: {replacementFinalOutput} stderr: {replacementFinalError}");
|
||||||
|
AssertTcpPortIsReleased(httpPort);
|
||||||
|
AssertUdpPortIsReleased(udpPort);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -79,6 +105,97 @@ public sealed class ProductionProcessTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProductionTransportSoakKeepsHandlesMemoryAndSocketsBounded()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int httpPort = ReserveTcpPort();
|
||||||
|
int udpPort = ReserveUdpPort();
|
||||||
|
string secretPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"rendezvous-transport-soak-secret-{Guid.NewGuid():N}");
|
||||||
|
await File.WriteAllBytesAsync(secretPath, RandomNumberGenerator.GetBytes(32));
|
||||||
|
Process? process = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process = Process.Start(CreateStartInfo(httpPort, udpPort, secretPath))
|
||||||
|
?? throw new InvalidOperationException("The production soak process did not start.");
|
||||||
|
Task<string> standardOutput = process.StandardOutput.ReadToEndAsync();
|
||||||
|
Task<string> standardError = process.StandardError.ReadToEndAsync();
|
||||||
|
await WaitForReadyAsync(httpPort, process, TimeSpan.FromSeconds(10));
|
||||||
|
process.Refresh();
|
||||||
|
int baselineHandles = process.HandleCount;
|
||||||
|
long baselineWorkingSet = process.WorkingSet64;
|
||||||
|
int peakHandles = baselineHandles;
|
||||||
|
byte[] invalidDatagram = RandomNumberGenerator.GetBytes(64);
|
||||||
|
IPEndPoint udpEndpoint = new(IPAddress.Loopback, udpPort);
|
||||||
|
using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(1) };
|
||||||
|
using UdpClient udp = new();
|
||||||
|
Stopwatch soak = Stopwatch.StartNew();
|
||||||
|
int cycles = 0;
|
||||||
|
int accepted = 0;
|
||||||
|
int shed = 0;
|
||||||
|
while (soak.Elapsed < TimeSpan.FromSeconds(10))
|
||||||
|
{
|
||||||
|
using HttpResponseMessage response = await client.GetAsync(
|
||||||
|
$"http://127.0.0.1:{httpPort}/health/live");
|
||||||
|
if (response.StatusCode == HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
accepted++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
|
||||||
|
shed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await udp.SendAsync(invalidDatagram, udpEndpoint);
|
||||||
|
cycles++;
|
||||||
|
if (cycles % 100 == 0)
|
||||||
|
{
|
||||||
|
process.Refresh();
|
||||||
|
peakHandles = Math.Max(peakHandles, process.HandleCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(cycles >= 100, $"Transport soak completed only {cycles} cycles.");
|
||||||
|
Assert.True(accepted > 0, "Transport soak never admitted a health request.");
|
||||||
|
Assert.True(shed > 0, "Transport soak never exercised typed HTTP load shedding.");
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||||
|
using (HttpResponseMessage recovered = await client.GetAsync(
|
||||||
|
$"http://127.0.0.1:{httpPort}/health/live"))
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.OK, recovered.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.Refresh();
|
||||||
|
Assert.InRange(peakHandles, 0, baselineHandles + 32);
|
||||||
|
Assert.InRange(process.HandleCount, 0, baselineHandles + 16);
|
||||||
|
Assert.InRange(process.WorkingSet64, 0, baselineWorkingSet + 67_108_864);
|
||||||
|
AssertUdpPortIsBound(udpPort);
|
||||||
|
|
||||||
|
await SendSigtermAsync(process);
|
||||||
|
using CancellationTokenSource shutdownTimeout = new(TimeSpan.FromSeconds(6));
|
||||||
|
await process.WaitForExitAsync(shutdownTimeout.Token);
|
||||||
|
string output = await standardOutput;
|
||||||
|
string error = await standardError;
|
||||||
|
Assert.True(
|
||||||
|
process.ExitCode == 0,
|
||||||
|
$"Transport soak process failed. stdout: {output} stderr: {error}");
|
||||||
|
AssertTcpPortIsReleased(httpPort);
|
||||||
|
AssertUdpPortIsReleased(udpPort);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await StopProcessTreeAsync(process);
|
||||||
|
File.Delete(secretPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DocumentedSmokeScriptReachesHttpAndAuthenticatedUdpFlow()
|
public async Task DocumentedSmokeScriptReachesHttpAndAuthenticatedUdpFlow()
|
||||||
{
|
{
|
||||||
@@ -90,10 +207,16 @@ public sealed class ProductionProcessTests
|
|||||||
string root = RepositoryRoot();
|
string root = RepositoryRoot();
|
||||||
int httpPort = ReserveTcpPort();
|
int httpPort = ReserveTcpPort();
|
||||||
int udpPort = ReserveUdpPort();
|
int udpPort = ReserveUdpPort();
|
||||||
string secretPath = Path.Combine(
|
string secretDirectory = Path.Combine(
|
||||||
Path.GetTempPath(),
|
Path.GetTempPath(),
|
||||||
$"rendezvous-smoke-secret-{Guid.NewGuid():N}");
|
$"rendezvous-smoke-secret-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(secretDirectory);
|
||||||
|
File.SetUnixFileMode(
|
||||||
|
secretDirectory,
|
||||||
|
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||||
|
string secretPath = Path.Combine(secretDirectory, "signing-key");
|
||||||
await File.WriteAllBytesAsync(secretPath, RandomNumberGenerator.GetBytes(32));
|
await File.WriteAllBytesAsync(secretPath, RandomNumberGenerator.GetBytes(32));
|
||||||
|
File.SetUnixFileMode(secretPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||||
Process? server = null;
|
Process? server = null;
|
||||||
Process? smoke = null;
|
Process? smoke = null;
|
||||||
try
|
try
|
||||||
@@ -115,6 +238,10 @@ public sealed class ProductionProcessTests
|
|||||||
"--Rendezvous:Provisioning:SigningKeys:0:NotBefore", now.AddHours(-1).ToString("O"),
|
"--Rendezvous:Provisioning:SigningKeys:0:NotBefore", now.AddHours(-1).ToString("O"),
|
||||||
"--Rendezvous:Provisioning:SigningKeys:0:SignUntil", now.AddHours(1).ToString("O"),
|
"--Rendezvous:Provisioning:SigningKeys:0:SignUntil", now.AddHours(1).ToString("O"),
|
||||||
"--Rendezvous:Provisioning:SigningKeys:0:VerifyUntil", now.AddHours(2).ToString("O"),
|
"--Rendezvous:Provisioning:SigningKeys:0:VerifyUntil", now.AddHours(2).ToString("O"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:SecretReference", $"file:{secretPath}",
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:NotBefore", now.AddHours(-1).ToString("O"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:SignUntil", now.AddHours(1).ToString("O"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:VerifyUntil", now.AddHours(2).ToString("O"),
|
||||||
"--Rendezvous:Udp:Port", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
"--Rendezvous:Udp:Port", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
"--Rendezvous:Deployment:PublicUdpPort", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
"--Rendezvous:Deployment:PublicUdpPort", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
},
|
},
|
||||||
@@ -177,6 +304,7 @@ public sealed class ProductionProcessTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
File.Delete(secretPath);
|
File.Delete(secretPath);
|
||||||
|
Directory.Delete(secretDirectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user