Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bad659c12 | |||
| f368fec6eb | |||
| 9e863ebf64 | |||
| ebb5eb617c | |||
| 7fb85059fb | |||
| cc5793f935 |
+26
-5
@@ -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
|
||||
|
||||
@@ -98,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
|
||||
|
||||
@@ -127,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"
|
||||
|
||||
@@ -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"
|
||||
@@ -8,5 +8,7 @@ TestResults/
|
||||
*.userosscache
|
||||
deploy/compose/.smoke.env
|
||||
artifacts/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
deploy/compose/secrets/*
|
||||
!deploy/compose/secrets/.gitignore
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
All notable Rendezvous release changes are recorded here. Versions follow
|
||||
Semantic Versioning; HTTP, UDP, and connection-ticket format compatibility is
|
||||
tracked separately and called out for every release.
|
||||
|
||||
## 1.0.0 - 2026-07-16
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Initial Client and Contracts package major: 1.
|
||||
- HTTP contract: v1; UDP mediation contract: v1; connection-ticket format: v1.
|
||||
- Server accepts Client 1.0.0 through the latest compatible 1.x release.
|
||||
- Client traversal is pinned to LiteNetLib 2.1.4; LiteNetLib 1.x is unsupported.
|
||||
|
||||
### Security and configuration
|
||||
|
||||
- Packages contain no reusable credentials or environment configuration.
|
||||
- Production server startup requires provisioned signing keys and the hardened
|
||||
single-active deployment configuration.
|
||||
|
||||
### Migration
|
||||
|
||||
- This is the first packaged release; no prior package or wire migration exists.
|
||||
- Consumers must pin both Rendezvous packages to the same exact version.
|
||||
@@ -1,4 +1,5 @@
|
||||
<Project>
|
||||
<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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -84,8 +84,8 @@ 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, secure single-active Linux deployment, and
|
||||
numeric capacity/resilience gates are implemented. Packaging, consumer pilots,
|
||||
and final production-readiness gates remain in progress;
|
||||
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.
|
||||
|
||||
@@ -100,17 +100,29 @@ defined in [hostile-input and overload protection](docs/security/abuse-protectio
|
||||
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
|
||||
operator controls are defined in the
|
||||
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
|
||||
Concrete detect/contain/recover/verify procedures are in the
|
||||
[incident and change runbooks](docs/operations/incident-runbooks.md).
|
||||
The pinned non-root container, production topology, graceful drain, Linux
|
||||
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 package, gameplay-socket, host-admission, provisioning, metadata, key rotation,
|
||||
versioning, and secure rollout seams are in the
|
||||
[game integration guide](docs/integration/sdk-seams.md).
|
||||
The always-on three-party scenarios, optional Linux namespace topology, and
|
||||
simulation limits are documented in the
|
||||
[deterministic topology harness](docs/integration/topology-harness.md).
|
||||
The current consumer evidence and remaining external gates are tracked in the
|
||||
[SpaceGame consumer pilot](docs/integration/spacegame-pilot.md) and independent
|
||||
[Unscouted consumer pilot](docs/integration/unscouted-pilot.md).
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"AllowedHosts": "localhost;127.0.0.1",
|
||||
"AllowedHosts": "localhost;127.0.0.1;rendezvous",
|
||||
"Rendezvous": {
|
||||
"Deployment": {
|
||||
"PublicHttpBaseUrl": "https://localhost/",
|
||||
@@ -32,6 +32,16 @@
|
||||
"NotBefore": "2026-01-01T00:00:00Z",
|
||||
"SignUntil": "2100-01-01T00:00:00Z",
|
||||
"VerifyUntil": "2100-01-02T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"KeyId": "local-smoke-unscouted-1",
|
||||
"SecretReference": "file:/run/secrets/rendezvous-signing-key",
|
||||
"CredentialKinds": ["DedicatedPublisher"],
|
||||
"GameId": "unscouted",
|
||||
"EnvironmentId": "smoke",
|
||||
"NotBefore": "2026-01-01T00:00:00Z",
|
||||
"SignUntil": "2100-01-01T00:00:00Z",
|
||||
"VerifyUntil": "2100-01-02T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"Games": [
|
||||
@@ -39,18 +49,41 @@
|
||||
"GameId": "space-game",
|
||||
"EnvironmentId": "smoke",
|
||||
"Enabled": true,
|
||||
"ProtocolVersions": [1, 2],
|
||||
"Regions": ["local"],
|
||||
"VisibilityModes": ["Public"],
|
||||
"PublisherTrustModes": ["ManagedDedicated"],
|
||||
"MetadataValueMaxBytes": {
|
||||
"mode": 32
|
||||
},
|
||||
"RequiredMetadataKeys": [],
|
||||
"MetadataMaxBytes": 512,
|
||||
"MetadataMaxKeys": 1,
|
||||
"MaxListingsPerPrincipal": 10,
|
||||
"MaxAnonymousListingsPerAddress": 0,
|
||||
"MaxActiveJoinAttempts": 100,
|
||||
"FallbackPolicy": "DedicatedEndpointAllowed"
|
||||
},
|
||||
{
|
||||
"GameId": "unscouted",
|
||||
"EnvironmentId": "smoke",
|
||||
"Enabled": true,
|
||||
"ProtocolVersions": [1],
|
||||
"Regions": ["local"],
|
||||
"VisibilityModes": ["Public"],
|
||||
"PublisherTrustModes": ["ManagedDedicated"],
|
||||
"MetadataValueMaxBytes": {},
|
||||
"RequiredMetadataKeys": [],
|
||||
"MetadataValueMaxBytes": {
|
||||
"mode": 32,
|
||||
"world": 64,
|
||||
"mods": 64
|
||||
},
|
||||
"RequiredMetadataKeys": ["mode", "world", "mods"],
|
||||
"MetadataMaxBytes": 512,
|
||||
"MetadataMaxKeys": 0,
|
||||
"MetadataMaxKeys": 3,
|
||||
"MaxListingsPerPrincipal": 10,
|
||||
"MaxAnonymousListingsPerAddress": 0,
|
||||
"MaxActiveJoinAttempts": 100,
|
||||
"FallbackPolicy": "Disabled"
|
||||
"FallbackPolicy": "DedicatedEndpointAllowed"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ name: rendezvous-local
|
||||
|
||||
services:
|
||||
rendezvous:
|
||||
image: finalfactory/rendezvous:local
|
||||
image: "${RENDEZVOUS_IMAGE:-finalfactory/rendezvous:local}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -42,9 +42,11 @@ test "$RENDEZVOUS_UID" -ne 0
|
||||
docker compose -f deploy/compose/compose.yaml up --build --detach
|
||||
```
|
||||
|
||||
`deploy/compose/appsettings.Production.json` is an isolated loopback smoke
|
||||
profile, not an Internet template: it deliberately opts into private advertised
|
||||
endpoints and has no TLS proxy. Its random key is ignored by Git and must be
|
||||
`deploy/compose/appsettings.Production.json` is a local/private-bridge smoke
|
||||
profile, not an Internet template: TCP is published only on host loopback, the
|
||||
explicit `rendezvous` host name serves isolated clients on the Compose network,
|
||||
and the profile deliberately opts into private advertised endpoints without a
|
||||
TLS proxy. Its random key is ignored by Git and must be
|
||||
deleted after use. Its deliberately long key window only keeps this disposable
|
||||
local fixture usable; production keys require short, reviewed rotation windows.
|
||||
Production configuration must use its real public names and must leave
|
||||
@@ -177,6 +179,9 @@ dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
|
||||
|
||||
For the local Compose profile, the script derives a ten-minute diagnostic
|
||||
publisher credential from the ignored local key without printing either secret.
|
||||
The fixed-scope helper used by the smoke can also support the manual
|
||||
[TestClient local flow](../integration/test-client.md); it is deliberately not a
|
||||
production issuer.
|
||||
For production, do not copy the signing key to the smoke host. Instead inject a
|
||||
short-lived, region-scoped credential through
|
||||
`RENDEZVOUS_PUBLISHER_CREDENTIAL`, and set the external endpoints:
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"recordedAt": "2026-07-16",
|
||||
"issue": 21,
|
||||
"consumerIssue": "Kyuubi/SpaceGame#3",
|
||||
"result": "checkpoint-pass-with-external-gates",
|
||||
"rendezvousBaseCommit": "ebb5eb617c0bbb170418afab396b68584b7f992e",
|
||||
"consumerCommit": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
|
||||
"consumerIssueComment": 11469,
|
||||
"packages": {
|
||||
"FinalFactory.Rendezvous.Client": {
|
||||
"version": "1.0.0",
|
||||
"source": "local-candidate",
|
||||
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||
"sha256": "fb156cf48b49f75c244dd25ea7cc4aa9fc6fab0a878393bb7efd5d9b131d0395"
|
||||
},
|
||||
"FinalFactory.Rendezvous.Contracts": {
|
||||
"version": "1.0.0",
|
||||
"source": "local-candidate",
|
||||
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||
"sha256": "a82ba986d3905d599096d1d8ce8f32cd4feb104abfca37b0f65e0d2ef3df9a6f"
|
||||
},
|
||||
"LiteNetLib": {
|
||||
"version": "2.1.4"
|
||||
}
|
||||
},
|
||||
"localRun": {
|
||||
"processes": ["Rendezvous", "Godot SpaceGame host", "two sequential Godot SpaceGame clients"],
|
||||
"typedOutcome": "Connected",
|
||||
"gameAdmission": "Accepted",
|
||||
"directGameplay": true,
|
||||
"authenticatedSessions": 2,
|
||||
"directInputs": 2,
|
||||
"directSnapshots": 2,
|
||||
"lifecyclePackets": 4,
|
||||
"gameplayTransport": "caller-owned-litenetlib",
|
||||
"rendezvousGameplayPayloadPath": "none",
|
||||
"hostLeaseRenewed": true,
|
||||
"reconnected": true,
|
||||
"deregistered": true
|
||||
},
|
||||
"linuxRun": {
|
||||
"runtime": "Godot 4.7 .NET Linux x86_64",
|
||||
"freshExport": true,
|
||||
"sourceDirty": false,
|
||||
"optimized": true,
|
||||
"dedicatedHostNamespace": "docker",
|
||||
"remoteClientNamespace": "docker",
|
||||
"topology": "private-bridge",
|
||||
"directGameplay": true,
|
||||
"fallback": "PunchTimedOut to explicit Docker-gateway endpoint, then Accepted game admission and direct gameplay",
|
||||
"artifactHashes": "SpaceGame issue #3 comment 11469"
|
||||
},
|
||||
"negativePaths": {
|
||||
"incompatibleProtocol": "proven",
|
||||
"staleHostPresence": "proven",
|
||||
"punchTimeout": "proven",
|
||||
"invalidAdmission": "integration-proven",
|
||||
"capacity": "regression-tested",
|
||||
"fallbackConnection": "godot-and-isolated-linux-proven",
|
||||
"reconnect": "proven"
|
||||
},
|
||||
"verification": {
|
||||
"debugBuild": "passed",
|
||||
"releaseBuild": "passed",
|
||||
"debugTests": { "passed": 31, "failed": 0 },
|
||||
"releaseTests": { "passed": 31, "failed": 0 },
|
||||
"exportRelease": "optimized-without-debug-symbols",
|
||||
"format": "passed",
|
||||
"shellcheck": "passed",
|
||||
"godotReconnectHarness": "passed",
|
||||
"godotFallbackHarness": "passed",
|
||||
"linuxContainerHarness": "passed-clean-source",
|
||||
"failureMatrix": "passed",
|
||||
"adversarialReview": "passed-after-fixes"
|
||||
},
|
||||
"openGates": [
|
||||
"public-package-restore",
|
||||
"representative-external-nat"
|
||||
],
|
||||
"relatedSpaceGameGates": [
|
||||
"production-enet-replacement",
|
||||
"capacity-profiles-64-and-128",
|
||||
"sigterm-drain-save"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"recordedAt": "2026-07-16",
|
||||
"issue": 22,
|
||||
"consumerIssue": "HeiKyu/Unscouted#459",
|
||||
"result": "checkpoint-pass-with-external-gates",
|
||||
"rendezvousConfigurationCommit": "f368fec6eb4344a6042974f58f888cf0f1ac8e8e",
|
||||
"consumerImplementationCommit": "1e5886aa7f1e44689b4c75e32693eb7b19fd72d7",
|
||||
"consumerEvidenceCommit": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
|
||||
"consumerIssueComment": 11499,
|
||||
"packages": {
|
||||
"FinalFactory.Rendezvous.Client": {
|
||||
"version": "1.0.0",
|
||||
"source": "local-candidate",
|
||||
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||
"sha256": "fb156cf48b49f75c244dd25ea7cc4aa9fc6fab0a878393bb7efd5d9b131d0395"
|
||||
},
|
||||
"FinalFactory.Rendezvous.Contracts": {
|
||||
"version": "1.0.0",
|
||||
"source": "local-candidate",
|
||||
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||
"sha256": "a82ba986d3905d599096d1d8ce8f32cd4feb104abfca37b0f65e0d2ef3df9a6f"
|
||||
},
|
||||
"LiteNetLib": {
|
||||
"version": "2.1.4"
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
"gameId": "unscouted",
|
||||
"environmentId": "smoke",
|
||||
"regionId": "local",
|
||||
"protocolVersion": 1,
|
||||
"publisherTrust": "ManagedDedicated",
|
||||
"fallbackPolicy": "DedicatedEndpointAllowed",
|
||||
"metadataKeys": ["mode", "world", "mods"],
|
||||
"metadataMaxKeys": 3,
|
||||
"metadataMaxBytes": 512
|
||||
},
|
||||
"godotRun": {
|
||||
"runtime": "Godot 4.7 .NET Linux x86_64",
|
||||
"processes": [
|
||||
"Rendezvous hardened Compose service",
|
||||
"Godot Unscouted host",
|
||||
"Godot incompatible-protocol client",
|
||||
"Godot direct client",
|
||||
"Godot fallback client"
|
||||
],
|
||||
"gameplayTransport": "unscouted-litenetlib",
|
||||
"rendezvousGameplayPayloadPath": "none",
|
||||
"directGameplay": true,
|
||||
"fallbackGameplay": true,
|
||||
"authenticatedSessions": 2,
|
||||
"gameplayExchanges": 2,
|
||||
"hostLeaseRenewed": true,
|
||||
"deregistered": true,
|
||||
"playerIdentityOwner": "unscouted",
|
||||
"canonicalGameStateOwner": "unscouted"
|
||||
},
|
||||
"negativePaths": {
|
||||
"incompatibleProtocol": "proven-no-compatible-listing",
|
||||
"wrongGame": "proven-exact-NotFound",
|
||||
"wrongEnvironment": "proven-exact-NotFound",
|
||||
"punchTimeout": "proven-typed-failure-then-game-owned-fallback",
|
||||
"unexpectedMetadata": "consumer-regression-tested"
|
||||
},
|
||||
"verification": {
|
||||
"rendezvousDebugTests": { "passed": 299, "failed": 0 },
|
||||
"rendezvousReleaseTests": { "passed": 299, "failed": 0 },
|
||||
"consumerDebugTests": { "passed": 3310, "skipped": 15, "failed": 0 },
|
||||
"consumerReleaseTests": { "passed": 3310, "skipped": 15, "failed": 0 },
|
||||
"consumerGdUnitTests": { "passed": 360, "skipped": 0, "failed": 0 },
|
||||
"consumerExport": "not-applicable-no-export-presets",
|
||||
"format": "passed",
|
||||
"shellcheck": "passed",
|
||||
"godotPilot": "passed",
|
||||
"adversarialReview": "passed-after-fixes"
|
||||
},
|
||||
"openGates": [
|
||||
"public-package-restore",
|
||||
"representative-external-nat"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
# Game integration seams
|
||||
|
||||
Tracking: #20
|
||||
|
||||
Use the [TestClient start-to-finish guide](test-client.md) before integrating a
|
||||
game. It proves the service and network path without engine or game code. This
|
||||
page documents only the seams that the diagnostic cannot choose for a game:
|
||||
package/version policy, ownership of the gameplay socket, host admission,
|
||||
metadata, credential custody, and deployment compatibility.
|
||||
|
||||
## Packages and compatibility
|
||||
|
||||
Consume `FinalFactory.Rendezvous.Client` and
|
||||
`FinalFactory.Rendezvous.Contracts` from the approved Gitea NuGet source and pin
|
||||
both to the same exact released version. Do not use a floating version range.
|
||||
The current release matrix is machine-readable in
|
||||
[`compatibility.json`](../releases/compatibility.json); the same window is
|
||||
available from authenticated `GET /v1/operator/status`.
|
||||
|
||||
The authoritative package feed is
|
||||
`https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json`. Add it to the
|
||||
consumer's `NuGet.config` and map only Rendezvous packages to it; retain the
|
||||
consumer's existing NuGet.org mapping for other dependencies:
|
||||
|
||||
```xml
|
||||
<packageSources>
|
||||
<add key="FinalFactory" value="https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="FinalFactory">
|
||||
<package pattern="FinalFactory.Rendezvous.*" />
|
||||
</packageSource>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
```
|
||||
|
||||
When the feed is anonymously readable, no reader credential is needed. If
|
||||
registry policy requires authentication, use the platform's NuGet credential
|
||||
provider or a protected per-user/CI NuGet configuration populated by the secret
|
||||
manager. Never put a registry token in the project file, repository,
|
||||
package-source URL, or `dotnet` command argument.
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FinalFactory.Rendezvous.Client" Version="1.0.0" />
|
||||
<PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
Run `dotnet restore`, then `dotnet list package --include-transitive` and verify
|
||||
that Client and Contracts resolve to the same exact version and LiteNetLib to the
|
||||
release matrix version before compiling the game.
|
||||
|
||||
Release 1.0.0 targets `netstandard2.1`, requires LiteNetLib `2.1.4`, speaks HTTP,
|
||||
UDP, and connection-ticket contract version `1`, and requires an exact
|
||||
tenant-configured gameplay protocol match. A package patch does not silently
|
||||
change a wire version. Follow the [release and migration policy](../releases/README.md)
|
||||
when changing any dimension, and validate the generated
|
||||
[OpenAPI v1 document](../api/rendezvous-v1.json) rather than hand-building HTTP.
|
||||
|
||||
## One caller-owned gameplay socket
|
||||
|
||||
Create the game's LiteNetLib manager through `RendezvousNetListener`; do not open
|
||||
a separate NAT socket. The game owns start, stop, and disposal. A coordinator
|
||||
owns polling while it is active, so call its `Poll()` once from the game/network
|
||||
thread and do not also call `NetManager.PollEvents()` during that period.
|
||||
|
||||
```csharp
|
||||
RendezvousNetListener networkEvents = new();
|
||||
NetManager gameplayNetwork = networkEvents.CreateManager();
|
||||
gameplayNetwork.ChannelsCount = 3; // set the game's required count before Start
|
||||
if (!gameplayNetwork.Start(gameplayPort))
|
||||
{
|
||||
throw new InvalidOperationException("Gameplay UDP socket could not start.");
|
||||
}
|
||||
|
||||
using RendezvousHostCoordinator host = new(
|
||||
gameplayNetwork,
|
||||
networkEvents,
|
||||
mediatorEndPoint,
|
||||
publishedSession,
|
||||
joinClient);
|
||||
|
||||
host.Poll(); // call each game frame while this coordinator owns polling
|
||||
```
|
||||
|
||||
LiteNetLib defaults to one QoS channel. Set `ChannelsCount` before `Start` when
|
||||
the game protocol uses additional channels; both peers must configure the same
|
||||
count. Rendezvous does not choose, remap, or reserve a gameplay channel.
|
||||
|
||||
Register normal game callbacks on `networkEvents.GameplayEvents`. Rendezvous
|
||||
reserves only its authenticated direct requests and forwards other callbacks.
|
||||
The same socket sends host presence, punches through the mediator, establishes
|
||||
the peer, and then carries gameplay. A NAT introduction is not success; accept a
|
||||
peer only after the coordinator reports the typed `Connected` outcome.
|
||||
|
||||
`Poll()` does not fetch new invitations. Schedule
|
||||
`RefreshJoinAttemptsAsync` repeatedly for the entire hosting lifetime using a
|
||||
bounded caller-owned timer (the diagnostic uses 250 ms), never allow two refreshes
|
||||
to overlap, and inspect each typed result. The refresh performs HTTP work and
|
||||
queues a snapshot; it does not call the LiteNetLib manager. Continue calling
|
||||
`Poll()` on the manager's owning thread so the queued snapshot, presence traffic,
|
||||
and callbacks are processed. Run the lease maintainer concurrently and cancel
|
||||
both loops before disposing the coordinator.
|
||||
|
||||
For example, start one sequential refresh loop when hosting begins and await it
|
||||
during shutdown:
|
||||
|
||||
```csharp
|
||||
static async Task RefreshInvitationsAsync(
|
||||
RendezvousHostCoordinator host,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using PeriodicTimer timer = new(TimeSpan.FromMilliseconds(250));
|
||||
do
|
||||
{
|
||||
RendezvousClientResult<int> result =
|
||||
await host.RefreshJoinAttemptsAsync(cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
ObserveBoundedHostRefreshFailure(result.Error);
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken));
|
||||
}
|
||||
```
|
||||
|
||||
On the joining side, create an attempt through `RendezvousJoinClient`, then give
|
||||
the issued attempt to `RendezvousClientCoordinator` using the same manager and
|
||||
listener. Cancellation, outcome reporting, bounded deadlines, fallback, and
|
||||
lease-maintainer examples are in the packaged
|
||||
[`FinalFactory.Rendezvous.Client` README](../../src/FinalFactory.Rendezvous.Client/README.md).
|
||||
|
||||
## Host admission remains game-owned
|
||||
|
||||
The coordinator privately validates and consumes the signed one-time connection
|
||||
ticket before accepting the LiteNetLib transport request. Do not create a second
|
||||
`ConnectionTicketValidator` beside it: the coordinator deliberately does not
|
||||
expose the expected or presented ticket. A connected transport proves only that
|
||||
Rendezvous authorized one attempt; it does not prove player identity,
|
||||
entitlement, capacity, ban status, or gameplay compatibility.
|
||||
|
||||
Treat `AttemptCompleted` with a successful outcome and non-null `Peer` as the
|
||||
start of game-owned admission. Keep that peer outside authoritative gameplay
|
||||
until the game's normal authentication and admission exchange succeeds; disconnect
|
||||
it on rejection or timeout:
|
||||
|
||||
```csharp
|
||||
host.AttemptCompleted += (_, completed) =>
|
||||
{
|
||||
if (!completed.Outcome.IsSuccess || completed.Peer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BeginBoundedGameAuthentication(
|
||||
completed.Peer,
|
||||
onAccepted: AdmitToAuthoritativeGameplay,
|
||||
onRejected: peer => peer.Disconnect());
|
||||
};
|
||||
```
|
||||
|
||||
Revoke an attempt when the game cancels it. Never log a ticket or capability. A
|
||||
successful Rendezvous check must not bypass the game's authentication or
|
||||
authoritative server rules. `ConnectionTicketValidator` is a lower-level
|
||||
primitive for a custom transport integration that owns the complete request
|
||||
acceptance path; it is not an extra gate for `RendezvousHostCoordinator`.
|
||||
|
||||
## Provision each game and environment
|
||||
|
||||
Provision game/environment scope before issuing credentials. The policy fixes
|
||||
enabled regions, exact gameplay protocols, visibility and publisher trust modes,
|
||||
metadata schema and byte budgets, quotas, and whether a dedicated fallback may
|
||||
be published. Unknown or disabled scope fails closed. Follow
|
||||
[game provisioning and signing-key lifecycle](../security/provisioning.md) for
|
||||
the complete schema, principal kinds, secret providers, overlap, and revocation.
|
||||
|
||||
Dedicated publisher credentials belong only on trusted hosting infrastructure.
|
||||
Never ship one in a player build, repository, image layer, appsettings file, URL,
|
||||
argument, log, crash report, or analytics event. Issue a short-lived credential
|
||||
scoped to one game/environment and its allowed regions from the trusted
|
||||
deployment boundary. Player-host grants are issued to an authenticated player
|
||||
session at runtime and are never embedded in the build. Player-host grants,
|
||||
dedicated publishers, anonymous unlisted hosts, and operators are separate
|
||||
principal kinds; do not interchange them.
|
||||
|
||||
Rotate signing keys with an overlap:
|
||||
|
||||
1. install a new authorized key inside its `NotBefore`/`SignUntil` window;
|
||||
2. begin issuing with it while the old key remains verify-only;
|
||||
3. wait at least the maximum credential lifetime plus allowed clock skew;
|
||||
4. retire the old verifier after `VerifyUntil` and preserve custody records.
|
||||
|
||||
A suspected compromise is not routine rotation: stop issuance, revoke the exact
|
||||
key through the protected operator route, remove or replace it in provisioning,
|
||||
invalidate affected credentials, and follow the
|
||||
[key-compromise runbook](../operations/incident-runbooks.md#signing-key-or-issuer-compromise).
|
||||
|
||||
## Metadata is public and policy-owned
|
||||
|
||||
Treat listing metadata as untrusted public input. Define a small allowlist in
|
||||
each provisioned game's `MetadataValueMaxBytes`, set `RequiredMetadataKeys`, and
|
||||
keep `MetadataMaxKeys` and `MetadataMaxBytes` to the smallest useful values. Values
|
||||
must be display data only—for example a bounded map or ruleset identifier. Never
|
||||
publish player identity, free-form chat, secrets, access tokens, internal
|
||||
addresses, world state, or data needed for authoritative gameplay.
|
||||
|
||||
The platform contract caps metadata at 32 keys, 256 UTF-8 bytes per value, and
|
||||
4096 encoded bytes total; tenant policy can and should be smaller. Build version
|
||||
and display name are separately bounded public fields. Games must escape metadata
|
||||
for their UI and must not infer trust from a listing being present.
|
||||
|
||||
## Local, staging, and production path
|
||||
|
||||
Use the checked-in Compose profile only for the local TestClient guide. For a
|
||||
real environment:
|
||||
|
||||
1. provision the game/environment policy and externally held signing keys;
|
||||
2. deploy one active service behind the source-preserving HTTPS/UDP topology in
|
||||
[secure single-active Linux deployment](../deployment/linux.md);
|
||||
3. install matching exact package versions in the game and set its service and
|
||||
mediator endpoints through environment-specific configuration;
|
||||
4. pass the TestClient health/publish/browse/punch/direct-traffic smoke using a
|
||||
short-lived diagnostic credential;
|
||||
5. run the topology harness and representative consumer-network trials; and
|
||||
6. monitor typed outcomes and bounded metrics before broad rollout.
|
||||
|
||||
Rendezvous v1 has no relay, account system, matchmaking engine, server-browser
|
||||
UI, gameplay authority, or durable session database. A game owns player-facing
|
||||
recovery and an explicit fallback. Do not describe direct traversal as guaranteed.
|
||||
@@ -0,0 +1,112 @@
|
||||
# SpaceGame consumer pilot
|
||||
|
||||
Tracking: Rendezvous #21 and SpaceGame #3.
|
||||
|
||||
The current SpaceGame checkpoint proves that the v1 client boundary establishes
|
||||
authenticated direct LiteNetLib traffic without taking ownership of the game's
|
||||
protocol, admission, player identity, entity identity, capacity, lifecycle, or
|
||||
gameplay payloads. Real Godot processes, reconnect, an explicit dedicated
|
||||
fallback, and a fresh Linux export now pass. The public package restore and a
|
||||
representative external NAT/CGNAT canary remain required before #21 can close.
|
||||
|
||||
## Pinned checkpoint
|
||||
|
||||
| Input | Value |
|
||||
| --- | --- |
|
||||
| Rendezvous compatibility source | `ebb5eb617c0bbb170418afab396b68584b7f992e` plus the current #21 configuration/evidence changes |
|
||||
| Rendezvous package source | `07004cd75fe172aa5dfdb3edda22fc280a4c4477` |
|
||||
| SpaceGame source | `f3f5bc29810c362656cd7143bec1ddc2cfaf9f22` |
|
||||
| Client package | `FinalFactory.Rendezvous.Client` `1.0.0` |
|
||||
| Contracts package | `FinalFactory.Rendezvous.Contracts` `1.0.0` |
|
||||
| LiteNetLib | `2.1.4` |
|
||||
| HTTP, UDP, ticket contracts | `1` |
|
||||
| SpaceGame gameplay protocol | `2` |
|
||||
|
||||
At the checkpoint date, the Final Factory Gitea NuGet service was reachable but
|
||||
both `FinalFactory.Rendezvous.*` `1.0.0` registrations returned HTTP 404. The run
|
||||
therefore restored locally built candidate packages with the hashes recorded in
|
||||
[`spacegame.json`](../evidence/consumers/spacegame.json). This proves candidate
|
||||
compatibility, not immutable registry publication. The release package restore
|
||||
must be repeated from the public feed.
|
||||
|
||||
## Proven local path
|
||||
|
||||
The SpaceGame host and client each create one caller-owned `NetManager`, set its
|
||||
three gameplay QoS channels before `Start`, and give the same manager and
|
||||
`RendezvousNetListener` to the coordinator. Rendezvous authenticates discovery,
|
||||
join authorization, host presence, mediation, and connection outcome reporting.
|
||||
After traversal, SpaceGame performs a separate audience-bound admission exchange
|
||||
on its own reliable command channel. A trusted game-auth boundary mints the
|
||||
opaque assertion; the player process never receives the signing key.
|
||||
|
||||
The authoritative host rejects expired, replayed, incorrectly signed,
|
||||
wrong-listing, duplicate-player, over-capacity, identity-mismatched,
|
||||
out-of-sequence, and over-rate traffic. It assigns a canonical game entity ID
|
||||
only after admission. The player ID, entity ID, listing ID, join-attempt ID, and
|
||||
LiteNetLib peer ID remain distinct values.
|
||||
|
||||
The bounded real-process harnesses observed:
|
||||
|
||||
- host publication and lease maintenance;
|
||||
- browser compatibility filtering and join authorization;
|
||||
- typed traversal outcome `Connected`;
|
||||
- successful audience-bound game admission;
|
||||
- reliable ordered frame-definition and spawn lifecycle records, reliable
|
||||
ordered input, and sequenced state snapshots on the caller-owned gameplay
|
||||
socket;
|
||||
- disconnect and a new authenticated session for the same durable player while
|
||||
LiteNetLib peers and canonical entity IDs change;
|
||||
- immediate host lease renewal and successful host deregistration;
|
||||
- a fresh optimized Linux export running the host and client in distinct
|
||||
hardened container namespaces; and
|
||||
- a forced punch timeout that connects the isolated client to an explicitly
|
||||
advertised, non-loopback Docker-gateway fallback and repeats game admission.
|
||||
|
||||
Rendezvous exposes no gameplay relay API; all lifecycle, command, and snapshot
|
||||
bytes are sent by SpaceGame through its caller-owned `NetManager`. Both Debug
|
||||
and Release builds passed. Both Debug and Release test runs passed 31 tests with
|
||||
zero failures. ExportRelease is optimized with debug symbols removed. The
|
||||
focused formatter, shell checker, fresh-export provenance gate, clean
|
||||
candidate-package restore, and adversarial branch review also passed.
|
||||
|
||||
## Failure evidence
|
||||
|
||||
| Path | Evidence | Status |
|
||||
| --- | --- | --- |
|
||||
| Incompatible protocol | protocol `999` returns no compatible listing and starts no traversal | Proven |
|
||||
| Stale/no host presence | typed `NoHostPresence/RendezvousService/HostPresence/Mediation` | Proven |
|
||||
| Traversal timeout | non-listening mediator produces typed `PunchTimedOut/LocalTraversal/NatTraversal/NatTraversal` | Proven |
|
||||
| Rejected game admission | invalid signature denies gameplay in the process matrix; wrong audience, expiry, and replay are regression-tested | Proven |
|
||||
| Capacity and duplicate player | game-owned roster rejects both and publishes current capacity | Regression-tested |
|
||||
| Configured fallback | typed `PunchTimedOut`, explicit non-loopback endpoint, same game admission, direct gameplay | Proven locally and across Linux namespaces |
|
||||
| Disconnect | host observes zero active players and final admitted count zero | Proven |
|
||||
| Reconnect | same durable player enters a second authenticated session with new peer/entity IDs | Proven |
|
||||
|
||||
## Rendezvous-side compatibility fixes
|
||||
|
||||
The pilot found generic integration gaps and keeps their fixes in this
|
||||
repository:
|
||||
|
||||
- the local production-shaped smoke tenant accepts SpaceGame gameplay protocol
|
||||
`2` and the bounded `mode` metadata key;
|
||||
- the Compose smoke tenant explicitly allows its private-network service name
|
||||
and enables only the dedicated-endpoint fallback policy;
|
||||
- SDK guidance requires games using multiple LiteNetLib QoS channels to set
|
||||
`ChannelsCount` before `Start` and states that Rendezvous reserves no gameplay
|
||||
channel; and
|
||||
- the local credential helper rejects any signing-key file with group or other
|
||||
permissions, in addition to its ownership, symlink, and hard-link checks.
|
||||
|
||||
Documentation contract tests cover these generic requirements.
|
||||
|
||||
## Remaining acceptance gates
|
||||
|
||||
Do not mark #21 passed until both remaining external gates have direct evidence:
|
||||
|
||||
1. restore the exact immutable `1.0.0` packages from the public Gitea feed; and
|
||||
2. run representative external NAT/CGNAT canaries and record the network
|
||||
topology and typed outcome.
|
||||
|
||||
SpaceGame #3 remains open independently for the production ENet replacement,
|
||||
64/128-player profiles, and SIGTERM/drain/save evidence. The consumer pilot
|
||||
does not claim those broader game-migration gates.
|
||||
+193
-68
@@ -1,97 +1,222 @@
|
||||
# Diagnostic TestClient integration guide
|
||||
# Start-to-finish TestClient guide
|
||||
|
||||
Tracking: #25
|
||||
Tracking: #20, #25
|
||||
|
||||
`FinalFactory.Rendezvous.TestClient` is the smallest supported public-SDK consumer.
|
||||
It exists for integration development, CI smoke checks, deployment verification,
|
||||
and operator diagnosis. It is intentionally not a production game client, game
|
||||
server, matchmaking UI, or relay.
|
||||
`FinalFactory.Rendezvous.TestClient` is the supported executable proof that a
|
||||
consumer can publish, browse, authorize, punch, connect, exchange direct traffic,
|
||||
and diagnose a failure using only the public Client and Contracts packages. It is
|
||||
intentionally thin: a polished server browser and player-facing connection UI
|
||||
belong in each game repository.
|
||||
|
||||
The automated scenario matrix, privileged Linux namespace run, and topology
|
||||
limitations are documented in the [deterministic topology harness](topology-harness.md).
|
||||
> **Traversal boundary:** Rendezvous v1 is not a relay and cannot guarantee a
|
||||
> connection through symmetric NAT, carrier-grade NAT, restrictive firewalls,
|
||||
> VPNs, or platform policy. It provides no accounts, social system, skill-based
|
||||
> matchmaking, gameplay server, gameplay authority, or gameplay transport.
|
||||
|
||||
## Prerequisites
|
||||
The automated scenario matrix, privileged Linux namespace run, and simulation
|
||||
limits are in the [deterministic topology harness](topology-harness.md). The
|
||||
[SDK seam guide](sdk-seams.md) covers the few integration details that this
|
||||
executable cannot show.
|
||||
|
||||
Start a configured Rendezvous service and note both its HTTP base URL and UDP
|
||||
mediator endpoint. The host needs a tenant-scoped publisher credential from the
|
||||
deployment secret boundary. Put it in an environment variable and pass only that
|
||||
variable's name when the default is unsuitable:
|
||||
## First local connection from a clean checkout
|
||||
|
||||
Prerequisites are the pinned .NET SDK, Docker with Compose, OpenSSL, Python 3,
|
||||
`curl`, and `jq`. Run these commands from the repository root. The generated key
|
||||
and credential are disposable local fixtures, not production provisioning.
|
||||
|
||||
```bash
|
||||
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<deployment-supplied value>'
|
||||
install -d -m 0700 deploy/compose/secrets
|
||||
umask 077
|
||||
openssl rand -out deploy/compose/secrets/signing-key 32
|
||||
export RENDEZVOUS_UID="$(id -u)"
|
||||
export RENDEZVOUS_GID="$(id -g)"
|
||||
test "$RENDEZVOUS_UID" -ne 0
|
||||
docker compose -f deploy/compose/compose.yaml up --build --detach
|
||||
ready=false
|
||||
for attempt in {1..45}; do
|
||||
if curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
test "$ready" = true
|
||||
curl --fail http://127.0.0.1:8080/health/live
|
||||
curl --fail http://127.0.0.1:8080/health/ready
|
||||
dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
|
||||
```
|
||||
|
||||
Never put the credential in a command argument, URL, checked-in configuration,
|
||||
shell trace, or captured test fixture. The development server's signing material
|
||||
is process-ephemeral; credentials from a prior development process are invalid.
|
||||
|
||||
## Manual three-terminal flow
|
||||
|
||||
Start the host:
|
||||
Only the host terminal needs a publisher credential. Disable shell tracing before
|
||||
capturing it; the helper prints the credential on stdout so command substitution
|
||||
can place it directly in the environment without writing it to disk.
|
||||
|
||||
```bash
|
||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
|
||||
--game space-game --environment development --region local --protocol 1
|
||||
set +x
|
||||
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$(./scripts/mint-local-publisher-credential.sh)"
|
||||
```
|
||||
|
||||
Browse from another terminal:
|
||||
The helper accepts no arguments, reads the ignored `0600` local Compose key, and
|
||||
mints only `space-game` / `smoke` / `local` / protocol `1` for ten minutes. It is
|
||||
not a reusable issuer or an example for production. Never put the result in a
|
||||
command argument, URL, shell history, log, screenshot, support ticket, captured
|
||||
fixture, or source file.
|
||||
|
||||
In terminal 1, publish a host. It stays alive for at most 60 seconds and exits
|
||||
after a joining peer completes the authenticated echo exchange:
|
||||
|
||||
```bash
|
||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||
browse --service http://127.0.0.1:5000/ \
|
||||
--game space-game --environment development --region local --protocol 1
|
||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||
--configuration Release --no-build -- \
|
||||
host --service http://127.0.0.1:8080/ --mediator 127.0.0.1:9050 \
|
||||
--game space-game --environment smoke --region local --protocol 1 \
|
||||
--display-name "Local diagnostic" --timeout-seconds 60 --run-seconds 60 \
|
||||
--exit-after-echo
|
||||
```
|
||||
|
||||
Join from a third terminal. Omit `--listing` for an interactive choice:
|
||||
Copy the public listing ID printed by the host, or discover it from terminal 2:
|
||||
|
||||
```bash
|
||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||
join --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
|
||||
--game space-game --environment development --region local --protocol 1 \
|
||||
--listing 00000000-0000-0000-0000-000000000000
|
||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||
--configuration Release --no-build -- \
|
||||
browse --service http://127.0.0.1:8080/ \
|
||||
--game space-game --environment smoke --region local --protocol 1
|
||||
```
|
||||
|
||||
Replace the sample UUID with the public listing ID printed by host or browse.
|
||||
Host and join each create one caller-owned LiteNetLib manager. That same socket
|
||||
sends presence/punch traffic, establishes the authenticated direct connection,
|
||||
and carries the ping/echo/ack/completion payload. The final completion confirms
|
||||
that the host received the reliable acknowledgement; none of this traffic passes through the HTTP
|
||||
service or UDP mediator.
|
||||
In terminal 3, either omit `--listing` and select interactively, or provide the
|
||||
copied ID for deterministic selection:
|
||||
|
||||
## CI and deployment smoke flow
|
||||
```bash
|
||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||
--configuration Release --no-build -- \
|
||||
join --service http://127.0.0.1:8080/ --mediator 127.0.0.1:9050 \
|
||||
--game space-game --environment smoke --region local --protocol 1 \
|
||||
--listing REPLACE_WITH_LISTING_UUID --timeout-seconds 30
|
||||
```
|
||||
|
||||
Use `--script --json`, set `--listing` when deterministic selection matters, and
|
||||
check the documented process exit code. `--timeout-seconds` bounds each startup,
|
||||
traversal, or direct-traffic stage; a script host also uses it as its total runtime
|
||||
unless `--run-seconds` is explicit. A host can add `--exit-after-echo` so it
|
||||
terminates after the joining peer acknowledges direct traffic and receives the
|
||||
host's completion confirmation. Every wait is
|
||||
bounded by coordinator state and `--timeout-seconds`; no orchestration should use
|
||||
an unbounded sleep.
|
||||
Success means the joiner prints `join.connected` and verified direct traffic,
|
||||
and the host prints verified direct traffic before deregistering. The host and
|
||||
joiner each create one caller-owned LiteNetLib manager. The same UDP socket sends
|
||||
presence and punch traffic, accepts the authenticated peer, and carries the
|
||||
ping/echo/ack/completion payload; direct traffic does not pass through the HTTP
|
||||
service or mediator.
|
||||
|
||||
The normal test suite contains a real process gate that starts the built Server,
|
||||
host TestClient, and join TestClient, waits for readiness and versioned events,
|
||||
and verifies direct traffic, cleanup, JSON shape, and secret canaries. Process
|
||||
trees are force-terminated in the test cleanup path if normal shutdown fails.
|
||||
Clean up secrets and the disposable service when finished:
|
||||
|
||||
Useful success events are:
|
||||
```bash
|
||||
unset RENDEZVOUS_PUBLISHER_CREDENTIAL
|
||||
docker compose -f deploy/compose/compose.yaml down
|
||||
rm deploy/compose/secrets/signing-key
|
||||
```
|
||||
|
||||
- `host.registered`, `host.ready`, `host.direct-traffic`, and `host.deregistered`;
|
||||
- `browse.completed` and `browse.session`; and
|
||||
- `join.connected`, `join.direct-traffic`, and `join.outcome-report`.
|
||||
## Script and JSON automation
|
||||
|
||||
Failure events preserve stable typed phases and outcomes. When a terminal outcome
|
||||
contains a configured dedicated endpoint, `join.fallback` reports `available`
|
||||
with endpoint type `dedicated`; no raw address is printed and no fallback is
|
||||
started implicitly.
|
||||
`--script` forbids prompts and selects the first compatible listing unless
|
||||
`--listing UUID` fixes the choice. `--json` emits one JSON object per line with
|
||||
`version: 1`. New optional properties may be added, but event names and exit
|
||||
codes are stable automation contracts. Informational events use stdout and
|
||||
failures use stderr.
|
||||
|
||||
## What the proof does and does not establish
|
||||
The deployment smoke performs the full health, publish, join, mediation, direct
|
||||
traffic, outcome-report, and cleanup flow using bounded waits:
|
||||
|
||||
The deterministic loopback test proves the complete service/host/client protocol,
|
||||
ticket admission, and peer-to-peer payload path. Loopback is not evidence that all
|
||||
consumer routers, carrier-grade NATs, symmetric NATs, firewalls, VPNs, IPv6 paths,
|
||||
or platform policies permit hole punching. Same-LAN, separated observed endpoints,
|
||||
network namespaces/containers, mediator restart, and adverse topology coverage
|
||||
belong to the topology harness tracked by #14. Production rollout still requires
|
||||
tests from representative networks and a game-owned fallback policy.
|
||||
```bash
|
||||
dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
|
||||
./scripts/smoke-deployment.sh
|
||||
```
|
||||
|
||||
For custom automation, capture JSON and preserve the process status separately:
|
||||
|
||||
```bash
|
||||
set +e
|
||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||
--configuration Release --no-build -- \
|
||||
browse --service http://127.0.0.1:8080/ \
|
||||
--game space-game --environment smoke --region local --protocol 1 \
|
||||
--script --json >browse.jsonl
|
||||
status=$?
|
||||
set -e
|
||||
jq -e 'select(.version == 1 and .event == "browse.completed")' browse.jsonl
|
||||
test "$status" -eq 0
|
||||
```
|
||||
|
||||
Never use an unbounded sleep to orchestrate processes. Wait for versioned events
|
||||
such as `host.ready` and apply a deadline. Useful success events are
|
||||
`host.registered`, `host.ready`, `host.direct-traffic`, `host.deregistered`,
|
||||
`browse.completed`, `browse.session`, `join.connected`, `join.direct-traffic`,
|
||||
and `join.outcome-report`.
|
||||
|
||||
| Exit | Meaning |
|
||||
| ---: | --- |
|
||||
| `0` | Requested diagnostic flow completed successfully |
|
||||
| `2` | Invalid command or options |
|
||||
| `3` | Missing or invalid local configuration |
|
||||
| `10` | HTTP, registration, browser, lease, or socket failure |
|
||||
| `11` | No compatible session was available or selected |
|
||||
| `12` | Authorization or traversal reached a typed terminal failure |
|
||||
| `13` | Direct connection succeeded but the direct traffic proof failed |
|
||||
| `130` | Caller cancellation or Ctrl+C |
|
||||
|
||||
## Observe a safe failure
|
||||
|
||||
Run this after the protocol-1 browse in terminal 2 and before the terminal-3
|
||||
join (or restart terminal 1 first). The preceding browse proves that one
|
||||
protocol-1 host is present. Now browse for deliberately incompatible protocol
|
||||
`999`. The command emits a successful directory response with
|
||||
`browse.completed`, `count: 0`, then exits `11` to distinguish compatibility
|
||||
from a service outage:
|
||||
|
||||
```bash
|
||||
set +e
|
||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||
--configuration Release --no-build -- \
|
||||
browse --service http://127.0.0.1:8080/ \
|
||||
--game space-game --environment smoke --region local --protocol 999 \
|
||||
--script --json >incompatible.jsonl
|
||||
status=$?
|
||||
set -e
|
||||
jq -e 'select(.event == "browse.completed" and .phase == "directory" and .count == 0)' \
|
||||
incompatible.jsonl
|
||||
test "$status" -eq 11
|
||||
```
|
||||
|
||||
This is a diagnostic failure drill, not a bypass: unknown tenant scope and
|
||||
protocols still fail closed, and the local helper cannot mint a credential for
|
||||
them.
|
||||
|
||||
## Diagnose by phase, not by guesswork
|
||||
|
||||
Start with the exit code, then the last versioned event and its `phase`, `status`,
|
||||
and typed `outcome`. Endpoint categories may be
|
||||
reported as `loopback`, `private`, or `public`; raw endpoints, credentials,
|
||||
capabilities, metadata, and player identities are never emitted.
|
||||
|
||||
| Symptom or last event | Distinction | Check next |
|
||||
| --- | --- | --- |
|
||||
| `host.configuration`, exit `3` | Local credential variable is missing or malformed before any request | Confirm the named environment variable exists, tracing is off, and the credential has not expired |
|
||||
| `host.registration`, exit `10` | Publisher authentication, tenant policy, metadata, quota, or HTTP failure | Use the typed status; compare credential scope with game/environment/region and the provisioned policy, then correlate protected server telemetry by operation and time |
|
||||
| `browse.sessions`, exit `10` | Directory request failed | Check HTTP reachability, `/health/ready`, rate limiting, and contract compatibility |
|
||||
| `browse.completed` count `0`, or `join.selection` empty, exit `11` | Healthy directory but no compatible visible listing | Match game, environment, region, and exact gameplay protocol; then confirm a host lease is still active |
|
||||
| Exact `join.selection` failure, exit `10` | Listing disappeared, is hidden, or scope no longer matches | Browse again; do not retry an old listing ID forever |
|
||||
| `join.authorization`, exit `12` | Service rejected the attempt before NAT traversal | Inspect typed category/outcome for policy, capacity, stale host, or active-attempt limits |
|
||||
| `join.punch` / `join.traversal`, exit `12` | Mediation or NAT traversal did not establish a peer | Confirm UDP endpoint/reply path, host presence, clocks, firewall/NAT behavior, and topology; use a game-owned fallback if policy supplies one |
|
||||
| `join.direct-connect`, exit `12` | Introduction occurred but authenticated direct admission failed | Confirm host is polling the same socket, the one-time ticket is current, and game admission did not reject capacity, identity, or bans |
|
||||
| `join.connected` followed by exit `13` | Peer connected but the direct gameplay-like echo did not finish | Inspect the peer lifecycle and caller polling; this is not an HTTP/directory failure |
|
||||
|
||||
Stopping a host without deregistration may leave its listing visible only until
|
||||
the bounded lease expires. During that window, a join can produce a typed stale
|
||||
host or traversal outcome; it must not be interpreted as a healthy host. Restarting
|
||||
the single-active service intentionally loses all ephemeral listings and attempts,
|
||||
so hosts re-register and clients browse again.
|
||||
|
||||
If a terminal outcome reports an authoritative dedicated fallback,
|
||||
`join.fallback` exposes only availability and endpoint type. TestClient never
|
||||
connects to it automatically. The game owns the decision, authentication, and
|
||||
connection policy. If no fallback is present, Rendezvous v1 offers no relay.
|
||||
|
||||
## Production use
|
||||
|
||||
Do not copy a production signing key to a diagnostic host. Supply a short-lived,
|
||||
least-scope publisher credential from the deployment secret boundary and set the
|
||||
external service, mediator, and matching scope variables described in the
|
||||
[secure Linux deployment smoke](../deployment/linux.md#http-and-udp-smoke).
|
||||
Run representative external-network tests; loopback success is not NAT coverage.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Unscouted consumer pilot
|
||||
|
||||
Tracking: Rendezvous #22 and Unscouted #459.
|
||||
|
||||
The current checkpoint independently proves that the v1 contracts are not
|
||||
shaped only around SpaceGame. A real Godot Unscouted host and clients consume
|
||||
the same Client and Contracts package surface, use one caller-owned LiteNetLib
|
||||
socket for NAT callbacks and gameplay, perform Unscouted's own keypair
|
||||
authentication and host admission, exchange gameplay, and exercise a
|
||||
game-owned fallback. The public package restore and representative external
|
||||
NAT/CGNAT canary remain required before #22 can close.
|
||||
|
||||
## Pinned checkpoint
|
||||
|
||||
| Input | Value |
|
||||
| --- | --- |
|
||||
| Rendezvous configuration source | `f368fec6eb4344a6042974f58f888cf0f1ac8e8e` |
|
||||
| Rendezvous package source | `07004cd75fe172aa5dfdb3edda22fc280a4c4477` |
|
||||
| Unscouted implementation | `1e5886aa7f1e44689b4c75e32693eb7b19fd72d7` |
|
||||
| Unscouted evidence | `f0574a7de82aadff6495ca5657dfc19cf7c2f67c` |
|
||||
| Client package | `FinalFactory.Rendezvous.Client` `1.0.0` |
|
||||
| Contracts package | `FinalFactory.Rendezvous.Contracts` `1.0.0` |
|
||||
| LiteNetLib | `2.1.4` |
|
||||
| Godot | `4.7.stable.mono.arch_linux.5b4e0cb0f` |
|
||||
| Game / environment / region | `unscouted` / `smoke` / `local` |
|
||||
| Rendezvous and gameplay protocol | `1` |
|
||||
|
||||
The exact package hashes are recorded in
|
||||
[`unscouted.json`](../evidence/consumers/unscouted.json). A clean restore into an
|
||||
empty package directory using only the consumer's checked-in `NuGet.config`
|
||||
returns `NU1101` for both packages. The verified local run used those exact
|
||||
candidate package files from the existing cache. This proves compatibility,
|
||||
not immutable registry publication.
|
||||
|
||||
## Game-neutral service boundary
|
||||
|
||||
Rendezvous #22 adds provisioning data, not an Unscouted branch in the server or
|
||||
SDK. The local production-shaped tenant permits protocol `1`, region `local`,
|
||||
public managed-dedicated listings, and the three bounded presentation keys
|
||||
`mode`, `world`, and `mods`. The short-lived credential helper accepts only the
|
||||
explicitly provisioned `space-game` and `unscouted` scopes and selects a
|
||||
distinct game-scoped signing-key ID and subject.
|
||||
|
||||
The consumer rejects any metadata key outside its three-key presentation
|
||||
schema and neutralizes control/BBCode characters before display. Rendezvous
|
||||
never receives Unscouted player keys or resolved identities, colony authority,
|
||||
simulation or persistence state, fog/interest state, or gameplay packets.
|
||||
|
||||
## Proven real Godot path
|
||||
|
||||
The normal `NetLaunch` argument path recognizes `--rendezvous-pilot` and opens a
|
||||
dedicated scene. That scene uses Unscouted's real `LiteNetLibTransport`,
|
||||
`GameServer`, `GameClient`, `ServerAuthenticator`, and `ClientAuthenticator`.
|
||||
It is not a copied SDK adapter.
|
||||
|
||||
One bounded run against the hardened Compose service started a host plus:
|
||||
|
||||
- a protocol-`999` client that found no compatible listing;
|
||||
- a direct client that received an authorized introduction, completed
|
||||
same-socket traversal, passed Unscouted keypair admission, and exchanged an
|
||||
Unscouted gameplay ping/pong; and
|
||||
- a client pointed at a non-listening mediator that received a typed traversal
|
||||
failure, applied the fallback decision in Unscouted code, repeated admission,
|
||||
and exchanged the same gameplay ping/pong through the ordinary game
|
||||
transport.
|
||||
|
||||
The direct client also proved that both a `space-game` join request and a
|
||||
`production` environment join request return exact `NotFound` results for the
|
||||
Unscouted listing. The host renewed its lease, admitted two independently
|
||||
authenticated sessions, completed two gameplay exchanges, and deregistered the
|
||||
listing on shutdown.
|
||||
|
||||
## Verification
|
||||
|
||||
- Rendezvous Debug and Release: 299 tests passed in each configuration, zero
|
||||
failures.
|
||||
- Unscouted Debug and Release: non-incremental builds passed; 3,310 tests passed
|
||||
with 15 intentional skips in each configuration.
|
||||
- Unscouted gdUnit/Godot: 360 tests passed, zero skipped or failed. The harness
|
||||
fix in Unscouted #461 keeps compilation headless and leaves the open editor's
|
||||
build tree unchanged.
|
||||
- The final Godot pilot, ShellCheck, JSON/whitespace checks, formatting gate,
|
||||
and adversarial branch review passed.
|
||||
- Export is not applicable because the Unscouted checkout has no
|
||||
`export_presets.cfg`; both C# configurations and the actual Godot entry point
|
||||
were exercised.
|
||||
|
||||
## Remaining acceptance gates
|
||||
|
||||
Do not mark #22 passed until both external gates have direct evidence:
|
||||
|
||||
1. publish or expose the exact immutable `1.0.0` packages on the configured
|
||||
Gitea feed and repeat the empty-cache consumer restore; and
|
||||
2. run the same Godot host/client path across representative residential,
|
||||
CGNAT, and IPv6/multi-host networks, recording the topology and typed
|
||||
direct/fallback outcome.
|
||||
|
||||
The loopback run proves the real process, socket, authentication, and gameplay
|
||||
shape. It does not claim production Internet traversal coverage.
|
||||
@@ -0,0 +1,339 @@
|
||||
# Incident and change runbooks
|
||||
|
||||
Tracking: #20
|
||||
|
||||
These runbooks supplement the [signal and operator reference](observability-and-operator-runbook.md).
|
||||
Every procedure has four explicit gates: detect, contain, recover, and verify.
|
||||
Record timestamps, the release digest, bounded aggregates, audit fingerprints,
|
||||
and `X-Rendezvous-Correlation-ID` values. Never copy credentials, capabilities,
|
||||
connection tickets, signing material, player identity, raw IP addresses,
|
||||
endpoints, listing metadata, or full request bodies into an incident record.
|
||||
|
||||
Operator routes must be reachable only from an allowed management source. Use a
|
||||
short-lived, least-permission operator credential minted outside Rendezvous.
|
||||
Pass it to an approved operator client through protected stdin or a secret agent,
|
||||
not a URL, command argument, environment-wide process launcher, shell trace, or
|
||||
ticket. All request shapes and responses are defined by the generated
|
||||
[OpenAPI v1 document](../api/rendezvous-v1.json).
|
||||
|
||||
Before an incident, keep these protected records available without depending on
|
||||
the affected service: current and previous image digests, matching configuration,
|
||||
key IDs and lifecycle windows (not raw key values), the game owner/on-call map,
|
||||
capacity baselines, collector destinations, and a separately authorized
|
||||
break-glass operator key. Test management-source allowlisting and credential
|
||||
permissions at least once per release.
|
||||
|
||||
Use the exact versioned action shapes below. Confirmation fields deliberately
|
||||
repeat the target so a stale UI selection or copy error fails closed. Responses
|
||||
do not echo targets.
|
||||
|
||||
| Operation | JSON body |
|
||||
| --- | --- |
|
||||
| `POST /v1/operator/listings/revoke` | `{"listingId":"<uuid>","confirmListingId":"<same uuid>"}` |
|
||||
| `POST /v1/operator/principals/revoke` | `{"subject":"<exact subject>","confirmSubject":"<same subject>","lifetimeSeconds":60}` |
|
||||
| `POST /v1/operator/keys/revoke` | `{"keyId":"<key id>","confirmKeyId":"<same key id>"}` |
|
||||
| `POST /v1/operator/drain` | `{"confirmation":"DRAIN"}` |
|
||||
|
||||
## Abuse or authentication spike
|
||||
|
||||
### Detect
|
||||
|
||||
- Alert on a baseline-relative increase in `rendezvous.limiter.drops`, HTTP/UDP
|
||||
request rate, `rendezvous.operator.authentication` rejected/forbidden results,
|
||||
registration requests by authentication status, queue depth, or p95/p99 latency.
|
||||
- Check `/health/live`, `/health/ready`, `rendezvous.store.available`, and
|
||||
authenticated `GET /v1/operator/status`. Separate public-source rejection,
|
||||
publisher credential failure, operator probing, and ordinary capacity growth.
|
||||
- Use only bounded operation/result dimensions and correlation IDs. Do not group
|
||||
by raw address, token, subject, listing ID, or metadata.
|
||||
|
||||
### Contain
|
||||
|
||||
- Preserve the dedicated operator partition. Do not raise public limits during
|
||||
an active spike. Apply source-preserving edge rate controls only when their
|
||||
collateral effect is understood and UDP source address/port remains intact.
|
||||
- For one abusive session, call `POST /v1/operator/listings/revoke` with identical
|
||||
`listingId` and `confirmListingId`. For a confirmed publisher subject, call
|
||||
`POST /v1/operator/principals/revoke` with identical `subject` and
|
||||
`confirmSubject` and a 1–600 second lifetime.
|
||||
- Revoke a signing key only when compromise evidence implicates that issuer;
|
||||
broad key revocation invalidates every credential signed by it. Drain only if
|
||||
the process itself must be isolated.
|
||||
|
||||
### Recover
|
||||
|
||||
- Correct the source integration, edge rule, leaked principal grant, or tenant
|
||||
budget under change control. Let a bounded principal revocation expire only
|
||||
after the owner confirms remediation; a repeated shorter revocation never
|
||||
shortens the original deadline.
|
||||
- Restore normal limits gradually. If saturation caused state churn, allow leases
|
||||
and attempts to expire naturally rather than deleting arbitrary state.
|
||||
|
||||
### Verify
|
||||
|
||||
- Require limiter drops, authentication result ratios, queue depth, latency, and
|
||||
direct-connect outcomes to return to the same-region baseline for the agreed
|
||||
observation window.
|
||||
- Confirm readiness stayed healthy or recovered, operator audit contains the
|
||||
intended action/result fingerprint, revoked resources cannot create new work,
|
||||
and unaffected tenants can still publish, browse, and connect.
|
||||
|
||||
## Signing key or issuer compromise
|
||||
|
||||
### Detect
|
||||
|
||||
- Treat secret-manager access alerts, unexpected issuance, credentials outside
|
||||
the expected region/kind, a signing-key expiry alarm, or unexplained publisher
|
||||
authentication growth as compromise until disproved.
|
||||
- Identify the non-secret key ID, allowed credential kinds, game/environment
|
||||
binding, `NotBefore`, `SignUntil`, and `VerifyUntil`. Do not retrieve or paste
|
||||
raw material merely to compare it.
|
||||
|
||||
### Contain
|
||||
|
||||
- Stop the affected external issuer and deny further access to its secret.
|
||||
- From a separate uncompromised break-glass operator key with `RotateKeys`, call
|
||||
`POST /v1/operator/keys/revoke` with identical `keyId` and `confirmKeyId`.
|
||||
Runtime revocation is immediate but process-local.
|
||||
- Remove or mark the key revoked in authoritative provisioning before any
|
||||
restart. Revoke affected principals/listings when narrower evidence supports
|
||||
it. Do not drain automatically unless the running instance cannot be trusted.
|
||||
|
||||
### Recover
|
||||
|
||||
- Generate replacement material in the approved secret boundary, use a new key
|
||||
ID, bind it to the exact credential kind and tenant, and deploy configuration
|
||||
referencing the secret—never the secret value.
|
||||
- Resume issuance with short lifetimes. Reissue only to authenticated workloads.
|
||||
When confidentiality is lost, do not use normal overlap to keep compromised
|
||||
credentials valid; document the intentional invalidation window.
|
||||
- Rotate any release, registry, or operator credential exposed by the same
|
||||
incident through its owning system; Rendezvous key revocation cannot revoke
|
||||
unrelated systems.
|
||||
|
||||
### Verify
|
||||
|
||||
- Confirm `GET /v1/operator/status` shows the compromised key revoked and the
|
||||
replacement signing, old credentials fail, new exact-scope credentials work,
|
||||
and the result survives a controlled restart from updated provisioning.
|
||||
- Pass TestClient registration, browse, authenticated mediation, and direct
|
||||
traffic with the replacement; monitor authentication and audit results through
|
||||
at least the maximum newly issued credential lifetime.
|
||||
|
||||
## Targeted listing or publisher revocation
|
||||
|
||||
### Detect
|
||||
|
||||
- Validate the abuse report against game-owned records and bounded Rendezvous
|
||||
evidence. Determine whether the target is one listing or an authenticated
|
||||
publisher subject. Do not use display name, metadata, or a raw address as
|
||||
identity.
|
||||
- Confirm current aggregate state through `GET /v1/operator/status` and record
|
||||
the correlation IDs that justified action.
|
||||
|
||||
### Contain
|
||||
|
||||
- Revoke one listing with `POST /v1/operator/listings/revoke`; the exact listing
|
||||
UUID must appear in both confirmation fields.
|
||||
- Revoke a publisher with `POST /v1/operator/principals/revoke`; the exact subject
|
||||
must appear in both confirmation fields and `lifetimeSeconds` must be 1–600.
|
||||
This removes that principal's active listings and attempts and blocks new ones
|
||||
for the bounded lifetime.
|
||||
- Choose the narrowest action. Do not revoke a tenant key for a single listing.
|
||||
|
||||
### Recover
|
||||
|
||||
- The game owner resolves the ban, account, workload, or configuration issue in
|
||||
the authoritative game system. Rendezvous does not own user accounts or bans.
|
||||
- After the original revocation deadline, permit a newly authenticated publisher
|
||||
to register. There is no un-revoke endpoint and no recovery of removed
|
||||
ephemeral listings; the host creates a new listing.
|
||||
|
||||
### Verify
|
||||
|
||||
- Confirm the old listing is no longer browsable or joinable, the principal
|
||||
cannot publish during its lifetime, and the audit action/result is present
|
||||
without the raw target.
|
||||
- Confirm unrelated publishers in the same tenant and another tenant still pass
|
||||
publish/browse/join/direct-traffic checks.
|
||||
|
||||
## Planned restart or crash recovery
|
||||
|
||||
### Detect
|
||||
|
||||
- Planned restart begins with a recorded change and a healthy current baseline.
|
||||
Crash recovery begins when liveness/process state fails or both TCP 8080 and
|
||||
UDP 9050 stop answering. Distinguish dependency/readiness failure from a dead
|
||||
process; liveness deliberately remains healthy for some recoverable failures.
|
||||
- Record active listing/lease/attempt aggregates. They are informational only:
|
||||
v1 has no durable runtime database to restore.
|
||||
|
||||
### Contain
|
||||
|
||||
- For a planned stop, call `POST /v1/operator/drain` with confirmation exactly
|
||||
`DRAIN`. Require readiness `503`, liveness `200`, and removal from new traffic.
|
||||
Allow the bounded drain deadline to finish, then send SIGTERM.
|
||||
- Never start a second active instance while the old process owns the advertised
|
||||
HTTP/UDP endpoints. On crash, fence the old process/host and verify both sockets
|
||||
are released before replacement.
|
||||
|
||||
### Recover
|
||||
|
||||
- Start exactly one instance from the recorded immutable image digest and matching
|
||||
reviewed configuration/key references. A restart intentionally loses listings,
|
||||
observed endpoints, attempts, replay markers, and runtime-only revocations.
|
||||
- Ensure any emergency key revocation is also present in authoritative
|
||||
provisioning. Hosts must re-register; clients must browse and start new
|
||||
attempts. Do not restore stale ephemeral state from logs or backups.
|
||||
|
||||
### Verify
|
||||
|
||||
- Require live and ready health, UDP bind, store availability, and one active
|
||||
target. Run the full deployment smoke and confirm host re-registration begins.
|
||||
- Verify no pre-restart listing or capability is accepted, runtime revocations
|
||||
that should persist are configuration-backed, and latency/outcomes stabilize.
|
||||
|
||||
## Release rollback
|
||||
|
||||
### Detect
|
||||
|
||||
- Trigger rollback from a predeclared objective: readiness loss, failed deployment
|
||||
smoke, contract/package incompatibility, security regression, direct-success
|
||||
regression beyond threshold, or sustained resource regression. Record the new
|
||||
and previous digests and the evidence; do not move a tag.
|
||||
|
||||
### Contain
|
||||
|
||||
- Stop promotion and new rollout work. Drain and stop the faulty single active
|
||||
instance, then verify both public sockets are released. Revoke affected keys or
|
||||
principals only when the defect creates an authorization risk.
|
||||
- Preserve logs, artifacts, provenance, signatures, and the faulty release record.
|
||||
Never overwrite or delete an immutable package/image to reuse its version.
|
||||
|
||||
### Recover
|
||||
|
||||
- Deploy the previous known-good image by digest with its compatible configuration
|
||||
and key set. Do not run old and new concurrently. If configuration changed,
|
||||
apply its reviewed down-migration before starting.
|
||||
- Publish a corrected build under a new SemVer after diagnosis; mark faulty release
|
||||
notes withdrawn when appropriate.
|
||||
|
||||
### Verify
|
||||
|
||||
- Check the running image digest, live/ready health, one active target, UDP source
|
||||
preservation, and the complete TestClient deployment smoke.
|
||||
- Confirm package/server compatibility from `GET /v1/operator/status`, hosts
|
||||
re-register, and the rollback objective returns to baseline for the observation
|
||||
window.
|
||||
|
||||
## Capacity saturation
|
||||
|
||||
### Detect
|
||||
|
||||
- Page when `rendezvous.queue.depth` remains above 90% of the configured attempt
|
||||
limit, lease-critical work is shed, `rendezvous.store.available` is zero, or no
|
||||
ready instance remains. Warn at 70%, sustained `rendezvous.limiter.drops`, or
|
||||
p95 latency above objective.
|
||||
- Compare CPU, memory, file descriptors, UDP errors, expiry churn, HTTP operation
|
||||
rate, and typed connection outcomes with the measured
|
||||
[capacity profile](capacity-and-resilience.md). Distinguish legitimate growth,
|
||||
attack traffic, downstream telemetry pressure, and a regression.
|
||||
|
||||
### Contain
|
||||
|
||||
- Preserve lease-critical and operator reserves. Shed new browse/join work with
|
||||
the existing typed `429`/`Retry-After` behavior; do not add an unbounded queue.
|
||||
- Apply per-tenant/source controls at the appropriate trusted boundary. If the
|
||||
process is unstable, drain new work and recover on one replacement rather than
|
||||
adding a second active replica; v1 state is process-local.
|
||||
|
||||
### Recover
|
||||
|
||||
- Remove the causal load or deploy a tested higher single-instance resource and
|
||||
budget profile. Change CPU/memory and server limits together, using the numeric
|
||||
gate and accelerated soak before production.
|
||||
- Long-term horizontal scaling requires a designed shared directory, replay, and
|
||||
attempt authority. A generic load balancer is not that design.
|
||||
|
||||
### Verify
|
||||
|
||||
- Re-run the capacity/resilience gate at the chosen profile, then require queue,
|
||||
limiter drops, expiry churn, latency, store health, and direct-success ratio to
|
||||
remain within objectives through the production observation window.
|
||||
- Confirm termination still completes within `DrainDeadlineSeconds + 5` and the
|
||||
public and operator partitions behave independently.
|
||||
|
||||
## Privacy or telemetry incident
|
||||
|
||||
### Detect
|
||||
|
||||
- Trigger on any credential, token, capability, player identity, raw IP/endpoint,
|
||||
listing ID, metadata, or caller-reported exact connection duration tied to an
|
||||
event or identity found in logs, metrics, traces, crash reports, support systems,
|
||||
or analytics. Aggregate HTTP/UDP duration histograms with bounded operation tags
|
||||
are expected telemetry. Also trigger when audit data exceeds its approved 30-day
|
||||
retention without an incident hold.
|
||||
- Identify the producing version, sink, access population, retention/replication
|
||||
path, and time window without copying the exposed value into a new system.
|
||||
|
||||
### Contain
|
||||
|
||||
- Stop or filter the offending export and restrict access to affected sinks.
|
||||
Preserve the minimum evidence under the incident process; do not take broad
|
||||
diagnostic dumps that amplify exposure.
|
||||
- Revoke exposed reusable credentials/keys through their owning boundary. Listing
|
||||
IDs and endpoints are not authentication secrets, but remove affected listings
|
||||
if continued exposure creates risk. Notify privacy/security owners according to
|
||||
applicable policy and law.
|
||||
|
||||
### Recover
|
||||
|
||||
- Patch the producer to the allowlisted telemetry model, test canary redaction
|
||||
across logs/metrics/traces/output, and deploy through the immutable release
|
||||
path. Delete or age out affected data from every sink according to approved
|
||||
retention and legal-hold direction.
|
||||
- Replace exposed credentials and re-register hosts when necessary. Do not claim
|
||||
that a service restart deletes copies already exported to collectors.
|
||||
|
||||
### Verify
|
||||
|
||||
- Search new telemetry using non-secret synthetic canaries and confirm no canary
|
||||
or prohibited field crosses the boundary. Verify audit records contain only
|
||||
fixed fields and fingerprints and that retention/eviction is operating.
|
||||
- Security/privacy owners confirm sink cleanup, access review, notification, and
|
||||
monitoring closure before the incident is resolved.
|
||||
|
||||
## Dependency or base-image upgrade
|
||||
|
||||
### Detect
|
||||
|
||||
- Open a reviewed change for an advisory, end-of-support date, pinned-digest
|
||||
refresh, or planned package update. Record affected package/image, current and
|
||||
proposed exact version/digest, advisory severity, exploitability, and required
|
||||
deadline. Never float to `latest` as remediation.
|
||||
|
||||
### Contain
|
||||
|
||||
- For an actively exploited critical issue, restrict exposure or stop the service
|
||||
under incident authority while building the fix. Revoking publisher keys does
|
||||
not repair a vulnerable runtime. Otherwise keep the known-good release running
|
||||
while the candidate is tested.
|
||||
|
||||
### Recover
|
||||
|
||||
- Update the SDK/base-image digest, lock files, license/advisory evidence, SBOM,
|
||||
compatibility matrix, and release notes together. For LiteNetLib or a wire/API
|
||||
change, apply the explicit version/migration policy rather than silently
|
||||
replacing compatible bytes.
|
||||
- Run locked restore, formatting, Debug and Release builds/tests, public contract
|
||||
and package gates, real consumer restores, reproducible artifact/image builds,
|
||||
vulnerability scan, signatures, topology/deployment smoke, and capacity checks
|
||||
proportional to the change. Promote the exact tested digest.
|
||||
|
||||
### Verify
|
||||
|
||||
- Verify signatures, provenance, checksums, SBOM contents, running digest, and
|
||||
absence of the advisory in the shipped artifact—not merely the build host.
|
||||
- Require live/ready health, TestClient direct traffic, real consumer compatibility,
|
||||
and normal latency/outcomes. Keep the previous digest and compatible config for
|
||||
rollback until the observation window closes.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Observability and operator runbook
|
||||
# Observability and operator reference
|
||||
|
||||
This runbook defines the production signals and privileged controls for the
|
||||
Rendezvous service. The service emits `System.Diagnostics.Metrics` instruments
|
||||
@@ -6,6 +6,10 @@ from the `FinalFactory.Rendezvous` meter and distributed-tracing activities from
|
||||
`FinalFactory.Rendezvous.Server`. Connect those sources to the deployment's
|
||||
OpenTelemetry or equivalent collector. Do not add identifiers to metric labels.
|
||||
|
||||
Concrete detect/contain/recover/verify procedures for abuse, key compromise,
|
||||
targeted revocation, restart, rollback, saturation, privacy incidents, and
|
||||
dependency upgrades are in the [incident and change runbooks](incident-runbooks.md).
|
||||
|
||||
## Health and readiness
|
||||
|
||||
- `GET /health/live` proves that the HTTP process can answer. It deliberately
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Releases and compatibility
|
||||
|
||||
Tracking: #19
|
||||
|
||||
Rendezvous releases are immutable, reproducible, and promoted only after the
|
||||
same candidate has passed package, consumer, server, container, and staging
|
||||
checks. A release consists of matching Client and Contracts NuGet packages, a
|
||||
framework-dependent Linux server archive, a versioned linux/amd64 OCI image,
|
||||
package/runtime and container SPDX inventories, checksums, provenance, release
|
||||
notes, and signatures. No workflow publishes a `latest` tag.
|
||||
|
||||
## Version dimensions
|
||||
|
||||
The central values in `eng/Versions.props` are the authority. Client,
|
||||
Contracts, and Server use SemVer. HTTP, UDP mediation, and connection-ticket
|
||||
formats advance independently so a wire change cannot hide inside a package
|
||||
patch release. The current machine-readable matrix is
|
||||
[`compatibility.json`](compatibility.json); the authenticated operator status
|
||||
endpoint exposes the server's supported window at runtime.
|
||||
|
||||
| Surface | Current | Compatibility rule |
|
||||
| --- | ---: | --- |
|
||||
| Client and Contracts | 1.0.0 | Matching exact versions; source/API breaks require a package major bump. |
|
||||
| Server | 1.0.0 | Accepts Client 1.0.0 through compatible 1.x releases. |
|
||||
| HTTP contract | 1 | Frozen OpenAPI, JSON vectors, and public API snapshot. |
|
||||
| UDP mediation | 1 | Frozen codec vectors; incompatible bytes require UDP v2. |
|
||||
| Connection ticket | 1 | A format change requires a new accepted ticket version and migration window. |
|
||||
| LiteNetLib | 2.1.4 | Exact dependency; LiteNetLib 1.x is rejected by package and consumer gates. |
|
||||
| Gameplay protocol | Per tenant | Exact match; Rendezvous does not translate gameplay protocols. |
|
||||
|
||||
`scripts/check-compatibility.sh` compares protected snapshots against the base
|
||||
revision. A changed public API snapshot requires a package major increase; an
|
||||
HTTP or UDP golden surface requires the corresponding contract increase. The
|
||||
normal tests also compare implementation output with the current versioned
|
||||
snapshots. For a deliberate break, add a new versioned contract directory and
|
||||
documentation instead of replacing the prior version's evidence.
|
||||
|
||||
## Candidate build
|
||||
|
||||
From a clean tagged checkout:
|
||||
|
||||
```bash
|
||||
./scripts/check-release-tag.sh v1.0.0
|
||||
./scripts/build-release.sh 1.0.0
|
||||
./scripts/verify-release.sh 1.0.0
|
||||
```
|
||||
|
||||
The tag build runs inside the digest-pinned `release-builder` Docker stage,
|
||||
which combines the pinned SDK with a pinned Python runtime. It uses the locked
|
||||
dependency graph, enforces NuGet
|
||||
advisories and approved licenses, runs formatting/build/tests, regenerates the
|
||||
OpenAPI drift check, and packs twice after a clean rebuild. NuGet's random OPC
|
||||
relationship identifiers are canonicalized before comparison; both `.nupkg`
|
||||
and `.snupkg` outputs must then be byte-identical. Package metadata identifies the exact
|
||||
repository commit, portable PDBs carry SourceLink data, and the Linux archive,
|
||||
runtime SBOM timestamp, and checksum ordering are deterministic. Buildx and
|
||||
BuildKit are also pinned for the linux/amd64 OCI build. Provenance records each
|
||||
artifact-producing tool version; an out-of-band rebuild must use the pinned
|
||||
builder and recorded versions rather than treating the runner label as a
|
||||
reproducibility guarantee.
|
||||
All project-authored artifact normalization and checksum updates run inside the
|
||||
same pinned builder; host tools only orchestrate or verify. The separately
|
||||
pinned Trivy and Cosign tools produce the container inventory and signatures.
|
||||
The tag workflow also performs two no-cache image builds with the commit time
|
||||
and revision fixed, disables unsigned builder-generated attestations, and
|
||||
requires identical OCI image IDs before signing the project provenance.
|
||||
|
||||
The local gate builds net8.0 SpaceGame- and Unscouted-shaped API fixtures using
|
||||
only the candidate feed plus NuGet.org; the Unscouted fixture also carries its
|
||||
real direct LiteNetLib 2.1.4 pin. The tag gate separately checks out the exact
|
||||
SpaceGame and Unscouted revisions in `eng/consumer-revisions.json`, injects
|
||||
exact candidate references without modifying those repositories, and restores
|
||||
their real game/network projects. Both paths must resolve the matching Client
|
||||
and Contracts version and LiteNetLib 2.1.4. Updating a consumer revision is a
|
||||
reviewed compatibility change, not a floating-main check.
|
||||
|
||||
## Promotion and publication
|
||||
|
||||
Pushing the matching `vMAJOR.MINOR.PATCH` tag starts the tag-only release
|
||||
workflow. Before any external write it:
|
||||
|
||||
1. builds and verifies the artifact set;
|
||||
2. builds the exact versioned container candidate;
|
||||
3. starts that image with production hardening and a temporary staging key;
|
||||
4. completes HTTP health, registration, browse, authenticated UDP mediation,
|
||||
and direct traffic;
|
||||
5. rejects all high or critical container findings and emits a container SPDX
|
||||
inventory;
|
||||
6. finalizes and verifies checksums over the publish-ready artifact set; and
|
||||
7. confirms the two NuGet versions, container version, and Gitea release do not
|
||||
already exist.
|
||||
|
||||
Publication has no skip-duplicate behavior. Gitea's immutable package versions,
|
||||
the workflow concurrency lock, and the preflight make a successful tag a
|
||||
single publication event. The workflow pushes symbols, publishes only the
|
||||
versioned container tag, records its `sha256` digest in both a digest file and
|
||||
provenance, regenerates the checksum manifest so that digest and public key are
|
||||
covered, signs the checksum manifest and image, attaches signed provenance,
|
||||
verifies the complete published-set schema and all signatures, and creates the
|
||||
Gitea release with exactly those artifacts. The detached checksum signature
|
||||
bundle is the sole envelope excluded from its own signed manifest.
|
||||
The loaded image ID is captured immediately after the byte-reproducible build;
|
||||
publication refuses to push if staging or another process retagged that local
|
||||
name to different bytes.
|
||||
|
||||
The protected `production` environment requires these secrets:
|
||||
|
||||
- `RELEASE_TOKEN`: a dedicated Gitea token limited to this repository and the
|
||||
HeiKyu package registry, with repository and package write access;
|
||||
- `RELEASE_USERNAME`: the dedicated Gitea service-account name that owns the
|
||||
release token;
|
||||
- `COSIGN_PRIVATE_KEY`: the encrypted Cosign release private key; and
|
||||
- `COSIGN_PASSWORD`: its password, stored separately.
|
||||
|
||||
No development signing key, registry credential, or deployable configuration
|
||||
is stored in source or packages. Keep the Cosign public key with operational
|
||||
records. Rotate the release key between releases: retain the old public key for
|
||||
historical verification, install the new encrypted private key and password as
|
||||
one reviewed change, verify a signed non-release blob, and only then retire the
|
||||
old secret. Suspected compromise requires token/key revocation and a new
|
||||
version; never overwrite or delete evidence to reuse a released version.
|
||||
|
||||
## Release notes and migration
|
||||
|
||||
Every dated `CHANGELOG.md` entry must contain Compatibility, Security and
|
||||
configuration, and Migration sections. Before tagging, state the supported
|
||||
Client/server window, all HTTP/UDP/ticket changes, security fixes, required
|
||||
configuration, and operator/consumer migration steps.
|
||||
|
||||
For a protocol migration, first make the server read both old and new versions
|
||||
within an explicit bounded window, publish a Client that writes the new version,
|
||||
verify adoption through bounded telemetry, then remove the old reader only in a
|
||||
subsequent breaking release. Never silently reinterpret old bytes. Consumers
|
||||
pin both Rendezvous packages to one exact version and choose gameplay protocol
|
||||
compatibility per tenant.
|
||||
|
||||
## Rollback and interrupted publication
|
||||
|
||||
Runtime rollback means redeploying the previous known-good image by digest and
|
||||
its matching configuration; it does not move a tag. Packages and release
|
||||
records remain available so already restored clients stay reproducible. If a
|
||||
new release is faulty, revoke affected publisher or signing keys when relevant,
|
||||
mark the release notes as withdrawn, and publish the fix under a new SemVer.
|
||||
|
||||
The registries cannot provide a transaction spanning NuGet, OCI, signatures,
|
||||
and release attachments. If publication stops after its first external write,
|
||||
the preflight intentionally prevents an automatic rerun. An operator must
|
||||
inventory every destination, preserve logs and hashes, complete or withdraw the
|
||||
partial version under change control, and then issue a new version. This avoids
|
||||
turning a partial failure into an untraceable overwrite.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"release": "1.0.0",
|
||||
"server": {
|
||||
"minimumClientVersion": "1.0.0",
|
||||
"maximumClientMajorVersion": 1
|
||||
},
|
||||
"packages": {
|
||||
"FinalFactory.Rendezvous.Client": "1.0.0",
|
||||
"FinalFactory.Rendezvous.Contracts": "1.0.0"
|
||||
},
|
||||
"contracts": {
|
||||
"http": [1],
|
||||
"udp": [1],
|
||||
"connectionTicket": [1],
|
||||
"gameplay": "exact-per-tenant"
|
||||
},
|
||||
"transport": {
|
||||
"package": "LiteNetLib",
|
||||
"version": "2.1.4",
|
||||
"major": 2
|
||||
},
|
||||
"consumers": {
|
||||
"SpaceGame": "net8.0",
|
||||
"Unscouted": "net8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<RendezvousVersion>1.0.0</RendezvousVersion>
|
||||
<RendezvousMajorVersion>1</RendezvousMajorVersion>
|
||||
<RendezvousMinorVersion>0</RendezvousMinorVersion>
|
||||
<RendezvousPatchVersion>0</RendezvousPatchVersion>
|
||||
<MinimumClientVersion>1.0.0</MinimumClientVersion>
|
||||
<MaximumClientMajorVersion>1</MaximumClientMajorVersion>
|
||||
<HttpContractVersion>1</HttpContractVersion>
|
||||
<UdpContractVersion>1</UdpContractVersion>
|
||||
<ConnectionTicketFormatVersion>1</ConnectionTicketFormatVersion>
|
||||
<LiteNetLibVersion>2.1.4</LiteNetLibVersion>
|
||||
<LiteNetLibMajorVersion>2</LiteNetLibMajorVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
FROM python:3.12.11-slim-bookworm@sha256:c00fc7b44d844b6da22861ec24af43968a5200eac4ec607b4725d585165d6b49 AS release-python
|
||||
FROM ghcr.io/jqlang/jq:1.8.1@sha256:95de8f005ca027686a1ca3b0853e2bb219062438015862816159f3f25a4d4230 AS release-jq
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS release-builder
|
||||
COPY --from=release-python /usr/local/ /usr/local/
|
||||
COPY --from=release-jq /jq /usr/local/bin/jq
|
||||
RUN dotnet --version \
|
||||
&& python3 --version \
|
||||
&& git --version \
|
||||
&& tar --version \
|
||||
&& gzip --version \
|
||||
&& jq --version
|
||||
WORKDIR /source
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"packageRegistry": "https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json",
|
||||
"containerRepository": "git.finalfactory.de/heikyu/rendezvous",
|
||||
"allowedLicenseExpressions": [
|
||||
"Apache-2.0",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"MIT"
|
||||
],
|
||||
"dependencyLicenseOverrides": {
|
||||
"xunit.abstractions/2.0.3": {
|
||||
"license": "Apache-2.0",
|
||||
"reason": "Legacy package predates NuGet SPDX metadata; reviewed against the xUnit.net Apache-2.0 license."
|
||||
}
|
||||
},
|
||||
"publishedPackages": [
|
||||
"FinalFactory.Rendezvous.Client",
|
||||
"FinalFactory.Rendezvous.Contracts"
|
||||
],
|
||||
"forbiddenArtifactNameFragments": [
|
||||
"credential",
|
||||
"password",
|
||||
"private-key",
|
||||
"signing-key"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
|
||||
PROJECT_PACKAGE_PREFIX = "finalfactory.rendezvous."
|
||||
CORE_PROPERTIES_PATH = (
|
||||
"package/services/metadata/core-properties/core-properties.psmdcp"
|
||||
)
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise SystemExit(message)
|
||||
|
||||
|
||||
def load_json(path: pathlib.Path):
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
return json.load(stream)
|
||||
|
||||
|
||||
def dependency_inventory(root: pathlib.Path):
|
||||
dependencies = {}
|
||||
for lock_path in sorted(root.glob("**/packages.lock.json")):
|
||||
if any(part in {"bin", "obj", "artifacts"} for part in lock_path.parts):
|
||||
continue
|
||||
lock = load_json(lock_path)
|
||||
for framework in lock.get("dependencies", {}).values():
|
||||
for package_id, details in framework.items():
|
||||
resolved = details.get("resolved")
|
||||
if not resolved or package_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
|
||||
continue
|
||||
key = package_id.lower()
|
||||
previous = dependencies.get(key)
|
||||
if previous is not None and previous[1] != resolved:
|
||||
fail(
|
||||
f"Dependency {package_id} resolves to both {previous[1]} and {resolved}."
|
||||
)
|
||||
dependencies[key] = (package_id, resolved)
|
||||
return [dependencies[key] for key in sorted(dependencies)]
|
||||
|
||||
|
||||
def package_license(package_id: str, version: str, overrides):
|
||||
package_root = pathlib.Path(
|
||||
os.environ.get("NUGET_PACKAGES", pathlib.Path.home() / ".nuget" / "packages")
|
||||
)
|
||||
version_dir = package_root / package_id.lower() / version
|
||||
nuspecs = list(version_dir.glob("*.nuspec"))
|
||||
if len(nuspecs) != 1:
|
||||
fail(f"Expected one restored nuspec for {package_id} {version} in {version_dir}.")
|
||||
root = ET.parse(nuspecs[0]).getroot()
|
||||
license_element = root.find(".//{*}license")
|
||||
if license_element is None or license_element.get("type") != "expression":
|
||||
override = overrides.get(f"{package_id.lower()}/{version}")
|
||||
if override is None:
|
||||
fail(f"{package_id} {version} does not declare an SPDX license expression.")
|
||||
return override["license"]
|
||||
expression = (license_element.text or "").strip()
|
||||
if not expression:
|
||||
fail(f"{package_id} {version} has an empty license expression.")
|
||||
return expression
|
||||
|
||||
|
||||
def command_policy(args) -> None:
|
||||
root = pathlib.Path(args.root).resolve()
|
||||
policy = load_json(root / "eng" / "release-policy.json")
|
||||
allowed = set(policy["allowedLicenseExpressions"])
|
||||
inventory = dependency_inventory(root)
|
||||
observed = []
|
||||
for package_id, version in inventory:
|
||||
expression = package_license(
|
||||
package_id, version, policy.get("dependencyLicenseOverrides", {})
|
||||
)
|
||||
if expression not in allowed:
|
||||
fail(
|
||||
f"Dependency {package_id} {version} uses unapproved license {expression}."
|
||||
)
|
||||
observed.append({"id": package_id, "version": version, "license": expression})
|
||||
lite_net_lib = [item for item in observed if item["id"].lower() == "litenetlib"]
|
||||
if lite_net_lib != [{"id": "LiteNetLib", "version": "2.1.4", "license": "MIT"}]:
|
||||
fail(f"LiteNetLib must resolve exactly to the reviewed 2.1.4 release: {lite_net_lib}")
|
||||
print(json.dumps({"dependencies": observed}, indent=2))
|
||||
|
||||
|
||||
def command_audit(args) -> None:
|
||||
document = load_json(pathlib.Path(args.input))
|
||||
findings = []
|
||||
|
||||
def visit(value):
|
||||
if isinstance(value, dict):
|
||||
if value.get("vulnerabilities"):
|
||||
findings.append(value)
|
||||
for child in value.values():
|
||||
visit(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
visit(child)
|
||||
|
||||
visit(document)
|
||||
if findings:
|
||||
fail(f"Locked dependency graph contains known vulnerabilities: {findings}")
|
||||
print("Locked dependency graph has no reported vulnerabilities.")
|
||||
|
||||
|
||||
def release_dependency_graph(root: pathlib.Path, server_deps: pathlib.Path):
|
||||
dependencies = {}
|
||||
edges = set()
|
||||
server_document = load_json(server_deps)
|
||||
for package_key, details in server_document.get("libraries", {}).items():
|
||||
if details.get("type") != "package":
|
||||
continue
|
||||
package_id, version = package_key.rsplit("/", 1)
|
||||
dependencies[package_id.lower()] = (package_id, version)
|
||||
server_target = next(iter(server_document.get("targets", {}).values()), {})
|
||||
for source_key, details in server_target.items():
|
||||
source_id = source_key.rsplit("/", 1)[0]
|
||||
source = (
|
||||
"SPDXRef-Server"
|
||||
if source_id == "FinalFactory.Rendezvous.Server"
|
||||
else source_id.lower()
|
||||
)
|
||||
for dependency_id in details.get("dependencies", {}):
|
||||
target = {
|
||||
"FinalFactory.Rendezvous.Contracts": "SPDXRef-Contracts",
|
||||
"FinalFactory.Rendezvous.Client": "SPDXRef-Client",
|
||||
}.get(dependency_id, dependency_id.lower())
|
||||
edges.add((source, target))
|
||||
|
||||
lock_roots = (
|
||||
("SPDXRef-Client", root / "src/FinalFactory.Rendezvous.Client/packages.lock.json"),
|
||||
(
|
||||
"SPDXRef-Contracts",
|
||||
root / "src/FinalFactory.Rendezvous.Contracts/packages.lock.json",
|
||||
),
|
||||
)
|
||||
for root_id, lock_path in lock_roots:
|
||||
lock = load_json(lock_path)
|
||||
for framework in lock.get("dependencies", {}).values():
|
||||
for package_id, details in framework.items():
|
||||
resolved = details.get("resolved")
|
||||
if resolved and not package_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
|
||||
dependencies[package_id.lower()] = (package_id, resolved)
|
||||
if details.get("type") == "Direct":
|
||||
edges.add((root_id, package_id.lower()))
|
||||
source = package_id.lower()
|
||||
for dependency_id in details.get("dependencies", {}):
|
||||
if dependency_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
|
||||
continue
|
||||
edges.add((source, dependency_id.lower()))
|
||||
edges.add(("SPDXRef-Client", "SPDXRef-Contracts"))
|
||||
inventory = [dependencies[key] for key in sorted(dependencies)]
|
||||
return inventory, edges
|
||||
|
||||
|
||||
def command_sbom(args) -> None:
|
||||
root = pathlib.Path(args.root).resolve()
|
||||
policy = load_json(root / "eng" / "release-policy.json")
|
||||
overrides = policy.get("dependencyLicenseOverrides", {})
|
||||
packages = [
|
||||
{
|
||||
"SPDXID": "SPDXRef-Rendezvous",
|
||||
"name": "FinalFactory.Rendezvous",
|
||||
"versionInfo": args.version,
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": False,
|
||||
"licenseConcluded": "NOASSERTION",
|
||||
"licenseDeclared": "NOASSERTION",
|
||||
"copyrightText": "NOASSERTION",
|
||||
}
|
||||
]
|
||||
internal_packages = [
|
||||
("SPDXRef-Server", "FinalFactory.Rendezvous.Server"),
|
||||
("SPDXRef-Client", "FinalFactory.Rendezvous.Client"),
|
||||
("SPDXRef-Contracts", "FinalFactory.Rendezvous.Contracts"),
|
||||
]
|
||||
for spdx_id, package_id in internal_packages:
|
||||
packages.append(
|
||||
{
|
||||
"SPDXID": spdx_id,
|
||||
"name": package_id,
|
||||
"versionInfo": args.version,
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": False,
|
||||
"licenseConcluded": "NOASSERTION",
|
||||
"licenseDeclared": "NOASSERTION",
|
||||
"copyrightText": "NOASSERTION",
|
||||
}
|
||||
)
|
||||
inventory, dependency_edges = release_dependency_graph(
|
||||
root, pathlib.Path(args.server_deps)
|
||||
)
|
||||
dependency_ids = {}
|
||||
for index, (package_id, version) in enumerate(
|
||||
inventory, start=1
|
||||
):
|
||||
expression = package_license(package_id, version, overrides)
|
||||
spdx_id = f"SPDXRef-Package-{index}"
|
||||
dependency_ids[package_id.lower()] = spdx_id
|
||||
packages.append(
|
||||
{
|
||||
"SPDXID": spdx_id,
|
||||
"name": package_id,
|
||||
"versionInfo": version,
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": False,
|
||||
"licenseConcluded": expression,
|
||||
"licenseDeclared": expression,
|
||||
"copyrightText": "NOASSERTION",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": f"pkg:nuget/{package_id}@{version}",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
created = dt.datetime.fromtimestamp(
|
||||
int(args.source_date_epoch), dt.timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
document = {
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": f"FinalFactory.Rendezvous-{args.version}",
|
||||
"documentNamespace": (
|
||||
"https://git.finalfactory.de/HeiKyu/Rendezvous/sbom/"
|
||||
f"{args.version}/{args.commit}"
|
||||
),
|
||||
"creationInfo": {
|
||||
"created": created,
|
||||
"creators": ["Tool: eng/release_artifacts.py"],
|
||||
},
|
||||
"documentDescribes": ["SPDXRef-Rendezvous"],
|
||||
"packages": packages,
|
||||
"relationships": [
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Rendezvous",
|
||||
"relationshipType": "CONTAINS",
|
||||
"relatedSpdxElement": spdx_id,
|
||||
}
|
||||
for spdx_id, _ in internal_packages
|
||||
]
|
||||
+ [
|
||||
{
|
||||
"spdxElementId": dependency_ids.get(source, source),
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": dependency_ids.get(target, target),
|
||||
}
|
||||
for source, target in sorted(dependency_edges)
|
||||
if source in dependency_ids or source.startswith("SPDXRef-")
|
||||
if target in dependency_ids or target.startswith("SPDXRef-")
|
||||
],
|
||||
}
|
||||
output = pathlib.Path(args.output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def nuspec_metadata(archive: pathlib.Path):
|
||||
with zipfile.ZipFile(archive) as package:
|
||||
names = package.namelist()
|
||||
nuspecs = [name for name in names if name.endswith(".nuspec")]
|
||||
if len(nuspecs) != 1:
|
||||
fail(f"{archive.name} must contain exactly one nuspec.")
|
||||
root = ET.fromstring(package.read(nuspecs[0]))
|
||||
metadata = root.find(".//{*}metadata")
|
||||
if metadata is None:
|
||||
fail(f"{archive.name} has no package metadata.")
|
||||
values = {
|
||||
child.tag.rsplit("}", 1)[-1]: (child.text or "").strip()
|
||||
for child in metadata
|
||||
if len(child) == 0
|
||||
}
|
||||
dependencies = {
|
||||
item.get("id"): item.get("version")
|
||||
for item in metadata.findall(".//{*}dependency")
|
||||
}
|
||||
repository = metadata.find("./{*}repository")
|
||||
repository_attributes = {} if repository is None else dict(repository.attrib)
|
||||
return values, dependencies, repository_attributes
|
||||
|
||||
|
||||
def verify_checksum_file(release_dir: pathlib.Path, excluded=()) -> None:
|
||||
checksum_path = release_dir / "checksums.sha256"
|
||||
lines = checksum_path.read_text(encoding="utf-8").splitlines()
|
||||
if not lines:
|
||||
fail("checksums.sha256 is empty.")
|
||||
referenced = set()
|
||||
for line in lines:
|
||||
digest, marker, relative = line.partition(" ")
|
||||
if marker != " " or not re.fullmatch(r"[0-9a-f]{64}", digest):
|
||||
fail(f"Malformed checksum line: {line}")
|
||||
target = release_dir / relative
|
||||
if not target.is_file():
|
||||
fail(f"Checksum references missing artifact: {relative}")
|
||||
actual = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
if actual != digest:
|
||||
fail(f"Checksum mismatch for {relative}.")
|
||||
referenced.add(relative)
|
||||
expected = {
|
||||
path.name
|
||||
for path in release_dir.iterdir()
|
||||
if path.is_file()
|
||||
and path.name != "checksums.sha256"
|
||||
and path.name not in excluded
|
||||
}
|
||||
if referenced != expected:
|
||||
fail(
|
||||
"Checksum manifest coverage differs. "
|
||||
f"Missing={expected - referenced}; extra={referenced - expected}"
|
||||
)
|
||||
|
||||
|
||||
def command_verify(args) -> None:
|
||||
release_dir = pathlib.Path(args.release_dir).resolve()
|
||||
version = args.version
|
||||
expected = {
|
||||
f"FinalFactory.Rendezvous.Client.{version}.nupkg",
|
||||
f"FinalFactory.Rendezvous.Client.{version}.snupkg",
|
||||
f"FinalFactory.Rendezvous.Contracts.{version}.nupkg",
|
||||
f"FinalFactory.Rendezvous.Contracts.{version}.snupkg",
|
||||
f"FinalFactory.Rendezvous.Server.{version}.linux-x64.tar.gz",
|
||||
f"FinalFactory.Rendezvous.{version}.spdx.json",
|
||||
"CHANGELOG.md",
|
||||
"checksums.sha256",
|
||||
"release-provenance.json",
|
||||
}
|
||||
checksum_exclusions = set()
|
||||
if args.phase in {"publish-ready", "signing-ready", "published"}:
|
||||
expected.add(f"FinalFactory.Rendezvous.Container.{version}.spdx.json")
|
||||
if args.phase in {"signing-ready", "published"}:
|
||||
expected.update({"container-digest.txt", "cosign.pub"})
|
||||
if args.phase == "published":
|
||||
expected.add("checksums.sha256.bundle")
|
||||
checksum_exclusions.add("checksums.sha256.bundle")
|
||||
actual = {path.name for path in release_dir.iterdir() if path.is_file()}
|
||||
if actual != expected:
|
||||
fail(f"Release artifact set differs. Missing={expected - actual}; extra={actual - expected}")
|
||||
|
||||
if args.phase in {"publish-ready", "signing-ready", "published"}:
|
||||
container_sbom = load_json(
|
||||
release_dir / f"FinalFactory.Rendezvous.Container.{version}.spdx.json"
|
||||
)
|
||||
if container_sbom.get("spdxVersion") != "SPDX-2.3":
|
||||
fail("Container inventory must be an SPDX 2.3 document.")
|
||||
if not container_sbom.get("packages"):
|
||||
fail("Container SPDX inventory contains no packages.")
|
||||
|
||||
policy = load_json(pathlib.Path(args.root) / "eng" / "release-policy.json")
|
||||
forbidden = tuple(fragment.lower() for fragment in policy["forbiddenArtifactNameFragments"])
|
||||
for artifact in actual:
|
||||
if artifact != "release-provenance.json" and any(fragment in artifact.lower() for fragment in forbidden):
|
||||
fail(f"Forbidden secret-like artifact name: {artifact}")
|
||||
|
||||
release_sbom = load_json(
|
||||
release_dir / f"FinalFactory.Rendezvous.{version}.spdx.json"
|
||||
)
|
||||
package_ids = {
|
||||
package.get("name"): package.get("SPDXID")
|
||||
for package in release_sbom.get("packages", [])
|
||||
}
|
||||
relationships = {
|
||||
(
|
||||
relationship.get("spdxElementId"),
|
||||
relationship.get("relationshipType"),
|
||||
relationship.get("relatedSpdxElement"),
|
||||
)
|
||||
for relationship in release_sbom.get("relationships", [])
|
||||
}
|
||||
expected_component_edges = {
|
||||
("SPDXRef-Server", "SPDXRef-Contracts"),
|
||||
("SPDXRef-Server", package_ids.get("LiteNetLib")),
|
||||
("SPDXRef-Server", package_ids.get("Microsoft.AspNetCore.OpenApi")),
|
||||
("SPDXRef-Client", "SPDXRef-Contracts"),
|
||||
("SPDXRef-Client", package_ids.get("LiteNetLib")),
|
||||
("SPDXRef-Contracts", package_ids.get("System.Text.Json")),
|
||||
}
|
||||
for source, target in expected_component_edges:
|
||||
if not target or (source, "DEPENDS_ON", target) not in relationships:
|
||||
fail(f"Release SPDX inventory is missing component edge {source} -> {target}.")
|
||||
bcl_id = package_ids.get("Microsoft.Bcl.AsyncInterfaces")
|
||||
if ("SPDXRef-Server", "DEPENDS_ON", bcl_id) in relationships:
|
||||
fail("Release SPDX inventory incorrectly flattens transitive dependencies onto Server.")
|
||||
|
||||
for package_id in policy["publishedPackages"]:
|
||||
package = release_dir / f"{package_id}.{version}.nupkg"
|
||||
metadata, dependencies, repository = nuspec_metadata(package)
|
||||
if metadata.get("id") != package_id or metadata.get("version") != version:
|
||||
fail(f"{package.name} identity/version metadata is incorrect.")
|
||||
if metadata.get("projectUrl") != "https://git.finalfactory.de/HeiKyu/Rendezvous":
|
||||
fail(f"{package.name} has an incorrect project URL.")
|
||||
if repository.get("url") != "https://git.finalfactory.de/HeiKyu/Rendezvous":
|
||||
fail(f"{package.name} has an incorrect repository URL.")
|
||||
if repository.get("commit") != provenance_commit(release_dir):
|
||||
fail(f"{package.name} does not identify the release commit.")
|
||||
symbol_package = release_dir / f"{package_id}.{version}.snupkg"
|
||||
with zipfile.ZipFile(symbol_package) as symbols:
|
||||
if not any(name.endswith(".pdb") for name in symbols.namelist()):
|
||||
fail(f"{symbol_package.name} contains no portable PDB.")
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
names = set(archive.namelist())
|
||||
required_entries = {
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
f"lib/netstandard2.1/{package_id}.dll",
|
||||
}
|
||||
if not required_entries <= names:
|
||||
fail(
|
||||
f"{package.name} is missing required package content: "
|
||||
f"{required_entries - names}"
|
||||
)
|
||||
forbidden_entries = [
|
||||
name
|
||||
for name in names
|
||||
if any(
|
||||
fragment in name.lower()
|
||||
for fragment in (
|
||||
"appsettings",
|
||||
"launchsettings",
|
||||
".env",
|
||||
"credential",
|
||||
"signing-key",
|
||||
"private-key",
|
||||
)
|
||||
)
|
||||
]
|
||||
if forbidden_entries:
|
||||
fail(
|
||||
f"{package.name} contains deployable configuration or secrets: "
|
||||
f"{forbidden_entries}"
|
||||
)
|
||||
if package_id.endswith(".Client"):
|
||||
if dependencies.get("LiteNetLib") != "[2.1.4]":
|
||||
fail(f"Client package must pin LiteNetLib exactly to 2.1.4: {dependencies}")
|
||||
contracts_range = dependencies.get("FinalFactory.Rendezvous.Contracts", "")
|
||||
if contracts_range != f"[{version}]":
|
||||
fail(f"Client package does not depend on the matching Contracts version.")
|
||||
|
||||
provenance = load_json(release_dir / "release-provenance.json")
|
||||
if provenance.get("version") != version:
|
||||
fail("Release provenance does not identify the requested version.")
|
||||
if provenance.get("treeState") != "clean" and not args.allow_dirty:
|
||||
fail("Release provenance must identify a clean tree.")
|
||||
container = provenance.get("containerImage", "")
|
||||
if container.endswith(":latest") or ":latest@" in container or f":{version}" not in container:
|
||||
fail(f"Container reference is mutable or not versioned: {container}")
|
||||
if provenance.get("containerPlatform") != "linux/amd64":
|
||||
fail("Release provenance must pin the linux/amd64 container platform.")
|
||||
for field in ("containerBaseDigests", "releaseBuilderBaseDigests"):
|
||||
images = provenance.get(field, [])
|
||||
if not images or any("@sha256:" not in image or image.endswith(":latest") for image in images):
|
||||
fail(f"Release provenance contains an unpinned build image in {field}: {images}")
|
||||
build_tools = provenance.get("buildTools", [])
|
||||
required_tool_prefixes = ("dotnet", "python=", "tar=", "gzip=", "jq=")
|
||||
observed_tools = [f"dotnet={provenance.get('dotnetSdk', '')}", *build_tools]
|
||||
for prefix in required_tool_prefixes:
|
||||
if not any(tool.startswith(prefix) for tool in observed_tools):
|
||||
fail(f"Release provenance is missing an artifact tool version: {prefix}")
|
||||
if args.phase in {"publish-ready", "signing-ready", "published"}:
|
||||
if not re.fullmatch(
|
||||
r"sha256:[0-9a-f]{64}", provenance.get("containerImageId", "")
|
||||
):
|
||||
fail("Release provenance is missing the verified local container image ID.")
|
||||
expected_namespace = (
|
||||
"https://git.finalfactory.de/HeiKyu/Rendezvous/container-sbom/"
|
||||
f"{version}/{provenance_commit(release_dir)}"
|
||||
)
|
||||
if container_sbom.get("documentNamespace") != expected_namespace:
|
||||
fail("Container SPDX inventory does not identify the release commit.")
|
||||
expected_created = dt.datetime.fromtimestamp(
|
||||
int(provenance.get("sourceDateEpoch", 0)), dt.timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
if container_sbom.get("creationInfo", {}).get("created") != expected_created:
|
||||
fail("Container SPDX timestamp is not normalized to the source epoch.")
|
||||
if args.phase in {"signing-ready", "published"}:
|
||||
digest = (release_dir / "container-digest.txt").read_text(
|
||||
encoding="utf-8"
|
||||
).strip()
|
||||
if not re.fullmatch(
|
||||
r"git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}", digest
|
||||
):
|
||||
fail(f"Published container digest is invalid: {digest}")
|
||||
if provenance.get("containerDigest") != digest:
|
||||
fail("Published provenance and container digest file differ.")
|
||||
for prefix in ("docker-buildx=", "buildkit="):
|
||||
if not any(tool.startswith(prefix) for tool in build_tools):
|
||||
fail(f"Published provenance is missing container tool version: {prefix}")
|
||||
verify_checksum_file(release_dir, checksum_exclusions)
|
||||
print(f"Verified {args.phase} release artifact set for {version}.")
|
||||
|
||||
|
||||
def provenance_commit(release_dir: pathlib.Path) -> str:
|
||||
provenance = load_json(release_dir / "release-provenance.json")
|
||||
commit = provenance.get("commit", "")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
fail("Release provenance must contain a full Git commit SHA.")
|
||||
return commit
|
||||
|
||||
|
||||
def command_consumer(args) -> None:
|
||||
assets = load_json(pathlib.Path(args.assets))
|
||||
libraries = assets.get("libraries", {})
|
||||
required = {
|
||||
f"FinalFactory.Rendezvous.Client/{args.version}",
|
||||
f"FinalFactory.Rendezvous.Contracts/{args.version}",
|
||||
"LiteNetLib/2.1.4",
|
||||
}
|
||||
missing = required - set(libraries)
|
||||
if missing:
|
||||
fail(f"Consumer restore is missing exact release dependencies: {missing}")
|
||||
forbidden = [name for name in libraries if name.lower().startswith("litenetlib/1.")]
|
||||
if forbidden:
|
||||
fail(f"Consumer resolved forbidden LiteNetLib 1.x assets: {forbidden}")
|
||||
|
||||
|
||||
def command_consumer_config(args) -> None:
|
||||
local_source = escape(str(pathlib.Path(args.local_source).resolve()))
|
||||
configuration = f'''<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="rendezvous-candidate" value="{local_source}" />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="rendezvous-candidate">
|
||||
<package pattern="FinalFactory.Rendezvous.*" />
|
||||
</packageSource>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
'''
|
||||
pathlib.Path(args.output).write_text(configuration, encoding="utf-8")
|
||||
|
||||
|
||||
def command_source_link(args) -> None:
|
||||
document = load_json(pathlib.Path(args.file))
|
||||
mappings = document.get("documents", {})
|
||||
expected = (
|
||||
"https://git.finalfactory.de/HeiKyu/Rendezvous/raw/commit/"
|
||||
f"{args.commit}/"
|
||||
)
|
||||
if not mappings or any(not value.startswith(expected) for value in mappings.values()):
|
||||
fail(f"SourceLink mappings do not identify commit {args.commit}: {mappings}")
|
||||
|
||||
|
||||
def command_normalize_package(args) -> None:
|
||||
package_path = pathlib.Path(args.package).resolve()
|
||||
if package_path.suffix not in {".nupkg", ".snupkg"}:
|
||||
fail(f"Unsupported NuGet archive extension: {package_path.name}")
|
||||
timestamp = dt.datetime.fromtimestamp(
|
||||
int(args.source_date_epoch), dt.timezone.utc
|
||||
)
|
||||
zip_timestamp = (
|
||||
max(timestamp.year, 1980),
|
||||
timestamp.month,
|
||||
timestamp.day,
|
||||
timestamp.hour,
|
||||
timestamp.minute,
|
||||
timestamp.second - (timestamp.second % 2),
|
||||
)
|
||||
with zipfile.ZipFile(package_path) as source:
|
||||
entries = {name: source.read(name) for name in source.namelist()}
|
||||
if ".signature.p7s" in entries:
|
||||
fail(f"Refusing to rewrite signed package: {package_path.name}")
|
||||
core_paths = [
|
||||
name
|
||||
for name in entries
|
||||
if name.startswith("package/services/metadata/core-properties/")
|
||||
and name.endswith(".psmdcp")
|
||||
]
|
||||
if len(core_paths) != 1:
|
||||
fail(f"Expected one NuGet core-properties part in {package_path.name}.")
|
||||
entries[CORE_PROPERTIES_PATH] = entries.pop(core_paths[0])
|
||||
|
||||
nuspec_paths = [name for name in entries if name.endswith(".nuspec")]
|
||||
if len(nuspec_paths) != 1:
|
||||
fail(f"Expected one nuspec in {package_path.name}.")
|
||||
nuspec = ET.fromstring(entries[nuspec_paths[0]])
|
||||
nuspec_namespace = nuspec.tag.partition("}")[0].removeprefix("{")
|
||||
if nuspec_namespace:
|
||||
ET.register_namespace("", nuspec_namespace)
|
||||
package_id = nuspec.findtext(".//{*}id")
|
||||
if package_id == "FinalFactory.Rendezvous.Client":
|
||||
contracts = nuspec.find(
|
||||
".//{*}dependency[@id='FinalFactory.Rendezvous.Contracts']"
|
||||
)
|
||||
if contracts is None:
|
||||
fail("Client package has no Contracts dependency to pin.")
|
||||
contracts.set("version", f"[{args.version}]")
|
||||
entries[nuspec_paths[0]] = ET.tostring(
|
||||
nuspec, encoding="utf-8", xml_declaration=True
|
||||
)
|
||||
|
||||
relationships_namespace = (
|
||||
"http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
)
|
||||
ET.register_namespace("", relationships_namespace)
|
||||
relationships = ET.fromstring(entries["_rels/.rels"])
|
||||
for relationship in relationships:
|
||||
relationship_type = relationship.get("Type", "")
|
||||
if relationship_type.endswith("/manifest"):
|
||||
relationship.set("Id", "RManifest")
|
||||
elif relationship_type.endswith("/metadata/core-properties"):
|
||||
relationship.set("Id", "RCoreProperties")
|
||||
relationship.set("Target", f"/{CORE_PROPERTIES_PATH}")
|
||||
entries["_rels/.rels"] = ET.tostring(
|
||||
relationships, encoding="utf-8", xml_declaration=True
|
||||
)
|
||||
|
||||
temporary = package_path.with_suffix(package_path.suffix + ".normalized")
|
||||
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_STORED) as target:
|
||||
for name in sorted(entries):
|
||||
info = zipfile.ZipInfo(name, date_time=zip_timestamp)
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
info.create_system = 3
|
||||
info.external_attr = 0o100644 << 16
|
||||
target.writestr(info, entries[name])
|
||||
temporary.replace(package_path)
|
||||
|
||||
|
||||
def command_provenance(args) -> None:
|
||||
versions = ET.parse(pathlib.Path(args.root) / "eng" / "Versions.props")
|
||||
|
||||
def version_property(name: str) -> str:
|
||||
element = versions.find(f".//{name}")
|
||||
if element is None or not element.text:
|
||||
fail(f"Missing central release property: {name}")
|
||||
return element.text.strip()
|
||||
|
||||
provenance = {
|
||||
"schemaVersion": 1,
|
||||
"version": args.version,
|
||||
"commit": args.commit,
|
||||
"treeState": args.tree_state,
|
||||
"buildConfiguration": "Release",
|
||||
"sourceDateEpoch": int(args.source_date_epoch),
|
||||
"dotnetSdk": args.dotnet_sdk,
|
||||
"buildTools": sorted(args.build_tool),
|
||||
"containerImage": f"git.finalfactory.de/heikyu/rendezvous:{args.version}",
|
||||
"containerPlatform": "linux/amd64",
|
||||
"containerBaseDigests": args.base_digest,
|
||||
"releaseBuilderBaseDigests": args.builder_base,
|
||||
"packages": [
|
||||
f"FinalFactory.Rendezvous.Client/{args.version}",
|
||||
f"FinalFactory.Rendezvous.Contracts/{args.version}",
|
||||
],
|
||||
"compatibility": {
|
||||
"minimumClientVersion": version_property("MinimumClientVersion"),
|
||||
"maximumClientMajorVersion": int(version_property("MaximumClientMajorVersion")),
|
||||
"httpContractVersions": [int(version_property("HttpContractVersion"))],
|
||||
"udpContractVersions": [int(version_property("UdpContractVersion"))],
|
||||
"connectionTicketFormatVersions": [
|
||||
int(version_property("ConnectionTicketFormatVersion"))
|
||||
],
|
||||
"liteNetLib": version_property("LiteNetLibVersion"),
|
||||
"gameplayProtocol": "exact-per-tenant",
|
||||
},
|
||||
}
|
||||
pathlib.Path(args.output).write_text(
|
||||
json.dumps(provenance, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def command_record_container_build(args) -> None:
|
||||
path = pathlib.Path(args.provenance)
|
||||
provenance = load_json(path)
|
||||
tools = set(provenance.get("buildTools", []))
|
||||
tools.add(f"docker-buildx={args.buildx_version}")
|
||||
tools.add(f"buildkit={args.buildkit_version}")
|
||||
provenance["buildTools"] = sorted(tools)
|
||||
provenance["containerPlatform"] = "linux/amd64"
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", args.image_id):
|
||||
fail(f"Container image ID is invalid: {args.image_id}")
|
||||
provenance["containerImageId"] = args.image_id
|
||||
path.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def command_record_container_digest(args) -> None:
|
||||
if not re.fullmatch(
|
||||
r"git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}",
|
||||
args.digest,
|
||||
):
|
||||
fail(f"Published container digest is invalid: {args.digest}")
|
||||
release_dir = pathlib.Path(args.release_dir)
|
||||
provenance_path = release_dir / "release-provenance.json"
|
||||
provenance = load_json(provenance_path)
|
||||
provenance["containerDigest"] = args.digest
|
||||
provenance_path.write_text(
|
||||
json.dumps(provenance, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
(release_dir / "container-digest.txt").write_text(
|
||||
args.digest + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def command_normalize_container_sbom(args) -> None:
|
||||
path = pathlib.Path(args.file)
|
||||
document = load_json(path)
|
||||
created = dt.datetime.fromtimestamp(
|
||||
int(args.source_date_epoch), dt.timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
document["name"] = f"FinalFactory.Rendezvous.Container-{args.version}"
|
||||
document["documentNamespace"] = (
|
||||
"https://git.finalfactory.de/HeiKyu/Rendezvous/container-sbom/"
|
||||
f"{args.version}/{args.commit}"
|
||||
)
|
||||
creation = document.setdefault("creationInfo", {})
|
||||
creation["created"] = created
|
||||
creation["creators"] = ["Tool: Trivy-0.69.3"]
|
||||
if isinstance(document.get("packages"), list):
|
||||
document["packages"] = sorted(
|
||||
document["packages"],
|
||||
key=lambda item: (
|
||||
item.get("SPDXID", ""),
|
||||
item.get("name", ""),
|
||||
item.get("versionInfo", ""),
|
||||
),
|
||||
)
|
||||
if isinstance(document.get("relationships"), list):
|
||||
document["relationships"] = sorted(
|
||||
document["relationships"],
|
||||
key=lambda item: (
|
||||
item.get("spdxElementId", ""),
|
||||
item.get("relationshipType", ""),
|
||||
item.get("relatedSpdxElement", ""),
|
||||
),
|
||||
)
|
||||
path.write_text(
|
||||
json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
policy = subparsers.add_parser("policy")
|
||||
policy.add_argument("--root", required=True)
|
||||
policy.set_defaults(handler=command_policy)
|
||||
audit = subparsers.add_parser("audit")
|
||||
audit.add_argument("--input", required=True)
|
||||
audit.set_defaults(handler=command_audit)
|
||||
sbom = subparsers.add_parser("sbom")
|
||||
sbom.add_argument("--root", required=True)
|
||||
sbom.add_argument("--version", required=True)
|
||||
sbom.add_argument("--commit", required=True)
|
||||
sbom.add_argument("--source-date-epoch", required=True)
|
||||
sbom.add_argument("--server-deps", required=True)
|
||||
sbom.add_argument("--output", required=True)
|
||||
sbom.set_defaults(handler=command_sbom)
|
||||
verify = subparsers.add_parser("verify")
|
||||
verify.add_argument("--root", required=True)
|
||||
verify.add_argument("--release-dir", required=True)
|
||||
verify.add_argument("--version", required=True)
|
||||
verify.add_argument("--allow-dirty", action="store_true")
|
||||
verify.add_argument(
|
||||
"--phase",
|
||||
choices=("build", "publish-ready", "signing-ready", "published"),
|
||||
default="build",
|
||||
)
|
||||
verify.set_defaults(handler=command_verify)
|
||||
consumer = subparsers.add_parser("consumer")
|
||||
consumer.add_argument("--assets", required=True)
|
||||
consumer.add_argument("--version", required=True)
|
||||
consumer.set_defaults(handler=command_consumer)
|
||||
consumer_config = subparsers.add_parser("consumer-config")
|
||||
consumer_config.add_argument("--local-source", required=True)
|
||||
consumer_config.add_argument("--output", required=True)
|
||||
consumer_config.set_defaults(handler=command_consumer_config)
|
||||
source_link = subparsers.add_parser("source-link")
|
||||
source_link.add_argument("--file", required=True)
|
||||
source_link.add_argument("--commit", required=True)
|
||||
source_link.set_defaults(handler=command_source_link)
|
||||
normalize = subparsers.add_parser("normalize-package")
|
||||
normalize.add_argument("--package", required=True)
|
||||
normalize.add_argument("--source-date-epoch", required=True)
|
||||
normalize.add_argument("--version", required=True)
|
||||
normalize.set_defaults(handler=command_normalize_package)
|
||||
provenance = subparsers.add_parser("provenance")
|
||||
provenance.add_argument("--root", required=True)
|
||||
provenance.add_argument("--version", required=True)
|
||||
provenance.add_argument("--commit", required=True)
|
||||
provenance.add_argument("--tree-state", required=True)
|
||||
provenance.add_argument("--source-date-epoch", required=True)
|
||||
provenance.add_argument("--dotnet-sdk", required=True)
|
||||
provenance.add_argument("--build-tool", action="append", default=[])
|
||||
provenance.add_argument("--base-digest", action="append", default=[])
|
||||
provenance.add_argument("--builder-base", action="append", default=[])
|
||||
provenance.add_argument("--output", required=True)
|
||||
provenance.set_defaults(handler=command_provenance)
|
||||
record_container = subparsers.add_parser("record-container-build")
|
||||
record_container.add_argument("--provenance", required=True)
|
||||
record_container.add_argument("--buildx-version", required=True)
|
||||
record_container.add_argument("--buildkit-version", required=True)
|
||||
record_container.add_argument("--image-id", required=True)
|
||||
record_container.set_defaults(handler=command_record_container_build)
|
||||
record_digest = subparsers.add_parser("record-container-digest")
|
||||
record_digest.add_argument("--release-dir", required=True)
|
||||
record_digest.add_argument("--digest", required=True)
|
||||
record_digest.set_defaults(handler=command_record_container_digest)
|
||||
normalize_container_sbom = subparsers.add_parser("normalize-container-sbom")
|
||||
normalize_container_sbom.add_argument("--file", required=True)
|
||||
normalize_container_sbom.add_argument("--version", required=True)
|
||||
normalize_container_sbom.add_argument("--commit", required=True)
|
||||
normalize_container_sbom.add_argument("--source-date-epoch", required=True)
|
||||
normalize_container_sbom.set_defaults(handler=command_normalize_container_sbom)
|
||||
args = parser.parse_args()
|
||||
args.handler(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
version="${1:-}"
|
||||
output="${2:-}"
|
||||
|
||||
property() {
|
||||
sed -n "s:.*<$1>\(.*\)</$1>.*:\1:p" "$root/eng/Versions.props"
|
||||
}
|
||||
|
||||
if [[ -z "$version" ]]; then
|
||||
version="$(property RendezvousVersion)"
|
||||
fi
|
||||
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+\.)*[0-9A-Za-z-]+)?(\+([0-9A-Za-z-]+\.)*[0-9A-Za-z-]+)?$'
|
||||
if [[ ! "$version" =~ $semver ]]; then
|
||||
echo "Release version is not valid SemVer: $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
prerelease="${version%%+*}"
|
||||
if [[ "$prerelease" == *-* ]]; then
|
||||
prerelease="${prerelease#*-}"
|
||||
IFS='.' read -r -a prerelease_identifiers <<<"$prerelease"
|
||||
for identifier in "${prerelease_identifiers[@]}"; do
|
||||
if [[ "$identifier" =~ ^[0-9]+$ && ! "$identifier" =~ ^(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "Numeric prerelease identifiers must not contain leading zeroes: $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ "$version" != "$(property RendezvousVersion)" ]]; then
|
||||
echo "Requested version $version differs from eng/Versions.props." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for command in dotnet git python3 tar gzip sha256sum cmp jq; do
|
||||
command -v "$command" >/dev/null || {
|
||||
echo "Required release command is unavailable: $command" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
commit="$(git -C "$root" rev-parse HEAD)"
|
||||
source_date_epoch="$(git -C "$root" show -s --format=%ct "$commit")"
|
||||
tree_state=clean
|
||||
if [[ -n "$(git -C "$root" status --porcelain --untracked-files=normal)" ]]; then
|
||||
tree_state=dirty
|
||||
fi
|
||||
allow_dirty=()
|
||||
if [[ "$tree_state" != clean ]]; then
|
||||
if [[ "${RENDEZVOUS_RELEASE_ALLOW_DIRTY:-0}" != 1 ]]; then
|
||||
echo "A formal release must be built from a clean Git tree." >&2
|
||||
exit 1
|
||||
fi
|
||||
allow_dirty=(--allow-dirty)
|
||||
fi
|
||||
|
||||
if [[ -z "$output" ]]; then
|
||||
output="$root/artifacts/release/$version"
|
||||
fi
|
||||
if [[ -e "$output" ]]; then
|
||||
echo "Release output already exists; refusing to overwrite: $output" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$(dirname "$output")"
|
||||
output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
|
||||
|
||||
work="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-release.XXXXXX")"
|
||||
cleanup() {
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
mkdir -p "$work/pack-1" "$work/pack-2" "$work/publish-1" "$work/publish-2" "$output"
|
||||
|
||||
create_server_archive() {
|
||||
local publish_directory="$1"
|
||||
local destination="$2"
|
||||
tar --sort=name \
|
||||
--mtime="@$source_date_epoch" \
|
||||
--owner=0 --group=0 --numeric-owner \
|
||||
-C "$publish_directory" -cf - . \
|
||||
| gzip -n >"$destination"
|
||||
}
|
||||
|
||||
common=(
|
||||
-p:ContinuousIntegrationBuild=true
|
||||
-p:PackageVersion="$version"
|
||||
-p:RepositoryCommit="$commit"
|
||||
-p:SourceRevisionId="$commit"
|
||||
)
|
||||
|
||||
cd "$root"
|
||||
dotnet restore Rendezvous.slnx --locked-mode
|
||||
python3 eng/release_artifacts.py policy --root "$root" >"$work/dependency-policy.json"
|
||||
dotnet package list \
|
||||
--project Rendezvous.slnx \
|
||||
--vulnerable \
|
||||
--include-transitive \
|
||||
--no-restore \
|
||||
--format json >"$work/nuget-vulnerabilities.json"
|
||||
python3 eng/release_artifacts.py audit --input "$work/nuget-vulnerabilities.json"
|
||||
dotnet format Rendezvous.slnx --verify-no-changes --no-restore
|
||||
api_before="$(sha256sum docs/api/*.json)"
|
||||
dotnet build Rendezvous.slnx --configuration Release --no-restore "${common[@]}"
|
||||
cp src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.dll \
|
||||
"$work/FinalFactory.Rendezvous.Server.first.dll"
|
||||
cp src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.pdb \
|
||||
"$work/FinalFactory.Rendezvous.Server.first.pdb"
|
||||
dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
|
||||
--configuration Release \
|
||||
--no-build \
|
||||
--no-restore \
|
||||
--output "$work/publish-1" \
|
||||
-p:UseAppHost=false \
|
||||
-p:OpenApiGenerateDocuments=false \
|
||||
"${common[@]}"
|
||||
create_server_archive "$work/publish-1" "$work/FinalFactory.Rendezvous.Server.first.tar.gz"
|
||||
if [[ "$tree_state" == clean ]]; then
|
||||
git diff --exit-code -- docs/api
|
||||
elif [[ "$api_before" != "$(sha256sum docs/api/*.json)" ]]; then
|
||||
echo "Generated OpenAPI changed during the release build." >&2
|
||||
exit 1
|
||||
fi
|
||||
dotnet test Rendezvous.slnx --configuration Release --no-build
|
||||
|
||||
for project in Client Contracts; do
|
||||
dotnet pack "src/FinalFactory.Rendezvous.$project/FinalFactory.Rendezvous.$project.csproj" \
|
||||
--configuration Release --no-build --output "$work/pack-1" "${common[@]}"
|
||||
done
|
||||
for package in "$work/pack-1"/*; do
|
||||
python3 eng/release_artifacts.py normalize-package \
|
||||
--package "$package" \
|
||||
--source-date-epoch "$source_date_epoch" \
|
||||
--version "$version"
|
||||
done
|
||||
|
||||
# Rebuild from the locked graph and prove package byte reproducibility.
|
||||
dotnet clean Rendezvous.slnx --configuration Release >/dev/null
|
||||
dotnet restore Rendezvous.slnx --locked-mode
|
||||
dotnet build Rendezvous.slnx --configuration Release --no-restore "${common[@]}"
|
||||
for project in Client Contracts; do
|
||||
python3 eng/release_artifacts.py source-link \
|
||||
--file "src/FinalFactory.Rendezvous.$project/obj/Release/netstandard2.1/FinalFactory.Rendezvous.$project.sourcelink.json" \
|
||||
--commit "$commit"
|
||||
done
|
||||
cmp --silent \
|
||||
"$work/FinalFactory.Rendezvous.Server.first.dll" \
|
||||
src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.dll || {
|
||||
echo "Server assembly is not byte reproducible." >&2
|
||||
exit 1
|
||||
}
|
||||
cmp --silent \
|
||||
"$work/FinalFactory.Rendezvous.Server.first.pdb" \
|
||||
src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.pdb || {
|
||||
echo "Server portable PDB is not byte reproducible." >&2
|
||||
exit 1
|
||||
}
|
||||
for project in Client Contracts; do
|
||||
dotnet pack "src/FinalFactory.Rendezvous.$project/FinalFactory.Rendezvous.$project.csproj" \
|
||||
--configuration Release --no-build --output "$work/pack-2" "${common[@]}"
|
||||
done
|
||||
for package in "$work/pack-2"/*; do
|
||||
python3 eng/release_artifacts.py normalize-package \
|
||||
--package "$package" \
|
||||
--source-date-epoch "$source_date_epoch" \
|
||||
--version "$version"
|
||||
done
|
||||
for package in "$work/pack-1"/*; do
|
||||
cmp --silent "$package" "$work/pack-2/$(basename "$package")" || {
|
||||
echo "Package is not byte reproducible: $(basename "$package")" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
cp "$work/pack-1"/* "$output/"
|
||||
|
||||
python3 eng/release_artifacts.py consumer-config \
|
||||
--local-source "$output" \
|
||||
--output "$work/consumer.NuGet.config"
|
||||
for consumer in spacegame unscouted; do
|
||||
project="$root/tests/consumers/$consumer/$(find "$root/tests/consumers/$consumer" -maxdepth 1 -name '*.csproj' -printf '%f\n')"
|
||||
packages="$work/consumer-packages-$consumer"
|
||||
dotnet restore "$project" \
|
||||
-p:RendezvousPackageVersion="$version" \
|
||||
-p:RestoreLockedMode=false \
|
||||
--packages "$packages" \
|
||||
--configfile "$work/consumer.NuGet.config" \
|
||||
--force-evaluate
|
||||
dotnet build "$project" \
|
||||
--configuration Release --no-restore \
|
||||
-p:RendezvousPackageVersion="$version"
|
||||
assets="$(dirname "$project")/obj/project.assets.json"
|
||||
python3 eng/release_artifacts.py consumer --assets "$assets" --version "$version"
|
||||
done
|
||||
|
||||
dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
|
||||
--configuration Release \
|
||||
--no-build \
|
||||
--no-restore \
|
||||
--output "$work/publish-2" \
|
||||
-p:UseAppHost=false \
|
||||
-p:OpenApiGenerateDocuments=false \
|
||||
"${common[@]}"
|
||||
server_archive="$output/FinalFactory.Rendezvous.Server.$version.linux-x64.tar.gz"
|
||||
create_server_archive "$work/publish-2" "$server_archive"
|
||||
cmp --silent "$work/FinalFactory.Rendezvous.Server.first.tar.gz" "$server_archive" || {
|
||||
echo "Server archive is not byte reproducible." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cp CHANGELOG.md "$output/CHANGELOG.md"
|
||||
python3 eng/release_artifacts.py sbom \
|
||||
--root "$root" \
|
||||
--version "$version" \
|
||||
--commit "$commit" \
|
||||
--source-date-epoch "$source_date_epoch" \
|
||||
--server-deps "$work/publish-2/FinalFactory.Rendezvous.Server.deps.json" \
|
||||
--output "$output/FinalFactory.Rendezvous.$version.spdx.json"
|
||||
|
||||
provenance=(
|
||||
provenance
|
||||
--root "$root"
|
||||
--version "$version"
|
||||
--commit "$commit"
|
||||
--tree-state "$tree_state"
|
||||
--source-date-epoch "$source_date_epoch"
|
||||
--dotnet-sdk "$(dotnet --version)"
|
||||
--build-tool "python=$(python3 --version 2>&1)"
|
||||
--build-tool "tar=$(tar --version | sed -n '1p')"
|
||||
--build-tool "gzip=$(gzip --version | sed -n '1p')"
|
||||
--build-tool "jq=$(jq --version)"
|
||||
--output "$output/release-provenance.json"
|
||||
)
|
||||
while IFS= read -r base; do
|
||||
provenance+=(--base-digest "$base")
|
||||
done < <(sed -n 's/^FROM \([^ ]*\).*/\1/p' Dockerfile)
|
||||
while IFS= read -r base; do
|
||||
provenance+=(--builder-base "$base")
|
||||
done < <(sed -n 's/^FROM \([^ ]*\).*/\1/p' eng/release-builder.Dockerfile)
|
||||
python3 eng/release_artifacts.py "${provenance[@]}"
|
||||
|
||||
(
|
||||
cd "$output"
|
||||
find . -maxdepth 1 -type f ! -name checksums.sha256 -printf '%f\n' \
|
||||
| LC_ALL=C sort \
|
||||
| xargs sha256sum >checksums.sha256
|
||||
)
|
||||
python3 eng/release_artifacts.py verify \
|
||||
--root "$root" \
|
||||
--release-dir "$output" \
|
||||
--version "$version" \
|
||||
"${allow_dirty[@]}"
|
||||
|
||||
echo "Release artifacts verified at $output"
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
base="${1:-origin/main}"
|
||||
|
||||
if ! git -C "$root" cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||
echo "Compatibility base does not exist; this is valid only for the initial version baseline: $base"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$(git -C "$root" rev-parse "$base")" == "$(git -C "$root" rev-parse HEAD)" ]]; then
|
||||
base="HEAD^"
|
||||
fi
|
||||
if ! git -C "$root" cat-file -e "$base:eng/Versions.props" 2>/dev/null; then
|
||||
echo "Base has no release version manifest; accepting the initial compatibility baseline."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_property() {
|
||||
sed -n "s:.*<$1>\(.*\)</$1>.*:\1:p" "$root/eng/Versions.props"
|
||||
}
|
||||
base_property() {
|
||||
git -C "$root" show "$base:eng/Versions.props" \
|
||||
| sed -n "s:.*<$1>\(.*\)</$1>.*:\1:p"
|
||||
}
|
||||
changed() {
|
||||
git -C "$root" diff --name-only "$base"...HEAD -- "$@" | grep -q .
|
||||
}
|
||||
require_increase() {
|
||||
local property="$1"
|
||||
local description="$2"
|
||||
shift 2
|
||||
if changed "$@"; then
|
||||
local before after
|
||||
before="$(base_property "$property")"
|
||||
after="$(current_property "$property")"
|
||||
if [[ ! "$before" =~ ^[0-9]+$ || ! "$after" =~ ^[0-9]+$ || "$after" -le "$before" ]]; then
|
||||
echo "$description changed without increasing $property ($before -> $after)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
require_increase RendezvousMajorVersion "Published .NET API snapshot" \
|
||||
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/client-public-api.txt' \
|
||||
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/contracts-public-api.txt'
|
||||
require_increase HttpContractVersion "HTTP/OpenAPI contract evidence" \
|
||||
'docs/api/*.json' \
|
||||
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/*.json' \
|
||||
':(exclude)tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/connection-ticket.json'
|
||||
require_increase UdpContractVersion "UDP contract evidence" \
|
||||
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/*.hex'
|
||||
require_increase ConnectionTicketFormatVersion "Connection-ticket format evidence" \
|
||||
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/connection-ticket.json'
|
||||
|
||||
echo "Compatibility changes are paired with the required version increase."
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
tag="${1:-${GITHUB_REF_NAME:-}}"
|
||||
version="$(sed -n 's:.*<RendezvousVersion>\(.*\)</RendezvousVersion>.*:\1:p' "$root/eng/Versions.props")"
|
||||
|
||||
if [[ "$tag" != "v$version" ]]; then
|
||||
echo "Release tag $tag does not match central version v$version." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$(git -C "$root" status --porcelain --untracked-files=normal)" ]]; then
|
||||
echo "Release tag checkout is not clean." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(git -C "$root" tag --points-at HEAD --list "$tag")" != "$tag" ]]; then
|
||||
echo "Release tag $tag does not point at the checked-out commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eq "^## $version - [0-9]{4}-[0-9]{2}-[0-9]{2}$" "$root/CHANGELOG.md"; then
|
||||
echo "CHANGELOG.md must contain a dated heading for $version." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Eq "^## $version - Unreleased$" "$root/CHANGELOG.md"; then
|
||||
echo "Release $version is still marked Unreleased." >&2
|
||||
exit 1
|
||||
fi
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
version="${1:?usage: finalize-release-candidate.sh VERSION RELEASE_DIRECTORY}"
|
||||
release_dir="${2:?usage: finalize-release-candidate.sh VERSION RELEASE_DIRECTORY}"
|
||||
container_sbom="$release_dir/FinalFactory.Rendezvous.Container.$version.spdx.json"
|
||||
|
||||
[[ -s "$container_sbom" ]] || {
|
||||
echo "Container SBOM is missing or empty: $container_sbom" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
(
|
||||
cd "$release_dir"
|
||||
find . -maxdepth 1 -type f ! -name checksums.sha256 -printf '%f\n' \
|
||||
| LC_ALL=C sort \
|
||||
| xargs sha256sum >checksums.sha256
|
||||
)
|
||||
python3 "$root/eng/release_artifacts.py" verify \
|
||||
--root "$root" \
|
||||
--release-dir "$release_dir" \
|
||||
--version "$version" \
|
||||
--phase publish-ready
|
||||
|
||||
echo "Finalized publish-ready release candidate $version"
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
version="${1:?usage: finalize-signing-ready-release.sh VERSION RELEASE_DIRECTORY}"
|
||||
release_dir="${2:?usage: finalize-signing-ready-release.sh VERSION RELEASE_DIRECTORY}"
|
||||
|
||||
(
|
||||
cd "$release_dir"
|
||||
find . -maxdepth 1 -type f \
|
||||
! -name checksums.sha256 \
|
||||
! -name checksums.sha256.bundle \
|
||||
-printf '%f\n' \
|
||||
| LC_ALL=C sort \
|
||||
| xargs sha256sum >checksums.sha256
|
||||
)
|
||||
python3 "$root/eng/release_artifacts.py" verify \
|
||||
--root "$root" \
|
||||
--release-dir "$release_dir" \
|
||||
--version "$version" \
|
||||
--phase signing-ready
|
||||
|
||||
echo "Finalized signing-ready release $version"
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LOCAL_KEY="${RENDEZVOUS_SMOKE_LOCAL_KEY:-$ROOT/deploy/compose/secrets/signing-key}"
|
||||
GAME_ID="${RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID:-space-game}"
|
||||
|
||||
case "$GAME_ID" in
|
||||
space-game)
|
||||
KEY_ID="local-smoke-1"
|
||||
SUBJECT="local-smoke-host"
|
||||
;;
|
||||
unscouted)
|
||||
KEY_ID="local-smoke-unscouted-1"
|
||||
SUBJECT="local-smoke-unscouted-host"
|
||||
;;
|
||||
*)
|
||||
printf 'RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID must be space-game or unscouted.\n' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if (( $# != 0 )); then
|
||||
printf 'This helper accepts no arguments; select only a provisioned local game through RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
command -v python3 >/dev/null || {
|
||||
printf 'Missing required command: python3\n' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# This is deliberately a local-fixture tool, not a general credential issuer.
|
||||
# Python reads the raw key from the protected file; key material never appears in
|
||||
# a child process argument, environment value, temporary file, or command output.
|
||||
python3 - "$LOCAL_KEY" "$GAME_ID" "$KEY_ID" "$SUBJECT" <<'PY'
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
|
||||
key_path = sys.argv[1]
|
||||
game_id = sys.argv[2]
|
||||
key_id = sys.argv[3]
|
||||
subject = sys.argv[4]
|
||||
try:
|
||||
metadata = os.lstat(key_path)
|
||||
except FileNotFoundError:
|
||||
raise SystemExit(f"Local Compose smoke key does not exist: {key_path}")
|
||||
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
raise SystemExit(f"Local Compose smoke key must be a regular non-symlink file: {key_path}")
|
||||
parent_path = os.path.dirname(os.path.abspath(key_path))
|
||||
parent = os.lstat(parent_path)
|
||||
if stat.S_ISLNK(parent.st_mode) or not stat.S_ISDIR(parent.st_mode):
|
||||
raise SystemExit(f"Local Compose secret directory must be a non-symlink directory: {parent_path}")
|
||||
if parent.st_uid != os.geteuid() or parent.st_mode & 0o077:
|
||||
raise SystemExit(f"Local Compose secret directory must be owned by this user with mode 0700: {parent_path}")
|
||||
if metadata.st_uid != os.geteuid() or metadata.st_mode & 0o077 or metadata.st_nlink != 1:
|
||||
raise SystemExit(f"Local Compose smoke key must be owned by this user, single-linked, and private to its owner: {key_path}")
|
||||
|
||||
with open(key_path, "rb") as key_file:
|
||||
key = key_file.read(33)
|
||||
if len(key) != 32:
|
||||
raise SystemExit(f"Local Compose smoke key must be exactly 32 bytes: {key_path}")
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"version": 1,
|
||||
"issuer": "final-factory-rendezvous-smoke",
|
||||
"audience": "rendezvous-service",
|
||||
"subject": subject,
|
||||
"kind": "dedicatedPublisher",
|
||||
"gameId": game_id,
|
||||
"environmentId": "smoke",
|
||||
"regions": ["local"],
|
||||
"permissions": [],
|
||||
"issuedAtUnixSeconds": now,
|
||||
"notBeforeUnixSeconds": now,
|
||||
"expiresAtUnixSeconds": now + 600,
|
||||
"nonce": secrets.token_hex(16),
|
||||
}
|
||||
|
||||
def base64url(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
encoded = base64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||
signed = f"rv1.{key_id}.{encoded}"
|
||||
signature = base64url(hmac.new(key, signed.encode("ascii"), hashlib.sha256).digest())
|
||||
print(f"{signed}.{signature}")
|
||||
PY
|
||||
Executable
+153
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
version="${1:?usage: publish-release.sh VERSION RELEASE_DIRECTORY}"
|
||||
release_dir="${2:?usage: publish-release.sh VERSION RELEASE_DIRECTORY}"
|
||||
api="${RENDEZVOUS_GITEA_API:-https://git.finalfactory.de/api/v1}"
|
||||
registry="${RENDEZVOUS_CONTAINER_REGISTRY:-git.finalfactory.de}"
|
||||
image="$registry/heikyu/rendezvous:$version"
|
||||
token="${RENDEZVOUS_RELEASE_TOKEN:?RENDEZVOUS_RELEASE_TOKEN is required}"
|
||||
username="${RENDEZVOUS_RELEASE_USERNAME:?RENDEZVOUS_RELEASE_USERNAME is required}"
|
||||
release_builder="${RENDEZVOUS_RELEASE_BUILDER:?RENDEZVOUS_RELEASE_BUILDER is required}"
|
||||
docker_config="$(mktemp -d)"
|
||||
curl_config="$(mktemp)"
|
||||
release_request=""
|
||||
release_response=""
|
||||
cleanup() {
|
||||
[[ -z "$release_request" ]] || rm -f "$release_request"
|
||||
[[ -z "$release_response" ]] || rm -f "$release_response"
|
||||
rm -f "$curl_config"
|
||||
rm -rf "$docker_config"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
chmod 0700 "$docker_config"
|
||||
chmod 0600 "$curl_config"
|
||||
printf 'header = "Authorization: token %s"\n' "$token" >"$curl_config"
|
||||
export DOCKER_CONFIG="$docker_config"
|
||||
|
||||
for command in cosign curl docker dotnet jq; do
|
||||
command -v "$command" >/dev/null || {
|
||||
echo "Required publication command is unavailable: $command" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
run_release_builder() {
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
--volume "$root:/source:ro" \
|
||||
--volume "$release_dir:$release_dir" \
|
||||
--workdir /source \
|
||||
"$release_builder" "$@"
|
||||
}
|
||||
|
||||
"$root/scripts/check-release-tag.sh" "v$version"
|
||||
"$root/scripts/verify-release.sh" "$version" "$release_dir" publish-ready
|
||||
|
||||
require_absent() {
|
||||
local description="$1"
|
||||
local url="$2"
|
||||
local status
|
||||
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||
--config "$curl_config" "$url")"
|
||||
if [[ "$status" != 404 ]]; then
|
||||
echo "$description must not exist before publication (HTTP $status)." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Gitea package versions are immutable. Require all destinations to be empty
|
||||
# before the first write so a tag can never become a silent partial rerun.
|
||||
require_absent "Client package $version" \
|
||||
"$api/packages/HeiKyu/nuget/FinalFactory.Rendezvous.Client/$version"
|
||||
require_absent "Contracts package $version" \
|
||||
"$api/packages/HeiKyu/nuget/FinalFactory.Rendezvous.Contracts/$version"
|
||||
require_absent "Container $version" \
|
||||
"$api/packages/HeiKyu/container/rendezvous/$version"
|
||||
require_absent "Release v$version" \
|
||||
"$api/repos/HeiKyu/Rendezvous/releases/tags/v$version"
|
||||
|
||||
feed="https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json"
|
||||
for package in \
|
||||
"$release_dir/FinalFactory.Rendezvous.Contracts.$version.nupkg" \
|
||||
"$release_dir/FinalFactory.Rendezvous.Client.$version.nupkg"; do
|
||||
dotnet nuget push "$package" \
|
||||
--source "$feed" \
|
||||
--api-key "$token" \
|
||||
--timeout 300
|
||||
done
|
||||
|
||||
printf '%s' "$token" | docker login "$registry" --username "$username" --password-stdin
|
||||
expected_image_id="$(jq -er '.containerImageId' "$release_dir/release-provenance.json")"
|
||||
current_image_id="$(docker image inspect --format '{{.Id}}' "$image")"
|
||||
if [[ "$current_image_id" != "$expected_image_id" ]]; then
|
||||
echo "Local release tag changed after staging ($expected_image_id -> $current_image_id)." >&2
|
||||
exit 1
|
||||
fi
|
||||
docker push "$image"
|
||||
digest_ref="$(docker inspect --format '{{index .RepoDigests 0}}' "$image")"
|
||||
if [[ ! "$digest_ref" =~ ^git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "Registry did not return an immutable Rendezvous image digest: $digest_ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_release_builder python3 eng/release_artifacts.py record-container-digest \
|
||||
--release-dir "$release_dir" \
|
||||
--digest "$digest_ref"
|
||||
cosign public-key --key env://COSIGN_PRIVATE_KEY >"$release_dir/cosign.pub"
|
||||
run_release_builder ./scripts/finalize-signing-ready-release.sh \
|
||||
"$version" "$release_dir"
|
||||
|
||||
cosign sign --yes --key env://COSIGN_PRIVATE_KEY "$digest_ref"
|
||||
cosign attest --yes \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
--type https://finalfactory.de/rendezvous/release-provenance/v1 \
|
||||
--predicate "$release_dir/release-provenance.json" \
|
||||
"$digest_ref"
|
||||
cosign sign-blob --yes \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
--bundle "$release_dir/checksums.sha256.bundle" \
|
||||
"$release_dir/checksums.sha256"
|
||||
"$root/scripts/verify-release.sh" "$version" "$release_dir" published
|
||||
cosign verify --key "$release_dir/cosign.pub" "$digest_ref" >/dev/null
|
||||
cosign verify-attestation \
|
||||
--key "$release_dir/cosign.pub" \
|
||||
--type https://finalfactory.de/rendezvous/release-provenance/v1 \
|
||||
"$digest_ref" >/dev/null
|
||||
cosign verify-blob \
|
||||
--key "$release_dir/cosign.pub" \
|
||||
--bundle "$release_dir/checksums.sha256.bundle" \
|
||||
"$release_dir/checksums.sha256" >/dev/null
|
||||
|
||||
release_request="$(mktemp)"
|
||||
release_response="$(mktemp)"
|
||||
prerelease=false
|
||||
if [[ "$version" == *-* ]]; then
|
||||
prerelease=true
|
||||
fi
|
||||
jq -n \
|
||||
--arg tag "v$version" \
|
||||
--arg commit "${GITHUB_SHA:?GITHUB_SHA is required}" \
|
||||
--arg digest "$digest_ref" \
|
||||
--argjson prerelease "$prerelease" \
|
||||
--rawfile changelog "$release_dir/CHANGELOG.md" \
|
||||
'{tag_name:$tag,target_commitish:$commit,name:("Rendezvous " + $tag),body:($changelog + "\n\n## Immutable container\n\n`" + $digest + "`\n"),draft:false,prerelease:$prerelease}' \
|
||||
>"$release_request"
|
||||
curl --fail --silent --show-error \
|
||||
--request POST \
|
||||
--config "$curl_config" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@$release_request" \
|
||||
"$api/repos/HeiKyu/Rendezvous/releases" >"$release_response"
|
||||
release_id="$(jq -er '.id' "$release_response")"
|
||||
|
||||
for artifact in "$release_dir"/*; do
|
||||
curl --fail --silent --show-error \
|
||||
--request POST \
|
||||
--config "$curl_config" \
|
||||
--form "attachment=@$artifact" \
|
||||
"$api/repos/HeiKyu/Rendezvous/releases/$release_id/assets?name=$(basename "$artifact")" \
|
||||
>/dev/null
|
||||
done
|
||||
|
||||
echo "Published immutable release v$version with container $digest_ref"
|
||||
@@ -13,7 +13,7 @@ ENVIRONMENT_ID="${RENDEZVOUS_SMOKE_ENVIRONMENT_ID:-smoke}"
|
||||
REGION="${RENDEZVOUS_SMOKE_REGION:-local}"
|
||||
PROTOCOL_VERSION="${RENDEZVOUS_SMOKE_PROTOCOL_VERSION:-1}"
|
||||
|
||||
for command in curl date dotnet jq mktemp od openssl tail tr wc; do
|
||||
for command in curl dotnet jq mktemp tail; do
|
||||
command -v "$command" >/dev/null || {
|
||||
printf 'Missing required command: %s\n' "$command" >&2
|
||||
exit 2
|
||||
@@ -35,39 +35,14 @@ for scoped_value in "$GAME_ID" "$ENVIRONMENT_ID" "$REGION"; do
|
||||
fi
|
||||
done
|
||||
|
||||
base64url() {
|
||||
openssl base64 -A | tr '+/' '-_' | tr -d '='
|
||||
}
|
||||
|
||||
local_credential() {
|
||||
if [[ ! -f "$LOCAL_KEY" ]] || [[ "$(wc -c < "$LOCAL_KEY")" -ne 32 ]]; then
|
||||
printf 'Local Compose smoke key must be exactly 32 bytes: %s\n' "$LOCAL_KEY" >&2
|
||||
if [[ "$GAME_ID" != space-game || "$ENVIRONMENT_ID" != smoke \
|
||||
|| "$REGION" != local || "$PROTOCOL_VERSION" != 1 ]]; then
|
||||
printf 'The local credential helper supports only space-game/smoke/local protocol 1. Supply RENDEZVOUS_PUBLISHER_CREDENTIAL for any other scope.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
local now expires nonce payload encoded signed hex signature
|
||||
now="$(date +%s)"
|
||||
expires="$((now + 600))"
|
||||
nonce="$(openssl rand -hex 16)"
|
||||
payload="$(jq -cn \
|
||||
--arg issuer final-factory-rendezvous-smoke \
|
||||
--arg audience rendezvous-service \
|
||||
--arg subject local-smoke-host \
|
||||
--arg kind dedicatedPublisher \
|
||||
--arg gameId "$GAME_ID" \
|
||||
--arg environmentId "$ENVIRONMENT_ID" \
|
||||
--arg region "$REGION" \
|
||||
--arg nonce "$nonce" \
|
||||
--argjson now "$now" \
|
||||
--argjson expires "$expires" \
|
||||
'{version:1,issuer:$issuer,audience:$audience,subject:$subject,kind:$kind,gameId:$gameId,environmentId:$environmentId,regions:[$region],permissions:[],issuedAtUnixSeconds:$now,notBeforeUnixSeconds:$now,expiresAtUnixSeconds:$expires,nonce:$nonce}')"
|
||||
encoded="$(printf '%s' "$payload" | base64url)"
|
||||
signed="rv1.local-smoke-1.$encoded"
|
||||
hex="$(od -An -v -tx1 "$LOCAL_KEY" | tr -d ' \n')"
|
||||
signature="$(printf '%s' "$signed" \
|
||||
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$hex" -binary \
|
||||
| base64url)"
|
||||
printf '%s.%s' "$signed" "$signature"
|
||||
RENDEZVOUS_SMOKE_LOCAL_KEY="$LOCAL_KEY" \
|
||||
"$ROOT/scripts/mint-local-publisher-credential.sh"
|
||||
}
|
||||
|
||||
credential="${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}"
|
||||
|
||||
Executable
+78
@@ -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
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
version="${1:?usage: verify-release.sh VERSION [RELEASE_DIRECTORY] [PHASE]}"
|
||||
release_dir="${2:-$root/artifacts/release/$version}"
|
||||
phase="${3:-build}"
|
||||
|
||||
python3 "$root/eng/release_artifacts.py" verify \
|
||||
--root "$root" \
|
||||
--release-dir "$release_dir" \
|
||||
--version "$version" \
|
||||
--phase "$phase"
|
||||
@@ -7,10 +7,12 @@
|
||||
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
|
||||
<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>
|
||||
|
||||
@@ -67,12 +67,17 @@ factory does not open a socket, and synchronized events must remain enabled:
|
||||
```csharp
|
||||
RendezvousNetListener networkEvents = new();
|
||||
NetManager gameplayNetManager = networkEvents.CreateManager();
|
||||
gameplayNetManager.ChannelsCount = 3; // example: configure the game protocol first
|
||||
if (!gameplayNetManager.Start(0))
|
||||
{
|
||||
throw new InvalidOperationException("The gameplay UDP socket could not start.");
|
||||
}
|
||||
```
|
||||
|
||||
LiteNetLib defaults to one QoS channel. Set `ChannelsCount` before `Start` when
|
||||
the game protocol uses more than one; both game processes must agree. Rendezvous
|
||||
does not reserve or reinterpret any gameplay channel.
|
||||
|
||||
The host polls join invitations asynchronously; that method only queues a
|
||||
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
|
||||
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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=="
|
||||
},
|
||||
|
||||
@@ -13,13 +13,16 @@ authenticated direct peer, and answers a bounded ping/echo/ack/completion exchan
|
||||
LiteNetLib socket, proves direct traffic, reports the typed outcome, and exits.
|
||||
|
||||
Run `dotnet run --project src/FinalFactory.Rendezvous.TestClient -- --help` for
|
||||
the complete option reference. A typical script-mode invocation is:
|
||||
the complete option reference. The repository's
|
||||
[start-to-finish guide](../../docs/integration/test-client.md) provides an
|
||||
executable local Compose setup, safe failure drill, JSON automation, and a
|
||||
phase-by-phase diagnostic table. A typical deployment invocation is:
|
||||
|
||||
```bash
|
||||
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<credential from the deployment boundary>'
|
||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
|
||||
--game space-game --environment development --region local --protocol 1 \
|
||||
host --service https://rendezvous.example/ --mediator rendezvous.example:9050 \
|
||||
--game space-game --environment production --region eu-central --protocol 1 \
|
||||
--script --json --exit-after-echo
|
||||
```
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
"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, )"
|
||||
}
|
||||
},
|
||||
"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=="
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -207,10 +207,16 @@ public sealed class ProductionProcessTests
|
||||
string root = RepositoryRoot();
|
||||
int httpPort = ReserveTcpPort();
|
||||
int udpPort = ReserveUdpPort();
|
||||
string secretPath = Path.Combine(
|
||||
string secretDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"rendezvous-smoke-secret-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(secretDirectory);
|
||||
File.SetUnixFileMode(
|
||||
secretDirectory,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
string secretPath = Path.Combine(secretDirectory, "signing-key");
|
||||
await File.WriteAllBytesAsync(secretPath, RandomNumberGenerator.GetBytes(32));
|
||||
File.SetUnixFileMode(secretPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
Process? server = null;
|
||||
Process? smoke = null;
|
||||
try
|
||||
@@ -232,6 +238,10 @@ public sealed class ProductionProcessTests
|
||||
"--Rendezvous:Provisioning:SigningKeys:0:NotBefore", now.AddHours(-1).ToString("O"),
|
||||
"--Rendezvous:Provisioning:SigningKeys:0:SignUntil", now.AddHours(1).ToString("O"),
|
||||
"--Rendezvous:Provisioning:SigningKeys:0:VerifyUntil", now.AddHours(2).ToString("O"),
|
||||
"--Rendezvous:Provisioning:SigningKeys:1:SecretReference", $"file:{secretPath}",
|
||||
"--Rendezvous:Provisioning:SigningKeys:1:NotBefore", now.AddHours(-1).ToString("O"),
|
||||
"--Rendezvous:Provisioning:SigningKeys:1:SignUntil", now.AddHours(1).ToString("O"),
|
||||
"--Rendezvous:Provisioning:SigningKeys:1:VerifyUntil", now.AddHours(2).ToString("O"),
|
||||
"--Rendezvous:Udp:Port", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"--Rendezvous:Deployment:PublicUdpPort", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
},
|
||||
@@ -294,6 +304,7 @@ public sealed class ProductionProcessTests
|
||||
}
|
||||
|
||||
File.Delete(secretPath);
|
||||
Directory.Delete(secretDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Documentation;
|
||||
|
||||
public sealed partial class DocumentationContractTests
|
||||
{
|
||||
private static readonly string[] IncidentScenarios =
|
||||
[
|
||||
"Abuse or authentication spike",
|
||||
"Signing key or issuer compromise",
|
||||
"Targeted listing or publisher revocation",
|
||||
"Planned restart or crash recovery",
|
||||
"Release rollback",
|
||||
"Capacity saturation",
|
||||
"Privacy or telemetry incident",
|
||||
"Dependency or base-image upgrade",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void TestClientGuideDocumentsTheExecutableSuccessAndFailureContracts()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
string guide = File.ReadAllText(Path.Combine(root, "docs", "integration", "test-client.md"));
|
||||
|
||||
Assert.Contains("space-game --environment smoke --region local --protocol 1", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("mint-local-publisher-credential.sh", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("join.connected", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("join.direct-traffic", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("browse.completed", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("--script --json", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("--run-seconds 60", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("for attempt in {1..45}", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("before the terminal-3", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("test \"$status\" -eq 11", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("no relay", guide, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("cannot guarantee", guide, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotMatch(ReusableCredential(), guide);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalCredentialHelperWhitelistsProvisionedGameScopesAndSmokeDelegatesToIt()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
string helper = File.ReadAllText(Path.Combine(root, "scripts", "mint-local-publisher-credential.sh"));
|
||||
string smoke = File.ReadAllText(Path.Combine(root, "scripts", "smoke-deployment.sh"));
|
||||
|
||||
Assert.Contains("if (( $# != 0 ));", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("space-game)", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("unscouted)", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("KEY_ID=\"local-smoke-1\"", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("KEY_ID=\"local-smoke-unscouted-1\"", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID must be space-game or unscouted", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("\"gameId\": game_id", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("\"environmentId\": \"smoke\"", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("\"regions\": [\"local\"]", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("now + 600", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("stat.S_ISLNK", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("parent.st_mode & 0o077", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("metadata.st_mode & 0o077", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("metadata.st_nlink != 1", helper, StringComparison.Ordinal);
|
||||
Assert.Contains("mint-local-publisher-credential.sh", smoke, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("hexkey:", smoke, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("openssl dgst", smoke, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnscoutedComposeTenantIsGameScopedAndMetadataBounded()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
using JsonDocument settings = JsonDocument.Parse(File.ReadAllText(
|
||||
Path.Combine(root, "deploy", "compose", "appsettings.Production.json")));
|
||||
JsonElement provisioning = settings.RootElement.GetProperty("Rendezvous").GetProperty("Provisioning");
|
||||
JsonElement game = provisioning.GetProperty("Games").EnumerateArray().Single(
|
||||
static item => item.GetProperty("GameId").GetString() == "unscouted");
|
||||
JsonElement key = provisioning.GetProperty("SigningKeys").EnumerateArray().Single(
|
||||
static item => item.GetProperty("KeyId").GetString() == "local-smoke-unscouted-1");
|
||||
|
||||
Assert.Equal("smoke", game.GetProperty("EnvironmentId").GetString());
|
||||
Assert.Equal([1], game.GetProperty("ProtocolVersions").EnumerateArray().Select(static value => value.GetInt32()));
|
||||
Assert.Equal(["mode", "mods", "world"], game.GetProperty("MetadataValueMaxBytes")
|
||||
.EnumerateObject().Select(static property => property.Name).Order(StringComparer.Ordinal));
|
||||
Assert.Equal(["mode", "mods", "world"], game.GetProperty("RequiredMetadataKeys")
|
||||
.EnumerateArray().Select(static value => value.GetString()).Order(StringComparer.Ordinal));
|
||||
Assert.Equal(3, game.GetProperty("MetadataMaxKeys").GetInt32());
|
||||
Assert.Equal("DedicatedEndpointAllowed", game.GetProperty("FallbackPolicy").GetString());
|
||||
Assert.Equal("unscouted", key.GetProperty("GameId").GetString());
|
||||
Assert.Equal("smoke", key.GetProperty("EnvironmentId").GetString());
|
||||
Assert.Equal(["DedicatedPublisher"], key.GetProperty("CredentialKinds")
|
||||
.EnumerateArray().Select(static value => value.GetString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryIncidentRunbookHasDetectContainRecoverAndVerifyGates()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
string runbooks = File.ReadAllText(Path.Combine(root, "docs", "operations", "incident-runbooks.md"));
|
||||
|
||||
for (int index = 0; index < IncidentScenarios.Length; index++)
|
||||
{
|
||||
string heading = $"## {IncidentScenarios[index]}";
|
||||
int start = runbooks.IndexOf(heading, StringComparison.Ordinal);
|
||||
Assert.True(start >= 0, $"Missing incident runbook heading: {heading}");
|
||||
int end = index + 1 < IncidentScenarios.Length
|
||||
? runbooks.IndexOf($"## {IncidentScenarios[index + 1]}", start, StringComparison.Ordinal)
|
||||
: runbooks.Length;
|
||||
Assert.True(end > start, $"Could not find the end of runbook: {heading}");
|
||||
string scenario = runbooks[start..end];
|
||||
|
||||
Assert.Contains("### Detect", scenario, StringComparison.Ordinal);
|
||||
Assert.Contains("### Contain", scenario, StringComparison.Ordinal);
|
||||
Assert.Contains("### Recover", scenario, StringComparison.Ordinal);
|
||||
Assert.Contains("### Verify", scenario, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.DoesNotMatch(ReusableCredential(), runbooks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DocumentedOperatorOperationsAndBodiesMatchTheReleasedOpenApi()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
string runbooks = File.ReadAllText(Path.Combine(root, "docs", "operations", "incident-runbooks.md"));
|
||||
using JsonDocument openApi = JsonDocument.Parse(File.ReadAllText(
|
||||
Path.Combine(root, "docs", "api", "rendezvous-v1.json")));
|
||||
JsonElement paths = openApi.RootElement.GetProperty("paths");
|
||||
|
||||
(string Method, string Path)[] documentedOperations = OperatorRoute().Matches(runbooks)
|
||||
.Cast<Match>()
|
||||
.Select(static match => (
|
||||
match.Groups["method"].Value.ToLowerInvariant(),
|
||||
match.Groups["path"].Value))
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
Assert.NotEmpty(documentedOperations);
|
||||
Assert.All(documentedOperations, operation =>
|
||||
Assert.True(
|
||||
paths.TryGetProperty(operation.Path, out JsonElement path)
|
||||
&& path.TryGetProperty(operation.Method, out _),
|
||||
$"OpenAPI does not contain {operation.Method.ToUpperInvariant()} {operation.Path}."));
|
||||
|
||||
Match[] actions = OperatorAction().Matches(runbooks).Cast<Match>().ToArray();
|
||||
Assert.Equal(4, actions.Length);
|
||||
foreach (Match action in actions)
|
||||
{
|
||||
string method = action.Groups["method"].Value.ToLowerInvariant();
|
||||
string path = action.Groups["path"].Value;
|
||||
using JsonDocument body = JsonDocument.Parse(action.Groups["body"].Value);
|
||||
string reference = paths.GetProperty(path)
|
||||
.GetProperty(method)
|
||||
.GetProperty("requestBody")
|
||||
.GetProperty("content")
|
||||
.GetProperty("application/json")
|
||||
.GetProperty("schema")
|
||||
.GetProperty("$ref")
|
||||
.GetString()!;
|
||||
string schemaName = reference["#/components/schemas/".Length..];
|
||||
string[] required = openApi.RootElement.GetProperty("components")
|
||||
.GetProperty("schemas")
|
||||
.GetProperty(schemaName)
|
||||
.GetProperty("required")
|
||||
.EnumerateArray()
|
||||
.Select(static property => property.GetString()!)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] documented = body.RootElement.EnumerateObject()
|
||||
.Select(static property => property.Name)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(required, documented);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SdkGuideMatchesTheReleasedPackageAndTransportMatrix()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
string guide = File.ReadAllText(Path.Combine(root, "docs", "integration", "sdk-seams.md"));
|
||||
using JsonDocument compatibility = JsonDocument.Parse(File.ReadAllText(
|
||||
Path.Combine(root, "docs", "releases", "compatibility.json")));
|
||||
JsonElement matrix = compatibility.RootElement;
|
||||
JsonElement packages = matrix.GetProperty("packages");
|
||||
string clientVersion = packages.GetProperty("FinalFactory.Rendezvous.Client").GetString()!;
|
||||
string contractsVersion = packages.GetProperty("FinalFactory.Rendezvous.Contracts").GetString()!;
|
||||
string liteNetLibVersion = matrix.GetProperty("transport").GetProperty("version").GetString()!;
|
||||
string clientFramework = XDocument.Load(Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"FinalFactory.Rendezvous.Client",
|
||||
"FinalFactory.Rendezvous.Client.csproj"))
|
||||
.Descendants("TargetFramework")
|
||||
.Single()
|
||||
.Value;
|
||||
int httpVersion = matrix.GetProperty("contracts").GetProperty("http")[0].GetInt32();
|
||||
int udpVersion = matrix.GetProperty("contracts").GetProperty("udp")[0].GetInt32();
|
||||
int ticketVersion = matrix.GetProperty("contracts").GetProperty("connectionTicket")[0].GetInt32();
|
||||
|
||||
Assert.Contains(
|
||||
$"FinalFactory.Rendezvous.Client\" Version=\"{clientVersion}\"",
|
||||
guide,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
$"FinalFactory.Rendezvous.Contracts\" Version=\"{contractsVersion}\"",
|
||||
guide,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains($"targets `{clientFramework}`", guide, StringComparison.Ordinal);
|
||||
Assert.Contains($"LiteNetLib `{liteNetLibVersion}`", guide, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json",
|
||||
guide,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(httpVersion, udpVersion);
|
||||
Assert.Equal(httpVersion, ticketVersion);
|
||||
Assert.Contains($"contract version `{httpVersion}`", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("gameplayNetwork.ChannelsCount = 3", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("defaults to one QoS channel", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("Rendezvous does not choose, remap, or reserve", guide, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpaceGamePilotEvidenceSeparatesProvenBehaviorFromOpenGates()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
string guide = File.ReadAllText(Path.Combine(root, "docs", "integration", "spacegame-pilot.md"));
|
||||
string deploymentGuide = File.ReadAllText(Path.Combine(root, "docs", "deployment", "linux.md"));
|
||||
using JsonDocument evidence = JsonDocument.Parse(File.ReadAllText(
|
||||
Path.Combine(root, "docs", "evidence", "consumers", "spacegame.json")));
|
||||
JsonElement record = evidence.RootElement;
|
||||
|
||||
Assert.Equal("checkpoint-pass-with-external-gates", record.GetProperty("result").GetString());
|
||||
Assert.Equal("none", record.GetProperty("localRun").GetProperty("rendezvousGameplayPayloadPath").GetString());
|
||||
Assert.Equal("caller-owned-litenetlib", record.GetProperty("localRun").GetProperty("gameplayTransport").GetString());
|
||||
Assert.True(record.GetProperty("localRun").GetProperty("directGameplay").GetBoolean());
|
||||
Assert.True(record.GetProperty("localRun").GetProperty("reconnected").GetBoolean());
|
||||
Assert.True(record.GetProperty("linuxRun").GetProperty("freshExport").GetBoolean());
|
||||
Assert.False(record.GetProperty("linuxRun").GetProperty("sourceDirty").GetBoolean());
|
||||
Assert.Equal(31, record.GetProperty("verification").GetProperty("debugTests").GetProperty("passed").GetInt32());
|
||||
Assert.Equal(31, record.GetProperty("verification").GetProperty("releaseTests").GetProperty("passed").GetInt32());
|
||||
Assert.Contains("public-package-restore", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||
Assert.Contains("representative-external-nat", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||
Assert.DoesNotContain("actual-godot-process-integration", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||
Assert.DoesNotContain("dedicated-fallback-connection", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||
Assert.Contains("Do not mark #21 passed", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("Rendezvous reserves no gameplay", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("private-network service name", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("local/private-bridge smoke", deploymentGuide, StringComparison.Ordinal);
|
||||
Assert.Contains("explicit `rendezvous` host name", deploymentGuide, StringComparison.Ordinal);
|
||||
|
||||
using JsonDocument composeSettings = JsonDocument.Parse(File.ReadAllText(
|
||||
Path.Combine(root, "deploy", "compose", "appsettings.Production.json")));
|
||||
JsonElement compose = composeSettings.RootElement;
|
||||
Assert.Contains("rendezvous", compose.GetProperty("AllowedHosts").GetString()!.Split(';'));
|
||||
JsonElement game = compose.GetProperty("Rendezvous").GetProperty("Provisioning").GetProperty("Games")[0];
|
||||
Assert.Contains(2, game.GetProperty("ProtocolVersions").EnumerateArray().Select(static version => version.GetInt32()));
|
||||
Assert.Equal("DedicatedEndpointAllowed", game.GetProperty("FallbackPolicy").GetString());
|
||||
Assert.DoesNotMatch(ReusableCredential(), guide);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnscoutedPilotEvidenceProvesAnIndependentGameBoundaryAndKeepsExternalGatesOpen()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
string guide = File.ReadAllText(Path.Combine(root, "docs", "integration", "unscouted-pilot.md"));
|
||||
using JsonDocument evidence = JsonDocument.Parse(File.ReadAllText(
|
||||
Path.Combine(root, "docs", "evidence", "consumers", "unscouted.json")));
|
||||
JsonElement record = evidence.RootElement;
|
||||
JsonElement run = record.GetProperty("godotRun");
|
||||
JsonElement negative = record.GetProperty("negativePaths");
|
||||
|
||||
Assert.Equal("checkpoint-pass-with-external-gates", record.GetProperty("result").GetString());
|
||||
Assert.Equal("unscouted", record.GetProperty("configuration").GetProperty("gameId").GetString());
|
||||
Assert.Equal(["mode", "world", "mods"], record.GetProperty("configuration").GetProperty("metadataKeys")
|
||||
.EnumerateArray().Select(static value => value.GetString()));
|
||||
Assert.Equal("none", run.GetProperty("rendezvousGameplayPayloadPath").GetString());
|
||||
Assert.Equal("unscouted-litenetlib", run.GetProperty("gameplayTransport").GetString());
|
||||
Assert.True(run.GetProperty("directGameplay").GetBoolean());
|
||||
Assert.True(run.GetProperty("fallbackGameplay").GetBoolean());
|
||||
Assert.Equal(2, run.GetProperty("authenticatedSessions").GetInt32());
|
||||
Assert.Equal("proven-exact-NotFound", negative.GetProperty("wrongGame").GetString());
|
||||
Assert.Equal("proven-exact-NotFound", negative.GetProperty("wrongEnvironment").GetString());
|
||||
Assert.Equal(3310, record.GetProperty("verification").GetProperty("consumerDebugTests").GetProperty("passed").GetInt32());
|
||||
Assert.Equal(360, record.GetProperty("verification").GetProperty("consumerGdUnitTests").GetProperty("passed").GetInt32());
|
||||
Assert.Contains("public-package-restore", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||
Assert.Contains("representative-external-nat", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||
Assert.Contains("Do not mark #22 passed", guide, StringComparison.Ordinal);
|
||||
Assert.Contains("not an Unscouted branch", guide, StringComparison.Ordinal);
|
||||
Assert.DoesNotMatch(ReusableCredential(), guide);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"rv1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ReusableCredential();
|
||||
|
||||
[GeneratedRegex(@"(?<method>GET|POST) `?(?<path>/v1/operator/[a-z/-]+)`?", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex OperatorRoute();
|
||||
|
||||
[GeneratedRegex(@"\| `(?<method>POST) (?<path>/v1/operator/[a-z/-]+)` \| `(?<body>\{[^`]+\})` \|", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex OperatorAction();
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "Rendezvous.slnx")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not locate the repository root.");
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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=="
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user