Compare commits

..

4 Commits

Author SHA1 Message Date
KyuubiYoru cc5793f935 feat(release): add reproducible signed artifacts (#19)
quality-gate / quality (push) Failing after 1m50s
quality-gate / container (push) Has been skipped
2026-07-16 17:48:21 +02:00
KyuubiYoru 07004cd75f docs(evidence): record clean capacity candidate (#18)
quality-gate / quality (push) Failing after 1m28s
quality-gate / container (push) Has been skipped
2026-07-16 16:11:39 +02:00
KyuubiYoru cf14836d48 fix(operations): require clean candidate provenance (#18) 2026-07-16 16:04:25 +02:00
KyuubiYoru 609dad7cf1 feat(operations): add capacity and resilience gates (#18) 2026-07-16 15:57:01 +02:00
70 changed files with 4474 additions and 176 deletions
+29 -5
View File
@@ -14,16 +14,34 @@ jobs:
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Install .NET SDK
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.301
- name: Restore locked dependencies
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
run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore
@@ -36,6 +54,9 @@ jobs:
- name: Test
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
shell: bash
run: |
@@ -95,10 +116,10 @@ jobs:
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET SDK
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.301
@@ -124,7 +145,10 @@ jobs:
chmod 0444 "$secret"
export RENDEZVOUS_UID=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)"
test -n "$container_id"
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654"
+193
View File
@@ -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"
+3
View File
@@ -7,5 +7,8 @@ TestResults/
*.user
*.userosscache
deploy/compose/.smoke.env
artifacts/
__pycache__/
*.pyc
deploy/compose/secrets/*
!deploy/compose/secrets/.gitignore
+25
View File
@@ -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.
+28
View File
@@ -1,4 +1,5 @@
<Project>
<Import Project="eng/Versions.props" />
<PropertyGroup>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
@@ -8,8 +9,35 @@
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<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>
<RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>moderate</NuGetAuditLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<Target Name="ConfigureGiteaSourceLink"
BeforeTargets="_GenerateSourceLinkFile"
DependsOnTargets="InitializeSourceControlInformation">
<ItemGroup>
<SourceRoot Update="@(SourceRoot)"
SourceLinkUrl="$(RepositoryUrl)/raw/commit/$(SourceRevisionId)/*" />
</ItemGroup>
</Target>
</Project>
+1 -1
View File
@@ -4,7 +4,7 @@
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<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.Extensions.ApiDescription.Server" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
+6 -1
View File
@@ -1,8 +1,10 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS build
ARG SOURCE_REVISION_ID
WORKDIR /source
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.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
@@ -14,7 +16,10 @@ RUN dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Se
--no-restore \
--output /out \
/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
+9 -3
View File
@@ -83,9 +83,9 @@ Rendezvous is under active roadmap development. The versioned contracts,
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
SDK coordination, typed connection outcomes, thin public-SDK diagnostic client,
deterministic NAT topology harness, hostile-input controls,
observability/operator surface, and secure single-active Linux deployment are
implemented. Capacity, resilience, packaging, and final production-readiness
gates remain in progress;
observability/operator surface, secure single-active Linux deployment, and
numeric capacity/resilience gates, and reproducible signed release pipeline are
implemented. Consumer pilots and final production-readiness gates remain in progress;
participating games must not treat the current repository as a finished production
service until those gates land.
@@ -103,6 +103,12 @@ operator controls are defined in the
The pinned non-root container, production topology, graceful drain, Linux
hardening, smoke procedure, and recovery lifecycle are documented in
[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
documented in the [TestClient integration guide](docs/integration/test-client.md).
The always-on three-party scenarios, optional Linux namespace topology, and
+1
View File
@@ -6,6 +6,7 @@
<Project Path="src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/FinalFactory.Rendezvous.Capacity/FinalFactory.Rendezvous.Capacity.csproj" />
<Project Path="tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj" />
</Folder>
</Solution>
+1 -1
View File
@@ -2,7 +2,7 @@ name: rendezvous-local
services:
rendezvous:
image: finalfactory/rendezvous:local
image: "${RENDEZVOUS_IMAGE:-finalfactory/rendezvous:local}"
build:
context: ../..
dockerfile: Dockerfile
+2 -1
View File
@@ -40,7 +40,8 @@ LockPersonality=true
SystemCallArchitectures=native
UMask=0077
LimitNOFILE=4096
MemoryMax=512M
CPUQuota=200%
MemoryMax=2G
TasksMax=128
[Install]
+57
View File
@@ -2931,6 +2931,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": {
"required": [
"httpListener",
@@ -3009,6 +3062,7 @@
"OperatorStatusResponse": {
"required": [
"status",
"compatibility",
"readiness",
"store",
"tenants",
@@ -3020,6 +3074,9 @@
"status": {
"type": "string"
},
"compatibility": {
"$ref": "#/components/schemas/OperatorCompatibilityResponse"
},
"readiness": {
"$ref": "#/components/schemas/OperatorReadinessResponse"
},
@@ -129,9 +129,18 @@ until the owner records:
- which games may enable anonymous unlisted player hosting;
- deployment regions, data-processing jurisdiction, and approval of the stated
30-day audit/13-month aggregate retention periods;
- 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.
- the per-game dedicated fallback endpoint policy.
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
tenant, replay, endpoint-verification, or secret-handling controls.
+4 -2
View File
@@ -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
changes fail the normal test gate.
Any incompatible change requires a new contract version. Additive JSON fields
may be introduced within v1 because v1 readers ignore unknown object members.
Readers ignore unknown JSON members, but the release gate deliberately treats
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.
+7 -4
View File
@@ -118,8 +118,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
monotonic clock. Alert on clock synchronization loss before rotating keys.
Start with the Compose limits (one CPU, 512 MiB, 128 PIDs, 4096 descriptors),
measure real traffic, then change the limits and the server budgets together.
The checked-in Compose limits (one CPU and 512 MiB) are for its isolated smoke
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
`DrainDeadlineSeconds` plus five seconds.
@@ -157,8 +160,8 @@ sudo systemctl enable --now rendezvous.service
Create the dedicated `rendezvous` user without a login shell. Keep
`/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
unit applies the same resource, filesystem, privilege, network-family, and
shutdown constraints as Compose.
unit applies the measured 2-vCPU/2-GiB core-state candidate profile plus the
same filesystem, privilege, network-family, and shutdown hardening as Compose.
## HTTP and UDP smoke
@@ -0,0 +1,148 @@
{
"schemaVersion": 2,
"evidenceVersion": "v2",
"generatedAt": "2026-07-16T14:10:53.6981858+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": "cf14836d48b0b4aaa67f99433f4fba3585bcd2bb",
"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": 56.37724115383554,
"peakWorkingSetBytes": 169705472,
"managedBytesAfterCleanup": 35615200
},
"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.003,
"p95Milliseconds": 0.0046,
"p99Milliseconds": 0.0054,
"operationsPerSecond": 282453.96000451926,
"minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200,
"passed": true
},
{
"operation": "lease-renewal",
"samples": 1000,
"p50Milliseconds": 0.0004,
"p95Milliseconds": 0.0009,
"p99Milliseconds": 0.0021,
"operationsPerSecond": 968992.2480620155,
"minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200,
"passed": true
},
{
"operation": "visible-session-browse",
"samples": 250,
"p50Milliseconds": 0.9046,
"p95Milliseconds": 3.3704,
"p99Milliseconds": 3.9471,
"operationsPerSecond": 695.5799787597697,
"minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200,
"passed": true
},
{
"operation": "join-attempt-issuance",
"samples": 1000,
"p50Milliseconds": 0.0029,
"p95Milliseconds": 0.0045,
"p99Milliseconds": 0.0055,
"operationsPerSecond": 296428.042092782,
"minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200,
"passed": true
},
{
"operation": "simultaneous-punch-pairing",
"samples": 1000,
"p50Milliseconds": 0.0043,
"p95Milliseconds": 0.0073,
"p99Milliseconds": 0.0115,
"operationsPerSecond": 109212.03516627532,
"minimumOperationsPerSecond": 2000,
"budgetMilliseconds": 100,
"passed": true
},
{
"operation": "principal-revocation",
"samples": 50,
"p50Milliseconds": 0.518,
"p95Milliseconds": 0.7049,
"p99Milliseconds": 11.8557,
"operationsPerSecond": 1320.1773262184577,
"minimumOperationsPerSecond": 50,
"budgetMilliseconds": 200,
"passed": true
},
{
"operation": "telemetry-recording",
"samples": 1000,
"p50Milliseconds": 0.0001,
"p95Milliseconds": 0.0001,
"p99Milliseconds": 0.0001,
"operationsPerSecond": 1438641.9220256077,
"minimumOperationsPerSecond": 10000,
"budgetMilliseconds": 1,
"passed": true
},
{
"operation": "coincident-listing-attempt-expiry",
"samples": 1,
"p50Milliseconds": 29.6882,
"p95Milliseconds": 29.6882,
"p99Milliseconds": 29.6882,
"operationsPerSecond": 33.682962483916384,
"minimumOperationsPerSecond": 0,
"budgetMilliseconds": 200,
"passed": true
}
],
"state": {
"peakListings": 25000,
"peakAttempts": 10000,
"peakReplayMarkers": 0,
"finalListings": 0,
"finalAttempts": 0,
"finalReplayMarkers": 0,
"expiryChurn": 94906,
"maintenanceSweeps": 36307,
"soakCyclesCompleted": 75126848,
"soakDurationSeconds": 300.0000015,
"soakPeakScheduledExpiryEntries": 7,
"soakManagedGrowthBytes": -257288,
"soakHandleGrowth": 2,
"restartStartedEmpty": true,
"overloadWasTyped": true,
"recoverySucceeded": true
},
"failures": [],
"passed": true
}
+189
View File
@@ -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 162 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.
@@ -115,7 +115,9 @@ permission denial is audited with actor and target fingerprints.
Key revocation is process-local in the current single-instance store. Apply the
same revocation to every instance, then replace configuration before restarting;
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.
## Audit retention and incident handling
+150
View File
@@ -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.
+27
View File
@@ -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"
}
}
+15
View File
@@ -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>
+17
View File
@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"consumers": [
{
"name": "SpaceGame",
"repository": "https://git.finalfactory.de/Kyuubi/SpaceGame.git",
"revision": "77519b0cc418a27f8d408ae2d7b8812fbe087c04",
"project": "SpaceGame.csproj"
},
{
"name": "Unscouted",
"repository": "https://git.finalfactory.de/HeiKyu/Unscouted.git",
"revision": "7807dbee86eb8b98e702f1eb89c88adff728f635",
"project": "Net.Core/Net.Core.csproj"
}
]
}
+14
View File
@@ -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
+27
View File
@@ -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"
]
}
+823
View File
@@ -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()
+253
View File
@@ -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"
+56
View File
@@ -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."
+27
View File
@@ -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
+26
View File
@@ -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"
+23
View File
@@ -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"
+153
View File
@@ -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"
+58
View File
@@ -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"
+78
View File
@@ -0,0 +1,78 @@
#!/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 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
+13
View File
@@ -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>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
<PackageTags>final-factory;multiplayer;nat;godot;litenetlib</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<PackageReference Include="LiteNetLib" />
<None Update="README.md" Pack="true" PackagePath="\" />
<None Include="../../CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
</ItemGroup>
</Project>
@@ -4,7 +4,7 @@
".NETStandard,Version=v2.1": {
"LiteNetLib": {
"type": "Direct",
"requested": "[2.1.4, )",
"requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
@@ -5,9 +5,13 @@
<RootNamespace>FinalFactory.Rendezvous.Contracts</RootNamespace>
<IsPackable>true</IsPackable>
<PackageId>FinalFactory.Rendezvous.Contracts</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Versioned transport-neutral contracts for Final Factory Rendezvous.</Description>
<PackageTags>final-factory;multiplayer;contracts;godot</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" />
<None Update="README.md" Pack="true" PackagePath="\" />
<None Include="../../CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
</ItemGroup>
</Project>
@@ -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.
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
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 readonly InMemoryEphemeralRendezvousStore _store;
@@ -84,15 +84,19 @@ internal sealed partial class GracefulDrainService : IHostedService, IDisposable
}
}
[LoggerMessage(
EventId = 1,
Level = LogLevel.Information,
Message = "Graceful drain started with a {DrainDeadlineSeconds}-second deadline")]
private static partial void LogDrainStarted(ILogger logger, int drainDeadlineSeconds);
private static readonly Action<ILogger, int, Exception?> DrainStarted = LoggerMessage.Define<int>(
LogLevel.Information,
new EventId(1, nameof(LogDrainStarted)),
"Graceful drain started with a {DrainDeadlineSeconds}-second deadline");
[LoggerMessage(
EventId = 2,
Level = LogLevel.Information,
Message = "Graceful drain finished after {ElapsedMilliseconds:F0} ms; ephemeral state was cleared")]
private static partial void LogDrainFinished(ILogger logger, double elapsedMilliseconds);
private static readonly Action<ILogger, double, Exception?> DrainFinished = LoggerMessage.Define<double>(
LogLevel.Information,
new EventId(2, nameof(LogDrainFinished)),
"Graceful drain finished after {ElapsedMilliseconds:F0} ms; ephemeral state was cleared");
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>
<AssemblyName>FinalFactory.Rendezvous.Server</AssemblyName>
<RootNamespace>FinalFactory.Rendezvous.Server</RootNamespace>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<IsPackable>false</IsPackable>
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../docs/api</OpenApiDocumentsDirectory>
@@ -14,4 +15,74 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" PrivateAssets="all" />
</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="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>
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Diagnostics;
namespace FinalFactory.Rendezvous.Server.Http;
internal sealed partial class RendezvousExceptionHandler(
internal sealed class RendezvousExceptionHandler(
ILogger<RendezvousExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
@@ -51,13 +51,15 @@ internal sealed partial class RendezvousExceptionHandler(
return true;
}
[LoggerMessage(
EventId = 200,
Level = LogLevel.Warning,
Message = "Request failed with {FailureKind} and HTTP status {StatusCode}; correlation {CorrelationId}")]
private static partial void LogRequestFailure(
private static readonly Action<ILogger, string, int, string, Exception?> RequestFailure =
LoggerMessage.Define<string, int, string>(
LogLevel.Warning,
new EventId(200, nameof(LogRequestFailure)),
"Request failed with {FailureKind} and HTTP status {StatusCode}; correlation {CorrelationId}");
private static void LogRequestFailure(
ILogger logger,
string failureKind,
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;
internal sealed partial class AuditTrail
internal sealed class AuditTrail
{
private readonly object _gate = new();
private readonly LinkedList<AuditEntry> _entries = [];
@@ -57,7 +57,6 @@ internal sealed partial class AuditTrail
_telemetry.RecordAudit(action, result);
LogOperatorAction(
_logger,
entry.Timestamp,
entry.ActorFingerprint,
action,
result,
@@ -105,19 +104,28 @@ internal sealed partial class AuditTrail
return Convert.ToHexString(digest.AsSpan(0, 12));
}
[LoggerMessage(
EventId = 100,
Level = LogLevel.Information,
Message = "Operator audit at {Timestamp}: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}")]
private static partial void LogOperatorAction(
private static readonly Action<ILogger, string, string, string, string, string, string, Exception?>
OperatorAction = LoggerMessage.Define<string, string, string, string, string, string>(
LogLevel.Information,
new EventId(100, nameof(LogOperatorAction)),
"Operator audit: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}");
private static void LogOperatorAction(
ILogger logger,
DateTimeOffset timestamp,
string actorFingerprint,
string action,
string result,
string targetKind,
string targetFingerprint,
string correlationId);
string correlationId) => OperatorAction(
logger,
actorFingerprint,
action,
result,
targetKind,
targetFingerprint,
correlationId,
null);
}
internal sealed record AuditEntry(
@@ -3,6 +3,7 @@ namespace FinalFactory.Rendezvous.Server.Operations;
internal sealed record OperatorStatusResponse
{
public required string Status { get; init; }
public required OperatorCompatibilityResponse Compatibility { get; init; }
public required OperatorReadinessResponse Readiness { get; init; }
public required OperatorStoreResponse Store { 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; }
}
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
{
public required bool HttpListener { get; init; }
@@ -19,6 +19,7 @@ internal sealed class OperatorService(
return new OperatorStatusResponse
{
Status = readinessSnapshot.IsReady ? "ready" : "not-ready",
Compatibility = ReleaseCompatibility.CreateResponse(),
Readiness = new OperatorReadinessResponse
{
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);
}
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Capacity")]
@@ -11,16 +11,29 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private readonly DateTimeOffset _wallOrigin;
private readonly TimeSpan _monotonicOrigin;
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<MediationHandle, SessionListingId> _presenceHandles = [];
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 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<SessionListingId, HashSet<JoinAttemptId>> _outcomesByListing = [];
private readonly PriorityQueue<DeadlineEntry<JoinAttemptId>, long> _outcomeExpiries = new();
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
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 PriorityQueue<DeadlineEntry<string>, long> _replayExpiries = new();
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 _nextUdpMaintenance;
private long _maintenanceSweepCount;
@@ -47,6 +60,22 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
public Guid InstanceId { get; }
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
{
@@ -161,10 +190,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
if (_listings.Count >= _options.MaxListings
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|| _listings.Values.Count(entry => string.Equals(
entry.Definition.OwnerSubject,
command.Listing.OwnerSubject,
StringComparison.Ordinal)) >= command.OwnerListingLimit)
|| _listingCountsByOwner.GetValueOrDefault(command.Listing.OwnerSubject)
>= command.OwnerListingLimit)
{
return new(StoreResultCode.CapacityExceeded);
}
@@ -183,12 +210,21 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
WallDeadline(now, _options.LeaseLifetime),
version: 1);
_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);
_presenceHandles.Add(frozen.HostPresenceHandle, frozen.ListingId);
_idempotency.Add(idempotencyKey, new(
IdempotencyEntry idempotency = new(
command.RequestFingerprint,
frozen.ListingId,
now + _options.IdempotencyLifetime));
now + _options.IdempotencyLifetime);
_idempotency.Add(idempotencyKey, idempotency);
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline);
return new(StoreResultCode.Success, Snapshot(entry));
}, cancellationToken);
@@ -346,10 +382,20 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.CapacityExceeded);
}
_presence[command.Handle] = new(
bool isNewPresence = !_presence.ContainsKey(command.Handle);
PresenceEntry presence = new(
command.PublicEndpoint,
command.LocalEndpoint,
now + _options.PresenceLifetime);
_presence[command.Handle] = presence;
if (isNewPresence)
{
ScheduleMutableDeadline(
_presenceExpiries,
_scheduledPresenceExpiries,
command.Handle,
presence.Deadline);
}
return new(StoreResultCode.Success, Snapshot(entry));
}, cancellationToken, eagerCleanup: false);
@@ -444,8 +490,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
if (_attempts.Count >= _options.MaxJoinAttempts
|| _outcomeReports.Count >= _options.MaxOutcomeReports
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|| _attempts.Values.Count(entry => entry.Command.Scope == command.Scope)
>= command.ScopeAttemptLimit)
|| _attemptCountsByScope.GetValueOrDefault(command.Scope) >= command.ScopeAttemptLimit)
{
return new(StoreResultCode.CapacityExceeded);
}
@@ -461,19 +506,25 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
now + _options.JoinAttemptLifetime,
WallDeadline(now, _options.JoinAttemptLifetime));
_attempts.Add(command.AttemptId, attempt);
_attemptExpiries.Enqueue(
new AttemptExpiry(command.AttemptId, attempt.Deadline),
attempt.Deadline.Ticks);
_outcomeReports.Add(command.AttemptId, new(
AddToIndex(_attemptsByListing, command.ListingId, command.AttemptId);
_attemptCountsByScope[command.Scope] =
_attemptCountsByScope.GetValueOrDefault(command.Scope) + 1;
EnqueueDeadline(_attemptExpiries, command.AttemptId, attempt.Deadline);
OutcomeReportEntry outcome = new(
command.ListingId,
command.ClientSubject,
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);
_idempotency.Add(idempotencyKey, new(
IdempotencyEntry idempotency = new(
command.RequestFingerprint,
command.AttemptId,
now + _options.IdempotencyLifetime));
now + _options.IdempotencyLifetime);
_idempotency.Add(idempotencyKey, idempotency);
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline);
return new(StoreResultCode.Success, Snapshot(attempt));
}, cancellationToken);
@@ -757,7 +808,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
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);
}, cancellationToken);
@@ -794,7 +847,24 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
+ _presence.Count
+ _attempts.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
.Where(item => string.Equals(item.Value.Definition.OwnerSubject, subject, StringComparison.Ordinal))
.Select(static item => item.Key)
@@ -818,7 +888,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
}
foreach (JoinAttemptId attemptId in outcomeReports)
{
_outcomeReports.Remove(attemptId);
RemoveOutcome(attemptId);
}
int activeResourcesAfter = _listings.Count
@@ -911,63 +981,42 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
ClearActiveState();
}
_expiryChurn += RemoveExpired(_revocations, now);
_expiryChurn += RemoveExpired(_replay, now);
string[] expiredIdempotency = _idempotency
.Where(item => item.Value.Deadline <= now)
.Select(static item => item.Key)
.ToArray();
_expiryChurn += expiredIdempotency.Length;
foreach (string key in expiredIdempotency)
{
_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 += RemoveExpiredMutableDeadlines(
_revocations,
_revocationExpiries,
_scheduledRevocationExpiries,
now);
_expiryChurn += RemoveExpiredDeadlines(_replay, _replayExpiries, now);
_expiryChurn += RemoveExpiredIdempotency(now);
_expiryChurn += RemoveExpiredPresence(now);
_expiryChurn += RemoveExpiredAttempts(now);
JoinAttemptId[] expiredOutcomes = _outcomeReports
.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);
}
_expiryChurn += RemoveExpiredOutcomes(now);
_expiryChurn += RemoveExpiredListings(now);
}
private void ClearActiveState()
{
_listings.Clear();
_listingCountsByOwner.Clear();
_listingExpiries.Clear();
_scheduledListingExpiries.Clear();
_leases.Clear();
_presenceHandles.Clear();
_presence.Clear();
_presenceExpiries.Clear();
_scheduledPresenceExpiries.Clear();
_attempts.Clear();
_attemptCountsByScope.Clear();
_attemptsByListing.Clear();
_attemptExpiries.Clear();
_outcomeReports.Clear();
_outcomesByListing.Clear();
_outcomeExpiries.Clear();
_attemptHandles.Clear();
_idempotency.Clear();
_idempotencyExpiries.Clear();
_replay.Clear();
_replayExpiries.Clear();
}
private void RemoveListing(SessionListingId listingId)
@@ -978,23 +1027,23 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
}
_leases.Remove(listing.Definition.LeaseId);
DecrementCount(_listingCountsByOwner, listing.Definition.OwnerSubject);
_presenceHandles.Remove(listing.Definition.HostPresenceHandle);
_presence.Remove(listing.Definition.HostPresenceHandle);
foreach (JoinAttemptId attemptId in _attempts
.Where(item => item.Value.Command.ListingId == listingId)
.Select(static item => item.Key)
.ToArray())
if (_attemptsByListing.TryGetValue(listingId, out HashSet<JoinAttemptId>? attempts))
{
RemoveAttempt(attemptId);
foreach (JoinAttemptId attemptId in attempts.ToArray())
{
RemoveAttempt(attemptId);
}
}
foreach (JoinAttemptId attemptId in _outcomeReports
.Where(item => item.Value.ListingId == listingId)
.Select(static item => item.Key)
.ToArray())
if (_outcomesByListing.TryGetValue(listingId, out HashSet<JoinAttemptId>? outcomes))
{
_outcomeReports.Remove(attemptId);
foreach (JoinAttemptId attemptId in outcomes.ToArray())
{
RemoveOutcome(attemptId);
}
}
}
@@ -1003,20 +1052,75 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
if (_attempts.Remove(attemptId, out AttemptEntry? attempt))
{
_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)
{
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)
{
_attemptExpiries.Dequeue();
if (_attempts.TryGetValue(candidate.AttemptId, out AttemptEntry? current)
if (_attempts.TryGetValue(candidate.Key, out AttemptEntry? current)
&& current.Deadline == candidate.Deadline)
{
RemoveAttempt(candidate.AttemptId);
RemoveAttempt(candidate.Key);
removed++;
}
}
@@ -1024,6 +1128,184 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
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))
{
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) =>
_presenceHandles.ContainsKey(handle) || _attemptHandles.ContainsKey(handle);
@@ -1063,20 +1345,6 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
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)
{
ArgumentNullException.ThrowIfNull(listing);
@@ -1219,7 +1487,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
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(
SessionListingId listingId,
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Transport;
internal sealed partial class UdpMediatorService : BackgroundService
internal sealed class UdpMediatorService : BackgroundService
{
private readonly ILogger<UdpMediatorService> _logger;
private readonly UdpMediatorOptions _options;
@@ -133,20 +133,23 @@ internal sealed partial class UdpMediatorService : BackgroundService
manager?.Stop();
}
[LoggerMessage(
EventId = 1,
Level = LogLevel.Information,
Message = "UDP mediator listening on {ListenAddress}:{ListenPort}")]
private static partial void LogMediatorListening(
private static readonly Action<ILogger, IPAddress, int, Exception?> MediatorListening =
LoggerMessage.Define<IPAddress, int>(
LogLevel.Information,
new EventId(1, nameof(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,
IPAddress listenAddress,
int listenPort);
int listenPort) => MediatorListening(logger, listenAddress, listenPort, null);
[LoggerMessage(
EventId = 2,
Level = LogLevel.Information,
Message = "UDP mediator stopped")]
private static partial void LogMediatorStopped(ILogger logger);
private static void LogMediatorStopped(ILogger logger) => MediatorStopped(logger, null);
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
{
@@ -4,7 +4,7 @@
"net10.0": {
"LiteNetLib": {
"type": "Direct",
"requested": "[2.1.4, )",
"requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
@@ -4,7 +4,7 @@
"net8.0": {
"LiteNetLib": {
"type": "Direct",
"requested": "[2.1.4, )",
"requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
@@ -22,7 +22,7 @@
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
"LiteNetLib": "[2.1.4, )"
"LiteNetLib": "[2.1.4, 2.1.4]"
}
},
"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=="
}
}
}
}
@@ -1,3 +1,6 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
@@ -19,6 +22,49 @@ namespace FinalFactory.Rendezvous.Tests.Client;
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]
public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging()
{
@@ -102,6 +148,27 @@ public sealed class RendezvousClientIntegrationTests
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()
{
IdempotencyKey = $"sdk-integration-{index}",
@@ -139,7 +206,7 @@ public sealed class RendezvousClientIntegrationTests
internal EphemeralCapabilityIssuer Capabilities { 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);
EphemeralStoreOptions stateOptions = new();
@@ -153,7 +220,7 @@ public sealed class RendezvousClientIntegrationTests
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
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 =>
ContractJson.Configure(options.SerializerOptions));
builder.Services.Configure<RouteHandlerOptions>(static options =>
@@ -180,10 +247,10 @@ public sealed class RendezvousClientIntegrationTests
app.MapRendezvousContractEndpoints();
await app.StartAsync();
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(
app,
new HttpClient { BaseAddress = new Uri(address) },
new HttpClient { BaseAddress = new Uri(serviceAddress) },
store,
capabilities,
credential);
@@ -196,4 +263,13 @@ public sealed class RendezvousClientIntegrationTests
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;
internal static class ContractTestFiles
{
public static string Read(string fileName) => File
.ReadAllText(Path.Combine(Directory, fileName))
.ReadAllText(Path.Combine(DirectoryFor(fileName), fileName))
.TrimEnd('\r', '\n');
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
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
string solution = Path.Combine(directory.FullName, "Rendezvous.slnx");
if (File.Exists(solution))
if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx")))
{
return Path.Combine(
directory.FullName,
"tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1");
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate contract test data.");
throw new DirectoryNotFoundException("Could not locate repository root.");
}
}
}
@@ -40,12 +40,10 @@ public sealed class OpenApiCompatibilityTests
];
[Fact]
public void GeneratedOpenApiContainsTheFrozenV1Surface()
public void GeneratedOpenApiContainsTheFrozenVersionedSurface()
{
string path = Path.Combine(
ContractTestFiles.Directory,
"../../../../../docs/api/rendezvous-v1.json");
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(Path.GetFullPath(path)));
using JsonDocument document = JsonDocument.Parse(
File.ReadAllText(ContractTestFiles.OpenApiDocument));
JsonElement root = document.RootElement;
Assert.Equal("3.1.1", root.GetProperty("openapi").GetString());
@@ -1,3 +1,4 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
@@ -22,6 +23,20 @@ public sealed class TraversalTokenCodecTests
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]
public void IntroductionTokenRejectsNonCanonicalOrAlteredFields()
{
@@ -61,6 +61,32 @@ public sealed class ProductionProcessTests
Assert.InRange(shutdown.Elapsed, TimeSpan.FromMilliseconds(700), TimeSpan.FromSeconds(5));
AssertTcpPortIsReleased(httpPort);
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
{
@@ -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]
public async Task DocumentedSmokeScriptReachesHttpAndAuthenticatedUdpFlow()
{
@@ -101,6 +101,16 @@ public sealed class OperatorEndpointTests
tenant.GameId == "space-game"
&& tenant.EnvironmentId == "production"
&& tenant.Status == "enabled");
Assert.Equal("1.0.0", operatorStatus.Compatibility.ServerVersion);
Assert.Equal("1.0.0", operatorStatus.Compatibility.MinimumClientVersion);
Assert.Equal(1, operatorStatus.Compatibility.MaximumClientMajorVersion);
Assert.Equal([1], operatorStatus.Compatibility.HttpContractVersions);
Assert.Equal([1], operatorStatus.Compatibility.UdpContractVersions);
Assert.Equal([1], operatorStatus.Compatibility.ConnectionTicketFormatVersions);
Assert.Equal(2, operatorStatus.Compatibility.LiteNetLibMajorVersion);
Assert.Equal(
"exact-per-tenant",
operatorStatus.Compatibility.GameplayProtocolCompatibility);
Assert.Contains(operatorStatus.SigningKeys, static key =>
key.KeyId == OperatorTestHost.OperatorKeyId
&& key.Status == "signing"
@@ -0,0 +1,185 @@
using System.Reflection;
using System.Text.Json;
using System.Xml.Linq;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Operations;
namespace FinalFactory.Rendezvous.Tests.Release;
public sealed class ReleaseCompatibilityTests
{
[Fact]
public void CentralVersionsRuntimeWindowAndPublishedMatrixStayAligned()
{
string root = FindRepositoryRoot();
XDocument versions = XDocument.Load(Path.Combine(root, "eng/Versions.props"));
string releaseVersion = Property(versions, "RendezvousVersion");
int releaseMajor = int.Parse(Property(versions, "RendezvousMajorVersion"), System.Globalization.CultureInfo.InvariantCulture);
string[] numericVersion = releaseVersion.Split(['-', '+'], 2)[0].Split('.');
Assert.Equal(releaseMajor, int.Parse(numericVersion[0], System.Globalization.CultureInfo.InvariantCulture));
Assert.Equal(Property(versions, "RendezvousMinorVersion"), numericVersion[1]);
Assert.Equal(Property(versions, "RendezvousPatchVersion"), numericVersion[2]);
int httpVersion = int.Parse(Property(versions, "HttpContractVersion"), System.Globalization.CultureInfo.InvariantCulture);
int udpVersion = int.Parse(Property(versions, "UdpContractVersion"), System.Globalization.CultureInfo.InvariantCulture);
int ticketVersion = int.Parse(Property(versions, "ConnectionTicketFormatVersion"), System.Globalization.CultureInfo.InvariantCulture);
Assert.Equal(releaseVersion, typeof(RendezvousPublisherClient).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion.Split('+')[0]);
Assert.Equal(releaseVersion, typeof(ContractLimits).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion.Split('+')[0]);
Assert.Equal(ContractLimits.ContractVersion, httpVersion);
OperatorCompatibilityResponse runtime = ReleaseCompatibility.CreateResponse();
Assert.Equal(releaseVersion, runtime.ServerVersion);
Assert.Equal(Property(versions, "MinimumClientVersion"), runtime.MinimumClientVersion);
Assert.Equal(int.Parse(Property(versions, "MaximumClientMajorVersion"), System.Globalization.CultureInfo.InvariantCulture), runtime.MaximumClientMajorVersion);
Assert.Equal([httpVersion], runtime.HttpContractVersions);
Assert.Equal([udpVersion], runtime.UdpContractVersions);
Assert.Equal([ticketVersion], runtime.ConnectionTicketFormatVersions);
Assert.Equal(int.Parse(Property(versions, "LiteNetLibMajorVersion"), System.Globalization.CultureInfo.InvariantCulture), runtime.LiteNetLibMajorVersion);
Assert.Equal("exact-per-tenant", runtime.GameplayProtocolCompatibility);
using JsonDocument matrix = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "docs/releases/compatibility.json")));
JsonElement document = matrix.RootElement;
Assert.Equal(releaseVersion, document.GetProperty("release").GetString());
Assert.Equal(releaseVersion, document.GetProperty("packages").GetProperty("FinalFactory.Rendezvous.Client").GetString());
Assert.Equal(releaseVersion, document.GetProperty("packages").GetProperty("FinalFactory.Rendezvous.Contracts").GetString());
Assert.Equal(runtime.MinimumClientVersion, document.GetProperty("server").GetProperty("minimumClientVersion").GetString());
Assert.Equal(runtime.MaximumClientMajorVersion, document.GetProperty("server").GetProperty("maximumClientMajorVersion").GetInt32());
Assert.Equal(httpVersion, Assert.Single(document.GetProperty("contracts").GetProperty("http").EnumerateArray()).GetInt32());
Assert.Equal(udpVersion, Assert.Single(document.GetProperty("contracts").GetProperty("udp").EnumerateArray()).GetInt32());
Assert.Equal(ticketVersion, Assert.Single(document.GetProperty("contracts").GetProperty("connectionTicket").EnumerateArray()).GetInt32());
Assert.Equal(runtime.GameplayProtocolCompatibility, document.GetProperty("contracts").GetProperty("gameplay").GetString());
Assert.Equal("LiteNetLib", document.GetProperty("transport").GetProperty("package").GetString());
Assert.Equal(Property(versions, "LiteNetLibVersion"), document.GetProperty("transport").GetProperty("version").GetString());
Assert.Equal(runtime.LiteNetLibMajorVersion, document.GetProperty("transport").GetProperty("major").GetInt32());
foreach ((string consumer, string fixture) in new[]
{
("SpaceGame", "spacegame/SpaceGame.Rendezvous.Consumer.csproj"),
("Unscouted", "unscouted/Unscouted.Rendezvous.Consumer.csproj"),
})
{
XDocument fixtureProject = XDocument.Load(Path.Combine(root, "tests/consumers", fixture));
Assert.Equal(
fixtureProject.Descendants("TargetFramework").Single().Value,
document.GetProperty("consumers").GetProperty(consumer).GetString());
}
string snapshots = Path.Combine(root, $"tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v{releaseMajor}");
Assert.True(File.Exists(Path.Combine(snapshots, "client-public-api.txt")));
Assert.True(File.Exists(Path.Combine(snapshots, "contracts-public-api.txt")));
Assert.True(File.Exists(Path.Combine(root, $"docs/api/rendezvous-v{httpVersion}.json")));
}
[Fact]
public void ReleaseDefinitionIsImmutableTagOnlyAndDocumentsRequiredNotes()
{
string root = FindRepositoryRoot();
XDocument versions = XDocument.Load(Path.Combine(root, "eng/Versions.props"));
string releaseVersion = Property(versions, "RendezvousVersion");
string workflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows/release.yml"));
Assert.Contains("tags:", workflow, StringComparison.Ordinal);
Assert.Contains("RELEASE_TOKEN", workflow, StringComparison.Ordinal);
Assert.Contains("RELEASE_USERNAME", workflow, StringComparison.Ordinal);
Assert.Contains("COSIGN_PRIVATE_KEY", workflow, StringComparison.Ordinal);
Assert.DoesNotContain(":latest", workflow, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("skip-duplicate", workflow, StringComparison.OrdinalIgnoreCase);
Assert.Contains("check-compatibility.sh", workflow, StringComparison.Ordinal);
Assert.Contains("--platform linux/amd64", workflow, StringComparison.Ordinal);
Assert.Contains("ignore-unfixed: false", workflow, StringComparison.Ordinal);
Assert.Contains("format: spdx-json", workflow, StringComparison.Ordinal);
Assert.Contains("normalize-container-sbom", workflow, StringComparison.Ordinal);
Assert.Contains("finalize-release-candidate.sh", workflow, StringComparison.Ordinal);
Assert.Contains("verify-real-consumers.sh", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_RELEASE_BUILDER", workflow, StringComparison.Ordinal);
Assert.Contains("--image-id", workflow, StringComparison.Ordinal);
string publisher = File.ReadAllText(Path.Combine(root, "scripts/publish-release.sh"));
Assert.Contains("expected_image_id", publisher, StringComparison.Ordinal);
Assert.Contains("finalize-signing-ready-release.sh", publisher, StringComparison.Ordinal);
XDocument packages = XDocument.Load(Path.Combine(root, "Directory.Packages.props"));
XElement liteNetLib = Assert.Single(packages.Descendants("PackageVersion"), static item => (string?)item.Attribute("Include") == "LiteNetLib");
Assert.Equal("[$(LiteNetLibVersion)]", (string?)liteNetLib.Attribute("Version"));
string changelog = File.ReadAllText(Path.Combine(root, "CHANGELOG.md"));
Assert.Contains("### Compatibility", changelog, StringComparison.Ordinal);
Assert.Contains("### Security and configuration", changelog, StringComparison.Ordinal);
Assert.Contains("### Migration", changelog, StringComparison.Ordinal);
Assert.DoesNotContain($"{releaseVersion} - Unreleased", changelog, StringComparison.Ordinal);
foreach (string consumer in new[] { "spacegame", "unscouted" })
{
string consumerDirectory = Path.Combine(root, "tests/consumers", consumer);
string projectPath = Assert.Single(Directory.GetFiles(consumerDirectory, "*.csproj"));
XDocument consumerProject = XDocument.Load(projectPath);
XElement[] references = consumerProject.Descendants("PackageReference")
.Where(static item => ((string?)item.Attribute("Include"))?.StartsWith("FinalFactory.Rendezvous.", StringComparison.Ordinal) == true)
.ToArray();
Assert.Equal(2, references.Length);
Assert.All(references, static reference =>
Assert.Equal("[$(RendezvousPackageVersion)]", (string?)reference.Attribute("Version")));
Assert.Equal(
"false",
consumerProject.Descendants("RestorePackagesWithLockFile").Single().Value);
}
XDocument unscouted = XDocument.Load(Path.Combine(
root,
"tests/consumers/unscouted/Unscouted.Rendezvous.Consumer.csproj"));
XElement directTransport = Assert.Single(
unscouted.Descendants("PackageReference"),
static item => (string?)item.Attribute("Include") == "LiteNetLib");
Assert.Equal("[2.1.4]", (string?)directTransport.Attribute("Version"));
using JsonDocument consumers = JsonDocument.Parse(
File.ReadAllText(Path.Combine(root, "eng/consumer-revisions.json")));
JsonElement[] pinnedConsumers = consumers.RootElement.GetProperty("consumers")
.EnumerateArray()
.ToArray();
Assert.Equal(
["SpaceGame", "Unscouted"],
pinnedConsumers.Select(static item => item.GetProperty("name").GetString()!).ToArray());
Assert.All(pinnedConsumers, static item =>
Assert.Matches("^[0-9a-f]{40}$", item.GetProperty("revision").GetString()));
}
[Fact]
public void ServerSourceManifestIsCompleteSortedAndDeterministic()
{
string root = FindRepositoryRoot();
string projectDirectory = Path.Combine(root, "src/FinalFactory.Rendezvous.Server");
XDocument project = XDocument.Load(Path.Combine(projectDirectory, "FinalFactory.Rendezvous.Server.csproj"));
string[] declared = project.Descendants("Compile")
.Select(static item => (string)item.Attribute("Include")!)
.ToArray();
string[] actual = Directory.GetFiles(projectDirectory, "*.cs", SearchOption.AllDirectories)
.Where(static path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
&& !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
.Select(path => Path.GetRelativePath(projectDirectory, path).Replace(Path.DirectorySeparatorChar, '/'))
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(actual, declared);
}
private static string Property(XDocument document, string name) =>
document.Descendants(name).Single().Value;
private static string FindRepositoryRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate repository root.");
}
}
@@ -411,6 +411,92 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
Assert.True(fixture.Store.CreateJoinAttempt(attempt).Succeeded);
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateJoinAttempt(
fixture.AttemptCommand(listing, "client-quota-2") with { ScopeAttemptLimit = 1 }).Code);
fixture.Clock.Advance(TimeSpan.FromSeconds(30));
Assert.True(fixture.Store.BindHostPresence(new(
first.Listing.HostPresenceHandle,
first.Listing.HostPresenceFingerprint,
EphemeralStateFixture.PublicEndpoint(42_101),
null)).Succeeded);
Assert.True(fixture.Store.CreateJoinAttempt(
fixture.AttemptCommand(listing, "client-quota-3") with { ScopeAttemptLimit = 1 }).Succeeded);
}
[Fact]
public void RemovingAListingReleasesItsConstantTimeOwnerQuota()
{
EphemeralStateFixture fixture = new();
CreateListingCommand first = fixture.ListingCommand(owner: "publisher-quota") with
{
OwnerListingLimit = 1,
};
CreateListingCommand second = fixture.ListingCommand(owner: "publisher-quota") with
{
OwnerListingLimit = 1,
};
StoreResult<StoredListing> created = fixture.Store.CreateListing(first);
Assert.True(created.Succeeded);
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateListing(second).Code);
Assert.True(fixture.Store.DeleteListing(new(
first.Listing.ListingId,
first.Listing.LeaseId,
first.Listing.LeaseFingerprint,
first.Listing.OwnerSubject)).Succeeded);
Assert.True(fixture.Store.CreateListing(second).Succeeded);
}
[Fact]
public void RepeatedMutableDeadlineRefreshesKeepOneScheduledEntryPerKey()
{
EphemeralStateFixture fixture = new();
StoredListing listing = fixture.CreateVisibleListing(out _);
for (int iteration = 0; iteration < 10_000; iteration++)
{
fixture.Clock.Advance(TimeSpan.FromTicks(1));
StoreResult<StoredListing> renewed = fixture.Store.RenewLease(new(
listing.Definition.ListingId,
listing.Definition.LeaseId,
listing.Definition.LeaseFingerprint,
listing.Definition.OwnerSubject,
listing.Version));
Assert.True(renewed.Succeeded, $"Renewal {iteration} failed with {renewed.Code}.");
listing = renewed.Value!;
StoreResult<StoredListing> presence = fixture.Store.BindHostPresence(new(
listing.Definition.HostPresenceHandle,
listing.Definition.HostPresenceFingerprint,
EphemeralStateFixture.PublicEndpoint(42_200),
null));
Assert.True(presence.Succeeded, $"Presence {iteration} failed with {presence.Code}.");
StoreResult<int> revoked = fixture.Store.RevokePrincipal(
"repeated-revocation",
TimeSpan.FromMinutes(1));
Assert.True(revoked.Succeeded, $"Revocation {iteration} failed with {revoked.Code}.");
}
Assert.Equal(4, fixture.Store.ScheduledExpiryEntryCount);
fixture.Clock.Advance(TimeSpan.FromSeconds(19));
Assert.True(fixture.Store.GetListing(listing.Definition.ListingId, true).Succeeded);
Assert.Equal(4, fixture.Store.ScheduledExpiryEntryCount);
}
[Fact]
public void RepeatedPrincipalRevocationCanExtendButCannotShortenProtection()
{
EphemeralStateFixture fixture = new();
const string subject = "protected-publisher";
Assert.True(fixture.Store.RevokePrincipal(subject, TimeSpan.FromMinutes(10)).Succeeded);
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
Assert.True(fixture.Store.RevokePrincipal(subject, TimeSpan.FromSeconds(1)).Succeeded);
fixture.Clock.Advance(TimeSpan.FromSeconds(2));
Assert.Equal(
StoreResultCode.Revoked,
fixture.Store.CreateListing(fixture.ListingCommand(owner: subject)).Code);
fixture.Clock.Advance(TimeSpan.FromSeconds(597));
Assert.True(fixture.Store.CreateListing(fixture.ListingCommand(owner: subject)).Succeeded);
}
[Fact]
@@ -0,0 +1,6 @@
{
"attemptId": "00000000-0000-0000-0000-000000000301",
"derivedAuthenticator": "TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT",
"connectionTicket": "AAAAAAAAAAAAAAAAAAADAU000000000000000000000",
"digest": "d6yCrbIOzwKoaOZQ8A9eggclDY0yzmhJH36FDz4L7wE"
}
@@ -97,7 +97,7 @@
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
"LiteNetLib": "[2.1.4, )"
"LiteNetLib": "[2.1.4, 2.1.4]"
}
},
"finalfactory.rendezvous.contracts": {
@@ -107,7 +107,7 @@
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
"LiteNetLib": "[2.1.4, )",
"LiteNetLib": "[2.1.4, 2.1.4]",
"Microsoft.AspNetCore.OpenApi": "[10.0.9, )"
}
},
@@ -116,12 +116,12 @@
"dependencies": {
"FinalFactory.Rendezvous.Client": "[1.0.0, )",
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
"LiteNetLib": "[2.1.4, )"
"LiteNetLib": "[2.1.4, 2.1.4]"
}
},
"LiteNetLib": {
"type": "CentralTransitive",
"requested": "[2.1.4, )",
"requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
+8
View File
@@ -0,0 +1,8 @@
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
Console.WriteLine(
$"SpaceGame consumer: contract={ContractLimits.ContractVersion}, "
+ $"client={typeof(RendezvousClientOptions).Assembly.GetName().Version}, "
+ $"transport={typeof(NetManager).Assembly.GetName().Version}");
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FinalFactory.Rendezvous.Client" Version="[$(RendezvousPackageVersion)]" />
<PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="[$(RendezvousPackageVersion)]" />
</ItemGroup>
</Project>
+8
View File
@@ -0,0 +1,8 @@
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
Console.WriteLine(
$"Unscouted consumer: contract={ContractLimits.ContractVersion}, "
+ $"client={typeof(RendezvousClientOptions).Assembly.GetName().Version}, "
+ $"transport={typeof(NetManager).Assembly.GetName().Version}");
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FinalFactory.Rendezvous.Client" Version="[$(RendezvousPackageVersion)]" />
<PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="[$(RendezvousPackageVersion)]" />
<PackageReference Include="LiteNetLib" Version="[2.1.4]" />
</ItemGroup>
</Project>