Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 589f802e2e | |||
| 4423503bba | |||
| 74ae126ea7 | |||
| a77d4b801c | |||
| 769336e424 | |||
| 41be7fbce5 | |||
| 1bca034a52 | |||
| 8d98d888b3 | |||
| ef07685d22 | |||
| f9cb7f47e5 | |||
| de753b99e6 | |||
| c3b3629515 | |||
| e8c07ee22a | |||
| d42e6c99a3 | |||
| c2f63db3ad | |||
| 99885f8c8c | |||
| 06c4ecf8f3 | |||
| 95c3a4aed6 | |||
| 00d5ff7764 | |||
| 6bad659c12 | |||
| f368fec6eb | |||
| 9e863ebf64 | |||
| ebb5eb617c |
+49
-13
@@ -21,7 +21,9 @@ jobs:
|
|||||||
- name: Install .NET SDK
|
- name: Install .NET SDK
|
||||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 10.0.301
|
dotnet-version: |
|
||||||
|
8.0.128
|
||||||
|
10.0.301
|
||||||
|
|
||||||
- name: Restore locked dependencies
|
- name: Restore locked dependencies
|
||||||
run: dotnet restore Rendezvous.slnx --locked-mode
|
run: dotnet restore Rendezvous.slnx --locked-mode
|
||||||
@@ -45,6 +47,12 @@ jobs:
|
|||||||
- name: Verify formatting and analyzers
|
- name: Verify formatting and analyzers
|
||||||
run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore
|
run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore
|
||||||
|
|
||||||
|
- name: Test dependency-free diagnostic dashboard
|
||||||
|
run: ./scripts/test-diagnostic-dashboard.sh
|
||||||
|
|
||||||
|
- name: Test observability dashboard provisioning
|
||||||
|
run: ./scripts/test-observability-assets.sh
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build Rendezvous.slnx --configuration Release --no-restore
|
run: dotnet build Rendezvous.slnx --configuration Release --no-restore
|
||||||
|
|
||||||
@@ -121,7 +129,9 @@ jobs:
|
|||||||
- name: Install .NET SDK
|
- name: Install .NET SDK
|
||||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 10.0.301
|
dotnet-version: |
|
||||||
|
8.0.128
|
||||||
|
10.0.301
|
||||||
|
|
||||||
- name: Build deployment diagnostic
|
- name: Build deployment diagnostic
|
||||||
run: |
|
run: |
|
||||||
@@ -140,12 +150,29 @@ jobs:
|
|||||||
rm -f "$secret"
|
rm -f "$secret"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
umask 077
|
||||||
install -d -m 0700 deploy/compose/secrets
|
install -d -m 0700 deploy/compose/secrets
|
||||||
openssl rand -out "$secret" 32
|
openssl rand -out "$secret" 32
|
||||||
|
chmod 0600 "$secret"
|
||||||
|
publisher_credential="$(RENDEZVOUS_SMOKE_LOCAL_KEY="$secret" \
|
||||||
|
./scripts/mint-local-publisher-credential.sh)"
|
||||||
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$publisher_credential"
|
||||||
chmod 0444 "$secret"
|
chmod 0444 "$secret"
|
||||||
export RENDEZVOUS_UID=1654
|
export RENDEZVOUS_UID=1654
|
||||||
export RENDEZVOUS_GID=1654
|
export RENDEZVOUS_GID=1654
|
||||||
|
port_suffix="$(( ${GITHUB_RUN_ID:-$$} % 10000 ))"
|
||||||
|
export RENDEZVOUS_HTTP_HOST_PORT="$(( 20000 + port_suffix ))"
|
||||||
|
export RENDEZVOUS_UDP_HOST_PORT="$(( 40000 + port_suffix ))"
|
||||||
|
runner_workspace_source="$(docker inspect "$HOSTNAME" | jq -er \
|
||||||
|
--arg destination "$GITHUB_WORKSPACE" \
|
||||||
|
'.[0].Mounts[] | select(.Destination == $destination) | .Source')"
|
||||||
|
export RENDEZVOUS_CONFIG_SOURCE="$runner_workspace_source/deploy/compose/appsettings.Production.json"
|
||||||
|
export RENDEZVOUS_SECRET_SOURCE="$runner_workspace_source/deploy/compose/secrets/signing-key"
|
||||||
export SOURCE_REVISION_ID="$GITHUB_SHA"
|
export SOURCE_REVISION_ID="$GITHUB_SHA"
|
||||||
|
smoke_client_image="rendezvous-smoke-client:${GITHUB_SHA}"
|
||||||
|
docker build --file eng/release-builder.Dockerfile \
|
||||||
|
--target release-builder \
|
||||||
|
--tag "$smoke_client_image" .
|
||||||
docker compose -f "$compose_file" build \
|
docker compose -f "$compose_file" build \
|
||||||
--build-arg SOURCE_REVISION_ID="$SOURCE_REVISION_ID"
|
--build-arg SOURCE_REVISION_ID="$SOURCE_REVISION_ID"
|
||||||
docker compose -f "$compose_file" up --no-build --detach
|
docker compose -f "$compose_file" up --no-build --detach
|
||||||
@@ -153,19 +180,28 @@ jobs:
|
|||||||
test -n "$container_id"
|
test -n "$container_id"
|
||||||
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654"
|
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654"
|
||||||
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container_id")" = "true"
|
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container_id")" = "true"
|
||||||
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/app/appsettings.Production.json\"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
|
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/app/appsettings.Production.json"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
|
||||||
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/run/secrets/rendezvous-signing-key\"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
|
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/run/secrets/rendezvous-signing-key"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
|
||||||
for attempt in {1..100}; do
|
docker run --rm \
|
||||||
if curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1; then
|
--network "container:${container_id}" \
|
||||||
break
|
--user "$(id -u):$(id -g)" \
|
||||||
fi
|
--env HOME=/tmp \
|
||||||
if (( attempt == 100 )); then
|
--env RENDEZVOUS_SMOKE_HTTP_URL=http://127.0.0.1:8080/ \
|
||||||
|
--env RENDEZVOUS_SMOKE_UDP_ENDPOINT=127.0.0.1:9050 \
|
||||||
|
--env RENDEZVOUS_PUBLISHER_CREDENTIAL \
|
||||||
|
--volume "$runner_workspace_source:/source:ro" \
|
||||||
|
--workdir /source \
|
||||||
|
"$smoke_client_image" \
|
||||||
|
bash -lc '
|
||||||
|
for attempt in {1..180}; do
|
||||||
|
curl --fail --silent "${RENDEZVOUS_SMOKE_HTTP_URL%/}/health/ready" >/dev/null 2>&1 && exec ./scripts/smoke-deployment.sh
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
' || {
|
||||||
docker compose -f "$compose_file" logs rendezvous
|
docker compose -f "$compose_file" logs rendezvous
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
}
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
./scripts/smoke-deployment.sh
|
|
||||||
docker compose -f "$compose_file" stop --timeout 40 rendezvous
|
docker compose -f "$compose_file" stop --timeout 40 rendezvous
|
||||||
test "$(docker inspect --format '{{.State.Running}}' "$container_id")" = "false"
|
test "$(docker inspect --format '{{.State.Running}}' "$container_id")" = "false"
|
||||||
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = "0"
|
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = "0"
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install pinned .NET SDK
|
- name: Install pinned .NET SDKs
|
||||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 10.0.301
|
dotnet-version: |
|
||||||
|
8.0.128
|
||||||
|
10.0.301
|
||||||
|
|
||||||
- name: Install pinned Buildx and BuildKit
|
- name: Install pinned Buildx and BuildKit
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
@@ -116,22 +118,48 @@ jobs:
|
|||||||
rm -f "$secret"
|
rm -f "$secret"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
umask 077
|
||||||
install -d -m 0700 deploy/compose/secrets
|
install -d -m 0700 deploy/compose/secrets
|
||||||
openssl rand -out "$secret" 32
|
openssl rand -out "$secret" 32
|
||||||
|
chmod 0600 "$secret"
|
||||||
|
publisher_credential="$(RENDEZVOUS_SMOKE_LOCAL_KEY="$secret" \
|
||||||
|
./scripts/mint-local-publisher-credential.sh)"
|
||||||
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$publisher_credential"
|
||||||
chmod 0444 "$secret"
|
chmod 0444 "$secret"
|
||||||
export RENDEZVOUS_UID=1654
|
export RENDEZVOUS_UID=1654
|
||||||
export RENDEZVOUS_GID=1654
|
export RENDEZVOUS_GID=1654
|
||||||
|
port_suffix="$(( ${GITHUB_RUN_ID:-$$} % 10000 ))"
|
||||||
|
export RENDEZVOUS_HTTP_HOST_PORT="$(( 20000 + port_suffix ))"
|
||||||
|
export RENDEZVOUS_UDP_HOST_PORT="$(( 40000 + port_suffix ))"
|
||||||
|
runner_workspace_source="$(docker inspect "$HOSTNAME" | jq -er \
|
||||||
|
--arg destination "$GITHUB_WORKSPACE" \
|
||||||
|
'.[0].Mounts[] | select(.Destination == $destination) | .Source')"
|
||||||
|
export RENDEZVOUS_CONFIG_SOURCE="$runner_workspace_source/deploy/compose/appsettings.Production.json"
|
||||||
|
export RENDEZVOUS_SECRET_SOURCE="$runner_workspace_source/deploy/compose/secrets/signing-key"
|
||||||
export RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
|
export RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
|
||||||
docker compose -f deploy/compose/compose.yaml up --detach --no-build
|
docker compose -f deploy/compose/compose.yaml up --detach --no-build
|
||||||
for attempt in {1..100}; do
|
container_id="$(docker compose -f deploy/compose/compose.yaml ps -q rendezvous)"
|
||||||
curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1 && break
|
test -n "$container_id"
|
||||||
if (( attempt == 100 )); then
|
docker run --rm \
|
||||||
|
--network "container:${container_id}" \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--env HOME=/tmp \
|
||||||
|
--env RENDEZVOUS_SMOKE_HTTP_URL=http://127.0.0.1:8080/ \
|
||||||
|
--env RENDEZVOUS_SMOKE_UDP_ENDPOINT=127.0.0.1:9050 \
|
||||||
|
--env RENDEZVOUS_PUBLISHER_CREDENTIAL \
|
||||||
|
--volume "$runner_workspace_source:/source:ro" \
|
||||||
|
--workdir /source \
|
||||||
|
"$RENDEZVOUS_RELEASE_BUILDER" \
|
||||||
|
bash -lc '
|
||||||
|
for attempt in {1..180}; do
|
||||||
|
curl --fail --silent "${RENDEZVOUS_SMOKE_HTTP_URL%/}/health/ready" >/dev/null 2>&1 && exec ./scripts/smoke-deployment.sh
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
' || {
|
||||||
docker compose -f deploy/compose/compose.yaml logs rendezvous
|
docker compose -f deploy/compose/compose.yaml logs rendezvous
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
}
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
./scripts/smoke-deployment.sh
|
|
||||||
|
|
||||||
- name: Scan candidate for high and critical vulnerabilities
|
- name: Scan candidate for high and critical vulnerabilities
|
||||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
|
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
|
|||||||
SDK coordination, typed connection outcomes, thin public-SDK diagnostic client,
|
SDK coordination, typed connection outcomes, thin public-SDK diagnostic client,
|
||||||
deterministic NAT topology harness, hostile-input controls,
|
deterministic NAT topology harness, hostile-input controls,
|
||||||
observability/operator surface, secure single-active Linux deployment, and
|
observability/operator surface, secure single-active Linux deployment, and
|
||||||
numeric capacity/resilience gates, and reproducible signed release pipeline are
|
numeric capacity/resilience gates, reproducible signed release pipeline, consumer
|
||||||
implemented. Consumer pilots and final production-readiness gates remain in progress;
|
pilot integrations, and production-readiness decision framework are implemented.
|
||||||
participating games must not treat the current repository as a finished production
|
Deployment-specific external canaries and optional roadmap follow-ups remain;
|
||||||
service until those gates land.
|
participating games must not treat a checkout alone as production approval.
|
||||||
|
|
||||||
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
|
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
|
||||||
threat model are indexed in [the architecture documentation](docs/architecture/README.md).
|
threat model are indexed in [the architecture documentation](docs/architecture/README.md).
|
||||||
@@ -100,6 +100,8 @@ defined in [hostile-input and overload protection](docs/security/abuse-protectio
|
|||||||
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
|
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
|
||||||
operator controls are defined in the
|
operator controls are defined in the
|
||||||
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
|
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
|
||||||
|
The optional public session diagnostic and the private provisioned Grafana stack
|
||||||
|
are described in [diagnostic dashboards](docs/operations/diagnostic-dashboards.md).
|
||||||
Concrete detect/contain/recover/verify procedures are in the
|
Concrete detect/contain/recover/verify procedures are in the
|
||||||
[incident and change runbooks](docs/operations/incident-runbooks.md).
|
[incident and change runbooks](docs/operations/incident-runbooks.md).
|
||||||
The pinned non-root container, production topology, graceful drain, Linux
|
The pinned non-root container, production topology, graceful drain, Linux
|
||||||
@@ -113,17 +115,29 @@ signing, staged promotion, rollback, and migration are defined in
|
|||||||
[releases and compatibility](docs/releases/README.md).
|
[releases and compatibility](docs/releases/README.md).
|
||||||
The scriptable host/browser/join diagnostic and its stable automation contract are
|
The scriptable host/browser/join diagnostic and its stable automation contract are
|
||||||
documented in the [TestClient integration guide](docs/integration/test-client.md).
|
documented in the [TestClient integration guide](docs/integration/test-client.md).
|
||||||
|
Optional bounded SSE deltas, reconnect/reset semantics, proxy requirements, and
|
||||||
|
polling fallback are documented in
|
||||||
|
[live session-list updates](docs/integration/live-session-updates.md).
|
||||||
The package, gameplay-socket, host-admission, provisioning, metadata, key rotation,
|
The package, gameplay-socket, host-admission, provisioning, metadata, key rotation,
|
||||||
versioning, and secure rollout seams are in the
|
versioning, and secure rollout seams are in the
|
||||||
[game integration guide](docs/integration/sdk-seams.md).
|
[game integration guide](docs/integration/sdk-seams.md).
|
||||||
The always-on three-party scenarios, optional Linux namespace topology, and
|
The always-on three-party scenarios, optional Linux namespace topology, and
|
||||||
simulation limits are documented in the
|
simulation limits are documented in the
|
||||||
[deterministic topology harness](docs/integration/topology-harness.md).
|
[deterministic topology harness](docs/integration/topology-harness.md).
|
||||||
|
The current consumer evidence and remaining external gates are tracked in the
|
||||||
|
[SpaceGame consumer pilot](docs/integration/spacegame-pilot.md) and independent
|
||||||
|
[Unscouted consumer pilot](docs/integration/unscouted-pilot.md).
|
||||||
|
The fail-closed launch decision, redacted evidence matrix, and two-machine
|
||||||
|
external-network procedure are in
|
||||||
|
[production readiness and real-network canary](docs/operations/production-readiness.md).
|
||||||
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The repository pins .NET SDK 10.0.301. From a clean clone, run the same gates as
|
The repository pins .NET SDK 10.0.301. The full process-test gate also requires
|
||||||
CI from the repository root:
|
the .NET 8 runtime because the public TestClient deliberately targets `net8.0`;
|
||||||
|
CI installs SDK 8.0.128 to supply the pinned 8.0.28 runtime. From a clean clone,
|
||||||
|
run the same gates as CI from the repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet restore Rendezvous.slnx --locked-mode
|
dotnet restore Rendezvous.slnx --locked-mode
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"AllowedHosts": "localhost;127.0.0.1",
|
"AllowedHosts": "localhost;127.0.0.1;rendezvous",
|
||||||
"Rendezvous": {
|
"Rendezvous": {
|
||||||
"Deployment": {
|
"Deployment": {
|
||||||
"PublicHttpBaseUrl": "https://localhost/",
|
"PublicHttpBaseUrl": "https://localhost/",
|
||||||
@@ -14,6 +14,29 @@
|
|||||||
"ListenAddress": "0.0.0.0",
|
"ListenAddress": "0.0.0.0",
|
||||||
"Port": 9050
|
"Port": 9050
|
||||||
},
|
},
|
||||||
|
"Diagnostics": {
|
||||||
|
"Enabled": false,
|
||||||
|
"PollIntervalSeconds": 10,
|
||||||
|
"MaximumRenderedSessions": 100,
|
||||||
|
"Scopes": [
|
||||||
|
{
|
||||||
|
"GameId": "space-game",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"ProtocolVersions": [1, 2],
|
||||||
|
"Regions": ["local"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GameId": "unscouted",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"ProtocolVersions": [1],
|
||||||
|
"Regions": ["local"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Metrics": {
|
||||||
|
"Enabled": false,
|
||||||
|
"BearerTokenSecretReference": "file:/run/secrets/rendezvous-metrics-token"
|
||||||
|
},
|
||||||
"AbuseProtection": {
|
"AbuseProtection": {
|
||||||
"TrustedProxyAddresses": ["127.0.0.1"],
|
"TrustedProxyAddresses": ["127.0.0.1"],
|
||||||
"OperatorAllowedAddresses": ["127.0.0.1"]
|
"OperatorAllowedAddresses": ["127.0.0.1"]
|
||||||
@@ -32,6 +55,16 @@
|
|||||||
"NotBefore": "2026-01-01T00:00:00Z",
|
"NotBefore": "2026-01-01T00:00:00Z",
|
||||||
"SignUntil": "2100-01-01T00:00:00Z",
|
"SignUntil": "2100-01-01T00:00:00Z",
|
||||||
"VerifyUntil": "2100-01-02T00:00:00Z"
|
"VerifyUntil": "2100-01-02T00:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"KeyId": "local-smoke-unscouted-1",
|
||||||
|
"SecretReference": "file:/run/secrets/rendezvous-signing-key",
|
||||||
|
"CredentialKinds": ["DedicatedPublisher"],
|
||||||
|
"GameId": "unscouted",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"NotBefore": "2026-01-01T00:00:00Z",
|
||||||
|
"SignUntil": "2100-01-01T00:00:00Z",
|
||||||
|
"VerifyUntil": "2100-01-02T00:00:00Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Games": [
|
"Games": [
|
||||||
@@ -39,18 +72,41 @@
|
|||||||
"GameId": "space-game",
|
"GameId": "space-game",
|
||||||
"EnvironmentId": "smoke",
|
"EnvironmentId": "smoke",
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
|
"ProtocolVersions": [1, 2],
|
||||||
|
"Regions": ["local"],
|
||||||
|
"VisibilityModes": ["Public"],
|
||||||
|
"PublisherTrustModes": ["ManagedDedicated"],
|
||||||
|
"MetadataValueMaxBytes": {
|
||||||
|
"mode": 32
|
||||||
|
},
|
||||||
|
"RequiredMetadataKeys": [],
|
||||||
|
"MetadataMaxBytes": 512,
|
||||||
|
"MetadataMaxKeys": 1,
|
||||||
|
"MaxListingsPerPrincipal": 10,
|
||||||
|
"MaxAnonymousListingsPerAddress": 0,
|
||||||
|
"MaxActiveJoinAttempts": 100,
|
||||||
|
"FallbackPolicy": "DedicatedEndpointAllowed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GameId": "unscouted",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"Enabled": true,
|
||||||
"ProtocolVersions": [1],
|
"ProtocolVersions": [1],
|
||||||
"Regions": ["local"],
|
"Regions": ["local"],
|
||||||
"VisibilityModes": ["Public"],
|
"VisibilityModes": ["Public"],
|
||||||
"PublisherTrustModes": ["ManagedDedicated"],
|
"PublisherTrustModes": ["ManagedDedicated"],
|
||||||
"MetadataValueMaxBytes": {},
|
"MetadataValueMaxBytes": {
|
||||||
"RequiredMetadataKeys": [],
|
"mode": 32,
|
||||||
|
"world": 64,
|
||||||
|
"mods": 64
|
||||||
|
},
|
||||||
|
"RequiredMetadataKeys": ["mode", "world", "mods"],
|
||||||
"MetadataMaxBytes": 512,
|
"MetadataMaxBytes": 512,
|
||||||
"MetadataMaxKeys": 0,
|
"MetadataMaxKeys": 3,
|
||||||
"MaxListingsPerPrincipal": 10,
|
"MaxListingsPerPrincipal": 10,
|
||||||
"MaxAnonymousListingsPerAddress": 0,
|
"MaxAnonymousListingsPerAddress": 0,
|
||||||
"MaxActiveJoinAttempts": 100,
|
"MaxActiveJoinAttempts": 100,
|
||||||
"FallbackPolicy": "Disabled"
|
"FallbackPolicy": "DedicatedEndpointAllowed"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Production
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
ASPNETCORE_HTTP_PORTS: "8080"
|
ASPNETCORE_HTTP_PORTS: "8080"
|
||||||
|
Rendezvous__Diagnostics__Enabled: "${RENDEZVOUS_DIAGNOSTICS_ENABLED:-false}"
|
||||||
|
Rendezvous__Metrics__Enabled: "${RENDEZVOUS_METRICS_ENABLED:-false}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./appsettings.Production.json:/app/appsettings.Production.json:ro
|
- ${RENDEZVOUS_CONFIG_SOURCE:-./appsettings.Production.json}:/app/appsettings.Production.json:ro
|
||||||
- ./secrets/signing-key:/run/secrets/rendezvous-signing-key:ro
|
- ${RENDEZVOUS_SECRET_SOURCE:-./secrets/signing-key}:/run/secrets/rendezvous-signing-key:ro
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080/tcp"
|
- "127.0.0.1:${RENDEZVOUS_HTTP_HOST_PORT:-8080}:8080/tcp"
|
||||||
- "9050:9050/udp"
|
- "${RENDEZVOUS_UDP_HOST_PORT:-9050}:9050/udp"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
services:
|
||||||
|
rendezvous:
|
||||||
|
environment:
|
||||||
|
Rendezvous__Metrics__Enabled: "true"
|
||||||
|
volumes:
|
||||||
|
- ../observability/secrets/rendezvous-metrics-token:/run/secrets/rendezvous-metrics-token:ro
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v3.13.1@sha256:3c42b892cf723fa54d2f262c37a0e1f80aa8c8ddb1da7b9b0df9455a35a7f893
|
||||||
|
user: "65534:65534"
|
||||||
|
read_only: true
|
||||||
|
init: true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
pids_limit: 128
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
|
- --storage.tsdb.retention.time=15d
|
||||||
|
volumes:
|
||||||
|
- ../observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- ../observability/secrets/rendezvous-metrics-token:/run/secrets/rendezvous-metrics-token:ro
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
depends_on:
|
||||||
|
- rendezvous
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:13.1.0@sha256:121a7a9ece6dc10b969f1f96eed64b4f07dfac0d0b8abc070f7cb83bbde86f63
|
||||||
|
user: "472:472"
|
||||||
|
read_only: true
|
||||||
|
init: true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
pids_limit: 128
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||||
|
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
|
||||||
|
GF_PLUGINS_PREINSTALL_DISABLED: "true"
|
||||||
|
GF_PLUGINS_PREINSTALL_AUTO_UPDATE: "false"
|
||||||
|
GF_SECURITY_ADMIN_USER: "rendezvous-admin"
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana-admin-password
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
||||||
|
GF_SERVER_DOMAIN: localhost
|
||||||
|
GF_SERVER_ROOT_URL: http://localhost:3000
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,nodev,size=32m,uid=472,gid=472,mode=0700
|
||||||
|
volumes:
|
||||||
|
- ../observability/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
|
||||||
|
- ../observability/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
|
||||||
|
- ../observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
- ../observability/secrets/grafana-admin-password:/run/secrets/grafana-admin-password:ro
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000/tcp"
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
{
|
||||||
|
"annotations": {"list": []},
|
||||||
|
"description": "Privacy-safe operational view of the single-active Rendezvous service. Capacity percentages use the approved 25,000 listing and 10,000 active-attempt launch envelope.",
|
||||||
|
"editable": false,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"liveNow": false,
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Service",
|
||||||
|
"description": "Prometheus can authenticate to and scrape the Rendezvous process.",
|
||||||
|
"type": "stat",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 0},
|
||||||
|
"fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"color": "red", "text": "DOWN"}, "1": {"color": "green", "text": "UP"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}}, "overrides": []},
|
||||||
|
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "up{job=\"rendezvous\"}", "legendFormat": "Rendezvous", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Store",
|
||||||
|
"type": "stat",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 4, "w": 4, "x": 4, "y": 0},
|
||||||
|
"fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"color": "red", "text": "UNAVAILABLE"}, "1": {"color": "green", "text": "AVAILABLE"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}}, "overrides": []},
|
||||||
|
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "rendezvous_store_available", "legendFormat": "Store", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Drain",
|
||||||
|
"type": "stat",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 4, "w": 4, "x": 8, "y": 0},
|
||||||
|
"fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"color": "green", "text": "ACCEPTING"}, "1": {"color": "orange", "text": "DRAINING"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 1}]}}, "overrides": []},
|
||||||
|
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "rendezvous_store_draining", "legendFormat": "Drain", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Listings",
|
||||||
|
"description": "Percentage of the approved 25,000-listing single-process envelope.",
|
||||||
|
"type": "gauge",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
|
||||||
|
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent", "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 70}, {"color": "red", "value": 90}]}}, "overrides": []},
|
||||||
|
"options": {"minVizHeight": 75, "minVizWidth": 75, "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showThresholdLabels": false, "showThresholdMarkers": true, "sizing": "auto"},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "100 * rendezvous_store_active_listings / 25000", "legendFormat": "Listings", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Join attempts",
|
||||||
|
"description": "Percentage of the approved 10,000 active-attempt single-process envelope.",
|
||||||
|
"type": "gauge",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 0},
|
||||||
|
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent", "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 70}, {"color": "red", "value": 90}]}}, "overrides": []},
|
||||||
|
"options": {"minVizHeight": 75, "minVizWidth": 75, "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showThresholdLabels": false, "showThresholdMarkers": true, "sizing": "auto"},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "100 * rendezvous_store_active_attempts / 10000", "legendFormat": "Attempts", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "HTTP request rate by operation",
|
||||||
|
"description": "Registration, renewal, browse, and join issuance appear as bounded operation names.",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 8, "w": 8, "x": 0, "y": 4},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "reqps"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "sum by (operation) (rate(rendezvous_http_requests_total[5m]))", "legendFormat": "{{operation}}", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "HTTP p95 latency",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 8, "w": 8, "x": 8, "y": 4},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "ms", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 200}]}}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "histogram_quantile(0.95, sum by (le, operation) (rate(rendezvous_http_duration_milliseconds_bucket[5m])))", "legendFormat": "{{operation}}", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"title": "HTTP error and shedding rate",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 8, "w": 8, "x": 16, "y": 4},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "reqps"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "sum by (status_code) (rate(rendezvous_http_requests_total{status_code=~\"4..|5..\"}[5m]))", "legendFormat": "HTTP {{status_code}}", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"title": "Browser live-update load",
|
||||||
|
"description": "Active public SSE clients, tenant scopes, and bounded replay entries.",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 12},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [
|
||||||
|
{"editorMode": "code", "expr": "rendezvous_browser_sse_subscribers", "legendFormat": "Subscribers", "range": true, "refId": "A"},
|
||||||
|
{"editorMode": "code", "expr": "rendezvous_browser_sse_tenants", "legendFormat": "Tenant scopes", "range": true, "refId": "B"},
|
||||||
|
{"editorMode": "code", "expr": "rendezvous_browser_replay_entries", "legendFormat": "Replay entries", "range": true, "refId": "C"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"title": "UDP mediation results",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 12},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "pps"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "sum by (operation, result) (rate(rendezvous_udp_results_total[5m]))", "legendFormat": "{{operation}} · {{result}}", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"title": "UDP p95 processing latency",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 12},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "ms", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 100}]}}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "histogram_quantile(0.95, sum by (le, operation) (rate(rendezvous_udp_duration_milliseconds_bucket[5m])))", "legendFormat": "{{operation}}", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"title": "UDP ingress and admitted response budget",
|
||||||
|
"description": "Traffic bytes observed by the process and the conservative maximum response budget admitted for successful introductions; this is not actual egress.",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 19},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "Bps"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [
|
||||||
|
{"editorMode": "code", "expr": "sum by (operation) (rate(rendezvous_udp_received_bytes_total[5m]))", "legendFormat": "Ingress · {{operation}}", "range": true, "refId": "A"},
|
||||||
|
{"editorMode": "code", "expr": "sum by (operation) (rate(rendezvous_udp_response_budget_bytes_total[5m]))", "legendFormat": "Response budget · {{operation}}", "range": true, "refId": "B"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"title": "Admission-control drops",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 19},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "sum by (transport, partition) (rate(rendezvous_limiter_drops_total[5m]))", "legendFormat": "{{transport}} · {{partition}}", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"title": "Direct-connect outcomes",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 19},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "sum by (outcome, elapsed_bucket) (rate(rendezvous_connection_outcomes_total[5m]))", "legendFormat": "{{outcome}} · {{elapsed_bucket}}", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"title": "Pairing p95 latency",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 26},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "ms", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 100}]}}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "histogram_quantile(0.95, rate(rendezvous_pairing_latency_milliseconds_bucket[5m]))", "legendFormat": "Pairing p95", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"title": "Presence and expiry state",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 26},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [
|
||||||
|
{"editorMode": "code", "expr": "rendezvous_store_fresh_presence_bindings", "legendFormat": "Fresh presence", "range": true, "refId": "A"},
|
||||||
|
{"editorMode": "code", "expr": "rendezvous_store_awaiting_presence_listings", "legendFormat": "Awaiting presence", "range": true, "refId": "B"},
|
||||||
|
{"editorMode": "code", "expr": "rate(rendezvous_store_expiry_churn_total[5m])", "legendFormat": "Expiry churn/s", "range": true, "refId": "C"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"title": "Signing key state",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 26},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "rendezvous_signing_keys", "legendFormat": "{{state}}", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"title": "Minimum signing window",
|
||||||
|
"description": "Page below seven days; escalate below 24 hours.",
|
||||||
|
"type": "stat",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 5, "w": 6, "x": 0, "y": 33},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "s", "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "orange", "value": 86400}, {"color": "green", "value": 604800}]}}, "overrides": []},
|
||||||
|
"options": {"colorMode": "background", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "rendezvous_signing_key_sign_seconds_remaining", "legendFormat": "Signing window", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"title": "Operator authentication",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 5, "w": 6, "x": 6, "y": 33},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "sum by (result) (rate(rendezvous_operator_authentication_total[5m]))", "legendFormat": "{{result}}", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"title": "Privileged audit outcomes",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 5, "w": 6, "x": 12, "y": 33},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "sum by (action, result) (rate(rendezvous_audit_events_total[5m]))", "legendFormat": "{{action}} · {{result}}", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"title": "Metrics access",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 5, "w": 6, "x": 18, "y": 33},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"editorMode": "code", "expr": "sum by (result) (rate(rendezvous_metrics_scrapes_total[5m]))", "legendFormat": "{{result}}", "range": true, "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"title": "Process memory",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 38},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "bytes", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 1073741824}, {"color": "red", "value": 1610612736}]}}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [
|
||||||
|
{"editorMode": "code", "expr": "process_resident_memory_bytes", "legendFormat": "Resident", "range": true, "refId": "A"},
|
||||||
|
{"editorMode": "code", "expr": "dotnet_gc_heap_size_bytes", "legendFormat": "Managed heap", "range": true, "refId": "B"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"title": "Process CPU and threads",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 38},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": [{"matcher": {"id": "byName", "options": "CPU cores"}, "properties": [{"id": "unit", "value": "cores"}]}]},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [
|
||||||
|
{"editorMode": "code", "expr": "rate(process_cpu_seconds_total[5m])", "legendFormat": "CPU cores", "range": true, "refId": "A"},
|
||||||
|
{"editorMode": "code", "expr": "process_threads", "legendFormat": "Process threads", "range": true, "refId": "B"},
|
||||||
|
{"editorMode": "code", "expr": "dotnet_thread_pool_threads", "legendFormat": "Thread-pool threads", "range": true, "refId": "C"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"title": "Descriptor and GC pressure",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
|
||||||
|
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 38},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
|
||||||
|
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [
|
||||||
|
{"editorMode": "code", "expr": "process_open_file_descriptors", "legendFormat": "Open descriptors", "range": true, "refId": "A"},
|
||||||
|
{"editorMode": "code", "expr": "sum(rate(dotnet_gc_collections_total[5m]))", "legendFormat": "GC collections/s", "range": true, "refId": "B"},
|
||||||
|
{"editorMode": "code", "expr": "dotnet_thread_pool_available_worker_threads", "legendFormat": "Available workers", "range": true, "refId": "C"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "15s",
|
||||||
|
"schemaVersion": 41,
|
||||||
|
"tags": ["rendezvous", "operations", "privacy-safe"],
|
||||||
|
"templating": {"list": []},
|
||||||
|
"time": {"from": "now-1h", "to": "now"},
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "Rendezvous operational overview",
|
||||||
|
"uid": "rendezvous-overview",
|
||||||
|
"version": 1,
|
||||||
|
"weekStart": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: Rendezvous
|
||||||
|
orgId: 1
|
||||||
|
folder: Rendezvous
|
||||||
|
type: file
|
||||||
|
disableDeletion: true
|
||||||
|
allowUiUpdates: false
|
||||||
|
updateIntervalSeconds: 30
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
|
foldersFromFilesStructure: false
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
deleteDatasources:
|
||||||
|
- name: Rendezvous Prometheus
|
||||||
|
orgId: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Rendezvous Prometheus
|
||||||
|
uid: rendezvous-prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
|
jsonData:
|
||||||
|
httpMethod: POST
|
||||||
|
timeInterval: 15s
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
external_labels:
|
||||||
|
service: rendezvous
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: rendezvous
|
||||||
|
scheme: http
|
||||||
|
metrics_path: /metrics
|
||||||
|
bearer_token_file: /run/secrets/rendezvous-metrics-token
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- rendezvous:8080
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
+233
-1
@@ -1177,6 +1177,159 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v1/sessions/stream": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Sessions"
|
||||||
|
],
|
||||||
|
"operationId": "StreamSessions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "contractVersion",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gameId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "environmentId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "protocolVersion",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "regionId",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "excludeFull",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "streamCursor",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Last-Event-ID",
|
||||||
|
"in": "header",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"X-Rendezvous-Correlation-ID": {
|
||||||
|
"description": "Safe request correlation identifier generated by the service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"text/event-stream": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SessionStreamEvent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"headers": {
|
||||||
|
"X-Rendezvous-Correlation-ID": {
|
||||||
|
"description": "Safe request correlation identifier generated by the service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests",
|
||||||
|
"headers": {
|
||||||
|
"X-Rendezvous-Correlation-ID": {
|
||||||
|
"description": "Safe request correlation identifier generated by the service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Retry-After": {
|
||||||
|
"description": "Whole seconds before the caller should retry (1-60).",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
|
"headers": {
|
||||||
|
"X-Rendezvous-Correlation-ID": {
|
||||||
|
"description": "Safe request correlation identifier generated by the service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v1/sessions/{listingId}/join-attempts": {
|
"/v1/sessions/{listingId}/join-attempts": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2657,7 +2810,8 @@
|
|||||||
"BrowseSessionsResponse": {
|
"BrowseSessionsResponse": {
|
||||||
"required": [
|
"required": [
|
||||||
"contractVersion",
|
"contractVersion",
|
||||||
"items"
|
"items",
|
||||||
|
"streamCursor"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2676,6 +2830,9 @@
|
|||||||
"null",
|
"null",
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"streamCursor": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3545,6 +3702,54 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
},
|
},
|
||||||
|
"SessionStreamEvent": {
|
||||||
|
"required": [
|
||||||
|
"contractVersion",
|
||||||
|
"kind",
|
||||||
|
"cursor"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"contractVersion": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"$ref": "#/components/schemas/SessionStreamEventKind"
|
||||||
|
},
|
||||||
|
"cursor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SessionListing"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"listingId": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SessionListingId"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SessionStreamEventKind": {
|
||||||
|
"enum": [
|
||||||
|
"sessionUpsert",
|
||||||
|
"sessionRemove",
|
||||||
|
"reset",
|
||||||
|
"keepalive"
|
||||||
|
]
|
||||||
|
},
|
||||||
"UpdateSessionRequest": {
|
"UpdateSessionRequest": {
|
||||||
"required": [
|
"required": [
|
||||||
"contractVersion",
|
"contractVersion",
|
||||||
@@ -3563,6 +3768,33 @@
|
|||||||
"leaseToken": {
|
"leaseToken": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"regionId": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RegionId"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protocolVersion": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"integer"
|
||||||
|
],
|
||||||
|
"format": "uint32"
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ListingVisibility"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"buildVersion": {
|
"buildVersion": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ engine, transport, or server dependency therefore fails the normal test gate.
|
|||||||
## Supported toolchain
|
## Supported toolchain
|
||||||
|
|
||||||
- Build SDK: .NET SDK 10.0.301, pinned by `global.json`.
|
- Build SDK: .NET SDK 10.0.301, pinned by `global.json`.
|
||||||
|
- Verification runtime: .NET SDK 8.0.128 supplies the pinned .NET 8.0.28
|
||||||
|
runtime used by TestClient process tests and release verification.
|
||||||
- Server runtime: .NET 10 LTS.
|
- Server runtime: .NET 10 LTS.
|
||||||
- Client/contracts compatibility target: .NET Standard 2.1, consumable by the
|
- Client/contracts compatibility target: .NET Standard 2.1, consumable by the
|
||||||
.NET 8-or-later runtime used by current Godot 4 C# projects.
|
.NET 8-or-later runtime used by current Godot 4 C# projects.
|
||||||
|
|||||||
@@ -42,9 +42,27 @@ test "$RENDEZVOUS_UID" -ne 0
|
|||||||
docker compose -f deploy/compose/compose.yaml up --build --detach
|
docker compose -f deploy/compose/compose.yaml up --build --detach
|
||||||
```
|
```
|
||||||
|
|
||||||
`deploy/compose/appsettings.Production.json` is an isolated loopback smoke
|
The example defaults to host TCP 8080 and UDP 9050. On a shared host, set
|
||||||
profile, not an Internet template: it deliberately opts into private advertised
|
`RENDEZVOUS_HTTP_HOST_PORT` and `RENDEZVOUS_UDP_HOST_PORT` before starting
|
||||||
endpoints and has no TLS proxy. Its random key is ignored by Git and must be
|
Compose, then point `RENDEZVOUS_SMOKE_HTTP_URL` and
|
||||||
|
`RENDEZVOUS_SMOKE_UDP_ENDPOINT` at those published ports. The container ports
|
||||||
|
and production-advertised service ports remain 8080/9050. Sibling-container CI
|
||||||
|
runners may also set `RENDEZVOUS_CONFIG_SOURCE` and
|
||||||
|
`RENDEZVOUS_SECRET_SOURCE` to host-visible absolute bind-source paths; local
|
||||||
|
operators should normally keep the checked-in relative defaults. CI runs its
|
||||||
|
ephemeral smoke client as a sidecar in the service container's network namespace
|
||||||
|
to avoid runner-specific bridge and host-routing policy; it does not widen the
|
||||||
|
default loopback HTTP publication. It mints the disposable publisher credential
|
||||||
|
while the generated key is still owner-private, then makes the key read-only for
|
||||||
|
the non-root service container. Automation probes readiness once per second and
|
||||||
|
allows up to three minutes for a cold, resource-constrained image to become
|
||||||
|
ready; the protocol smoke retains its separate, stricter scenario timeout.
|
||||||
|
|
||||||
|
`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
|
deleted after use. Its deliberately long key window only keeps this disposable
|
||||||
local fixture usable; production keys require short, reviewed rotation windows.
|
local fixture usable; production keys require short, reviewed rotation windows.
|
||||||
Production configuration must use its real public names and must leave
|
Production configuration must use its real public names and must leave
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 2,
|
"schemaVersion": 2,
|
||||||
"evidenceVersion": "v2",
|
"evidenceVersion": "v2",
|
||||||
"generatedAt": "2026-07-16T14:10:53.6981858+00:00",
|
"generatedAt": "2026-07-16T20:28:43.2873744+00:00",
|
||||||
"profile": "candidate",
|
"profile": "candidate",
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"framework": ".NET 10.0.9",
|
"framework": ".NET 10.0.9",
|
||||||
@@ -14,14 +14,14 @@
|
|||||||
"cpuQuota": "not-enforced",
|
"cpuQuota": "not-enforced",
|
||||||
"memoryLimit": "not-enforced",
|
"memoryLimit": "not-enforced",
|
||||||
"garbageCollector": "workstation",
|
"garbageCollector": "workstation",
|
||||||
"commitSha": "cf14836d48b0b4aaa67f99433f4fba3585bcd2bb",
|
"commitSha": "00d5ff776408e7d80ce6648953e62a7233aca35c",
|
||||||
"treeState": "clean",
|
"treeState": "clean",
|
||||||
"command": "RENDEZVOUS_CAPACITY_PROFILE=candidate RENDEZVOUS_CAPACITY_CPUSET=0,1 ./scripts/run-capacity-gate.sh",
|
"command": "RENDEZVOUS_CAPACITY_PROFILE=candidate RENDEZVOUS_CAPACITY_CPUSET=0,1 ./scripts/run-capacity-gate.sh",
|
||||||
"imageDigest": "not-containerized",
|
"imageDigest": "not-containerized",
|
||||||
"workloadSeed": "fixed-sequences-random-identifiers",
|
"workloadSeed": "fixed-sequences-random-identifiers",
|
||||||
"capacityPhaseAverageCpuPercent": 56.37724115383554,
|
"capacityPhaseAverageCpuPercent": 55.52666859166872,
|
||||||
"peakWorkingSetBytes": 169705472,
|
"peakWorkingSetBytes": 176758784,
|
||||||
"managedBytesAfterCleanup": 35615200
|
"managedBytesAfterCleanup": 35608984
|
||||||
},
|
},
|
||||||
"targets": {
|
"targets": {
|
||||||
"visibleListings": 25000,
|
"visibleListings": 25000,
|
||||||
@@ -39,10 +39,10 @@
|
|||||||
{
|
{
|
||||||
"operation": "registration-and-presence",
|
"operation": "registration-and-presence",
|
||||||
"samples": 1000,
|
"samples": 1000,
|
||||||
"p50Milliseconds": 0.003,
|
"p50Milliseconds": 0.0029,
|
||||||
"p95Milliseconds": 0.0046,
|
"p95Milliseconds": 0.0046,
|
||||||
"p99Milliseconds": 0.0054,
|
"p99Milliseconds": 0.0055,
|
||||||
"operationsPerSecond": 282453.96000451926,
|
"operationsPerSecond": 287918.9220315559,
|
||||||
"minimumOperationsPerSecond": 200,
|
"minimumOperationsPerSecond": 200,
|
||||||
"budgetMilliseconds": 200,
|
"budgetMilliseconds": 200,
|
||||||
"passed": true
|
"passed": true
|
||||||
@@ -51,9 +51,9 @@
|
|||||||
"operation": "lease-renewal",
|
"operation": "lease-renewal",
|
||||||
"samples": 1000,
|
"samples": 1000,
|
||||||
"p50Milliseconds": 0.0004,
|
"p50Milliseconds": 0.0004,
|
||||||
"p95Milliseconds": 0.0009,
|
"p95Milliseconds": 0.0007,
|
||||||
"p99Milliseconds": 0.0021,
|
"p99Milliseconds": 0.0019,
|
||||||
"operationsPerSecond": 968992.2480620155,
|
"operationsPerSecond": 1076426.264800861,
|
||||||
"minimumOperationsPerSecond": 200,
|
"minimumOperationsPerSecond": 200,
|
||||||
"budgetMilliseconds": 200,
|
"budgetMilliseconds": 200,
|
||||||
"passed": true
|
"passed": true
|
||||||
@@ -61,10 +61,10 @@
|
|||||||
{
|
{
|
||||||
"operation": "visible-session-browse",
|
"operation": "visible-session-browse",
|
||||||
"samples": 250,
|
"samples": 250,
|
||||||
"p50Milliseconds": 0.9046,
|
"p50Milliseconds": 1.0232,
|
||||||
"p95Milliseconds": 3.3704,
|
"p95Milliseconds": 3.6083,
|
||||||
"p99Milliseconds": 3.9471,
|
"p99Milliseconds": 4.2925,
|
||||||
"operationsPerSecond": 695.5799787597697,
|
"operationsPerSecond": 650.0325926341947,
|
||||||
"minimumOperationsPerSecond": 200,
|
"minimumOperationsPerSecond": 200,
|
||||||
"budgetMilliseconds": 200,
|
"budgetMilliseconds": 200,
|
||||||
"passed": true
|
"passed": true
|
||||||
@@ -72,10 +72,10 @@
|
|||||||
{
|
{
|
||||||
"operation": "join-attempt-issuance",
|
"operation": "join-attempt-issuance",
|
||||||
"samples": 1000,
|
"samples": 1000,
|
||||||
"p50Milliseconds": 0.0029,
|
"p50Milliseconds": 0.0028,
|
||||||
"p95Milliseconds": 0.0045,
|
"p95Milliseconds": 0.0042,
|
||||||
"p99Milliseconds": 0.0055,
|
"p99Milliseconds": 0.0052,
|
||||||
"operationsPerSecond": 296428.042092782,
|
"operationsPerSecond": 135253.93927098127,
|
||||||
"minimumOperationsPerSecond": 200,
|
"minimumOperationsPerSecond": 200,
|
||||||
"budgetMilliseconds": 200,
|
"budgetMilliseconds": 200,
|
||||||
"passed": true
|
"passed": true
|
||||||
@@ -83,10 +83,10 @@
|
|||||||
{
|
{
|
||||||
"operation": "simultaneous-punch-pairing",
|
"operation": "simultaneous-punch-pairing",
|
||||||
"samples": 1000,
|
"samples": 1000,
|
||||||
"p50Milliseconds": 0.0043,
|
"p50Milliseconds": 0.0039,
|
||||||
"p95Milliseconds": 0.0073,
|
"p95Milliseconds": 0.0069,
|
||||||
"p99Milliseconds": 0.0115,
|
"p99Milliseconds": 0.0087,
|
||||||
"operationsPerSecond": 109212.03516627532,
|
"operationsPerSecond": 110619.46902654869,
|
||||||
"minimumOperationsPerSecond": 2000,
|
"minimumOperationsPerSecond": 2000,
|
||||||
"budgetMilliseconds": 100,
|
"budgetMilliseconds": 100,
|
||||||
"passed": true
|
"passed": true
|
||||||
@@ -94,10 +94,10 @@
|
|||||||
{
|
{
|
||||||
"operation": "principal-revocation",
|
"operation": "principal-revocation",
|
||||||
"samples": 50,
|
"samples": 50,
|
||||||
"p50Milliseconds": 0.518,
|
"p50Milliseconds": 0.495,
|
||||||
"p95Milliseconds": 0.7049,
|
"p95Milliseconds": 0.6508,
|
||||||
"p99Milliseconds": 11.8557,
|
"p99Milliseconds": 11.011,
|
||||||
"operationsPerSecond": 1320.1773262184577,
|
"operationsPerSecond": 1393.258301729591,
|
||||||
"minimumOperationsPerSecond": 50,
|
"minimumOperationsPerSecond": 50,
|
||||||
"budgetMilliseconds": 200,
|
"budgetMilliseconds": 200,
|
||||||
"passed": true
|
"passed": true
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"p50Milliseconds": 0.0001,
|
"p50Milliseconds": 0.0001,
|
||||||
"p95Milliseconds": 0.0001,
|
"p95Milliseconds": 0.0001,
|
||||||
"p99Milliseconds": 0.0001,
|
"p99Milliseconds": 0.0001,
|
||||||
"operationsPerSecond": 1438641.9220256077,
|
"operationsPerSecond": 1479289.9408284025,
|
||||||
"minimumOperationsPerSecond": 10000,
|
"minimumOperationsPerSecond": 10000,
|
||||||
"budgetMilliseconds": 1,
|
"budgetMilliseconds": 1,
|
||||||
"passed": true
|
"passed": true
|
||||||
@@ -116,10 +116,10 @@
|
|||||||
{
|
{
|
||||||
"operation": "coincident-listing-attempt-expiry",
|
"operation": "coincident-listing-attempt-expiry",
|
||||||
"samples": 1,
|
"samples": 1,
|
||||||
"p50Milliseconds": 29.6882,
|
"p50Milliseconds": 27.7056,
|
||||||
"p95Milliseconds": 29.6882,
|
"p95Milliseconds": 27.7056,
|
||||||
"p99Milliseconds": 29.6882,
|
"p99Milliseconds": 27.7056,
|
||||||
"operationsPerSecond": 33.682962483916384,
|
"operationsPerSecond": 36.093525543388026,
|
||||||
"minimumOperationsPerSecond": 0,
|
"minimumOperationsPerSecond": 0,
|
||||||
"budgetMilliseconds": 200,
|
"budgetMilliseconds": 200,
|
||||||
"passed": true
|
"passed": true
|
||||||
@@ -134,10 +134,10 @@
|
|||||||
"finalReplayMarkers": 0,
|
"finalReplayMarkers": 0,
|
||||||
"expiryChurn": 94906,
|
"expiryChurn": 94906,
|
||||||
"maintenanceSweeps": 36307,
|
"maintenanceSweeps": 36307,
|
||||||
"soakCyclesCompleted": 75126848,
|
"soakCyclesCompleted": 77547145,
|
||||||
"soakDurationSeconds": 300.0000015,
|
"soakDurationSeconds": 300.0000041,
|
||||||
"soakPeakScheduledExpiryEntries": 7,
|
"soakPeakScheduledExpiryEntries": 7,
|
||||||
"soakManagedGrowthBytes": -257288,
|
"soakManagedGrowthBytes": -263432,
|
||||||
"soakHandleGrowth": 2,
|
"soakHandleGrowth": 2,
|
||||||
"restartStartedEmpty": true,
|
"restartStartedEmpty": true,
|
||||||
"overloadWasTyped": true,
|
"overloadWasTyped": true,
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"recordedAt": "2026-07-16",
|
||||||
|
"issue": 21,
|
||||||
|
"consumerIssue": "Kyuubi/SpaceGame#3",
|
||||||
|
"result": "checkpoint-pass-with-external-gates",
|
||||||
|
"rendezvousBaseCommit": "ebb5eb617c0bbb170418afab396b68584b7f992e",
|
||||||
|
"consumerCommit": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
|
||||||
|
"consumerIssueComment": 11469,
|
||||||
|
"packages": {
|
||||||
|
"FinalFactory.Rendezvous.Client": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "local-candidate",
|
||||||
|
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||||
|
"sha256": "fb156cf48b49f75c244dd25ea7cc4aa9fc6fab0a878393bb7efd5d9b131d0395"
|
||||||
|
},
|
||||||
|
"FinalFactory.Rendezvous.Contracts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "local-candidate",
|
||||||
|
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||||
|
"sha256": "a82ba986d3905d599096d1d8ce8f32cd4feb104abfca37b0f65e0d2ef3df9a6f"
|
||||||
|
},
|
||||||
|
"LiteNetLib": {
|
||||||
|
"version": "2.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"localRun": {
|
||||||
|
"processes": ["Rendezvous", "Godot SpaceGame host", "two sequential Godot SpaceGame clients"],
|
||||||
|
"typedOutcome": "Connected",
|
||||||
|
"gameAdmission": "Accepted",
|
||||||
|
"directGameplay": true,
|
||||||
|
"authenticatedSessions": 2,
|
||||||
|
"directInputs": 2,
|
||||||
|
"directSnapshots": 2,
|
||||||
|
"lifecyclePackets": 4,
|
||||||
|
"gameplayTransport": "caller-owned-litenetlib",
|
||||||
|
"rendezvousGameplayPayloadPath": "none",
|
||||||
|
"hostLeaseRenewed": true,
|
||||||
|
"reconnected": true,
|
||||||
|
"deregistered": true
|
||||||
|
},
|
||||||
|
"linuxRun": {
|
||||||
|
"runtime": "Godot 4.7 .NET Linux x86_64",
|
||||||
|
"freshExport": true,
|
||||||
|
"sourceDirty": false,
|
||||||
|
"optimized": true,
|
||||||
|
"dedicatedHostNamespace": "docker",
|
||||||
|
"remoteClientNamespace": "docker",
|
||||||
|
"topology": "private-bridge",
|
||||||
|
"directGameplay": true,
|
||||||
|
"fallback": "PunchTimedOut to explicit Docker-gateway endpoint, then Accepted game admission and direct gameplay",
|
||||||
|
"artifactHashes": "SpaceGame issue #3 comment 11469"
|
||||||
|
},
|
||||||
|
"negativePaths": {
|
||||||
|
"incompatibleProtocol": "proven",
|
||||||
|
"staleHostPresence": "proven",
|
||||||
|
"punchTimeout": "proven",
|
||||||
|
"invalidAdmission": "integration-proven",
|
||||||
|
"capacity": "regression-tested",
|
||||||
|
"fallbackConnection": "godot-and-isolated-linux-proven",
|
||||||
|
"reconnect": "proven"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"debugBuild": "passed",
|
||||||
|
"releaseBuild": "passed",
|
||||||
|
"debugTests": { "passed": 31, "failed": 0 },
|
||||||
|
"releaseTests": { "passed": 31, "failed": 0 },
|
||||||
|
"exportRelease": "optimized-without-debug-symbols",
|
||||||
|
"format": "passed",
|
||||||
|
"shellcheck": "passed",
|
||||||
|
"godotReconnectHarness": "passed",
|
||||||
|
"godotFallbackHarness": "passed",
|
||||||
|
"linuxContainerHarness": "passed-clean-source",
|
||||||
|
"failureMatrix": "passed",
|
||||||
|
"adversarialReview": "passed-after-fixes"
|
||||||
|
},
|
||||||
|
"openGates": [
|
||||||
|
"public-package-restore",
|
||||||
|
"representative-external-nat"
|
||||||
|
],
|
||||||
|
"relatedSpaceGameGates": [
|
||||||
|
"production-enet-replacement",
|
||||||
|
"capacity-profiles-64-and-128",
|
||||||
|
"sigterm-drain-save"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"recordedAt": "2026-07-16",
|
||||||
|
"issue": 22,
|
||||||
|
"consumerIssue": "HeiKyu/Unscouted#459",
|
||||||
|
"result": "checkpoint-pass-with-external-gates",
|
||||||
|
"rendezvousConfigurationCommit": "f368fec6eb4344a6042974f58f888cf0f1ac8e8e",
|
||||||
|
"consumerImplementationCommit": "1e5886aa7f1e44689b4c75e32693eb7b19fd72d7",
|
||||||
|
"consumerEvidenceCommit": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
|
||||||
|
"consumerIssueComment": 11499,
|
||||||
|
"packages": {
|
||||||
|
"FinalFactory.Rendezvous.Client": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "local-candidate",
|
||||||
|
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||||
|
"sha256": "fb156cf48b49f75c244dd25ea7cc4aa9fc6fab0a878393bb7efd5d9b131d0395"
|
||||||
|
},
|
||||||
|
"FinalFactory.Rendezvous.Contracts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "local-candidate",
|
||||||
|
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
|
||||||
|
"sha256": "a82ba986d3905d599096d1d8ce8f32cd4feb104abfca37b0f65e0d2ef3df9a6f"
|
||||||
|
},
|
||||||
|
"LiteNetLib": {
|
||||||
|
"version": "2.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"gameId": "unscouted",
|
||||||
|
"environmentId": "smoke",
|
||||||
|
"regionId": "local",
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"publisherTrust": "ManagedDedicated",
|
||||||
|
"fallbackPolicy": "DedicatedEndpointAllowed",
|
||||||
|
"metadataKeys": ["mode", "world", "mods"],
|
||||||
|
"metadataMaxKeys": 3,
|
||||||
|
"metadataMaxBytes": 512
|
||||||
|
},
|
||||||
|
"godotRun": {
|
||||||
|
"runtime": "Godot 4.7 .NET Linux x86_64",
|
||||||
|
"processes": [
|
||||||
|
"Rendezvous hardened Compose service",
|
||||||
|
"Godot Unscouted host",
|
||||||
|
"Godot incompatible-protocol client",
|
||||||
|
"Godot direct client",
|
||||||
|
"Godot fallback client"
|
||||||
|
],
|
||||||
|
"gameplayTransport": "unscouted-litenetlib",
|
||||||
|
"rendezvousGameplayPayloadPath": "none",
|
||||||
|
"directGameplay": true,
|
||||||
|
"fallbackGameplay": true,
|
||||||
|
"authenticatedSessions": 2,
|
||||||
|
"gameplayExchanges": 2,
|
||||||
|
"hostLeaseRenewed": true,
|
||||||
|
"deregistered": true,
|
||||||
|
"playerIdentityOwner": "unscouted",
|
||||||
|
"canonicalGameStateOwner": "unscouted"
|
||||||
|
},
|
||||||
|
"negativePaths": {
|
||||||
|
"incompatibleProtocol": "proven-no-compatible-listing",
|
||||||
|
"wrongGame": "proven-exact-NotFound",
|
||||||
|
"wrongEnvironment": "proven-exact-NotFound",
|
||||||
|
"punchTimeout": "proven-typed-failure-then-game-owned-fallback",
|
||||||
|
"unexpectedMetadata": "consumer-regression-tested"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"rendezvousDebugTests": { "passed": 299, "failed": 0 },
|
||||||
|
"rendezvousReleaseTests": { "passed": 299, "failed": 0 },
|
||||||
|
"consumerDebugTests": { "passed": 3310, "skipped": 15, "failed": 0 },
|
||||||
|
"consumerReleaseTests": { "passed": 3310, "skipped": 15, "failed": 0 },
|
||||||
|
"consumerGdUnitTests": { "passed": 360, "skipped": 0, "failed": 0 },
|
||||||
|
"consumerExport": "not-applicable-no-export-presets",
|
||||||
|
"format": "passed",
|
||||||
|
"shellcheck": "passed",
|
||||||
|
"godotPilot": "passed",
|
||||||
|
"adversarialReview": "passed-after-fixes"
|
||||||
|
},
|
||||||
|
"openGates": [
|
||||||
|
"public-package-restore",
|
||||||
|
"representative-external-nat"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "rendezvous-production-readiness",
|
||||||
|
"evaluatedCommit": "00d5ff776408e7d80ce6648953e62a7233aca35c",
|
||||||
|
"decision": "not-ready",
|
||||||
|
"localGates": [
|
||||||
|
{
|
||||||
|
"id": "immutable-release-artifacts",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"note": "Clean candidate packages and server archive are byte reproducible and fully verified."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "debug-and-release-verification",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"note": "All 300 tests pass in Debug and Release; the Release build has zero warnings and errors."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "real-consumer-pilots",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"note": "Pinned real projects restore the candidate and both game launch pilots pass direct traffic."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "candidate-capacity-resilience",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/capacity/v2/candidate-2cpu.json",
|
||||||
|
"note": "The clean two-CPU five-minute candidate passes all budgets with zero retained state."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "production-process-recovery",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"note": "All selected restart, drain, socket release, overload, and recovery tests pass."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "security-privacy-observability",
|
||||||
|
"status": "pass",
|
||||||
|
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"note": "The complete security, privacy, health, audit, telemetry, and release suite passes."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"externalGates": [
|
||||||
|
{
|
||||||
|
"id": "public-package-empty-cache-restore",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "The public registry does not currently resolve version 1.0.0."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "signed-publication",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/releases/README.md",
|
||||||
|
"note": "Protected release credentials and immutable tag publication are required."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "source-preserving-udp-ingress",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "The public ingress path needs packet-level source and reply validation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "same-lan-direct-canary",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "Requires two independently operated game clients."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "home-nat-direct-canary",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "Requires distinct residential networks."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "restrictive-cgnat-typed-failure",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "Requires a known restrictive carrier topology."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "firewall-blocked-udp-typed-failure",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "Requires an independently controlled firewall rule."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ipv6-direct-canary",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/production-readiness.md",
|
||||||
|
"note": "Requires two IPv6-capable external clients and public ingress."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "public-rate-shaped-capacity",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/capacity-and-resilience.md",
|
||||||
|
"note": "The full public HTTP and UDP traffic mix has not been measured."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "one-hour-candidate-endurance",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/capacity-and-resilience.md",
|
||||||
|
"note": "A production-shaped one-hour candidate run is required."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "alert-delivery",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/incident-runbooks.md",
|
||||||
|
"note": "A real alert sink must observe trigger and recovery notifications."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cold-standby-rollback-drill",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/capacity-and-resilience.md",
|
||||||
|
"note": "The deployment must demonstrate the host-visible recovery objective."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "documentation-only-runbook-exercise",
|
||||||
|
"status": "pending",
|
||||||
|
"evidenceRef": "docs/operations/incident-runbooks.md",
|
||||||
|
"note": "An independent operator must execute the runbooks using only the docs."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "rendezvous-local-release-candidate",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"sourceCommit": "00d5ff776408e7d80ce6648953e62a7233aca35c",
|
||||||
|
"treeState": "clean",
|
||||||
|
"result": "pass",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "FinalFactory.Rendezvous.Client.1.0.0.nupkg",
|
||||||
|
"sha256": "f2a4b9727b5faeba284ddcb7fc575c7495f1e29b763faababa7cd71444dc2950"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FinalFactory.Rendezvous.Contracts.1.0.0.nupkg",
|
||||||
|
"sha256": "92317f153911ebf7b8ea04cd3206ec2a17f882cb4eddb26aa8ab094ffdb06627"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FinalFactory.Rendezvous.Server.1.0.0.linux-x64.tar.gz",
|
||||||
|
"sha256": "0dab8cfc696b4a55d6ffba46286c9e532df2c943ed8fd6347fb528516156b3ba"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"lockedRestore": "pass",
|
||||||
|
"reportedVulnerabilities": 0,
|
||||||
|
"format": "pass",
|
||||||
|
"releaseBuildWarnings": 0,
|
||||||
|
"releaseBuildErrors": 0,
|
||||||
|
"debugTestsPassed": 300,
|
||||||
|
"debugTestsFailed": 0,
|
||||||
|
"releaseTestsPassed": 300,
|
||||||
|
"releaseTestsFailed": 0,
|
||||||
|
"selectedProductionFaultTestsPassed": 17,
|
||||||
|
"byteReproduciblePackages": "pass",
|
||||||
|
"byteReproducibleServerArchive": "pass",
|
||||||
|
"sbomChecksumsAndProvenance": "pass",
|
||||||
|
"candidateConsumerFixtures": "pass",
|
||||||
|
"realConsumerRestores": "pass"
|
||||||
|
},
|
||||||
|
"consumers": [
|
||||||
|
{
|
||||||
|
"name": "SpaceGame",
|
||||||
|
"revision": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
|
||||||
|
"candidateRestore": "pass",
|
||||||
|
"directTrafficPilot": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Unscouted",
|
||||||
|
"revision": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
|
||||||
|
"candidateRestore": "pass",
|
||||||
|
"directTrafficPilot": "pass"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limitations": {
|
||||||
|
"publicRegistryRestore": "pending",
|
||||||
|
"signedPublication": "pending",
|
||||||
|
"externalNetworkCanaries": "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Live session-list updates
|
||||||
|
|
||||||
|
Tracking: #26
|
||||||
|
|
||||||
|
Live updates are an optional acceleration for an open server browser. The
|
||||||
|
bounded `GET /v1/sessions` snapshot remains the source of truth, and join
|
||||||
|
authorization still revalidates current capacity, presence, policy, and
|
||||||
|
compatibility. A displayed player count is advisory, never an admission promise.
|
||||||
|
|
||||||
|
## Snapshot, stream, reset
|
||||||
|
|
||||||
|
Every `BrowseSessionsResponse` includes `streamCursor` in addition to its normal
|
||||||
|
pagination cursor. Connect to `GET /v1/sessions/stream` with the same game,
|
||||||
|
environment, protocol, optional region, and `excludeFull` filter. Send the most
|
||||||
|
recent stream cursor as `Last-Event-ID`.
|
||||||
|
|
||||||
|
| SSE event | Contract kind | UI action |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `session_upsert` | `sessionUpsert` | Add or replace the complete public projection by listing ID. |
|
||||||
|
| `session_remove` | `sessionRemove` | Remove the listing ID. |
|
||||||
|
| `reset` | `reset` | Discard local state, fetch a fresh snapshot, then reconnect with its cursor. |
|
||||||
|
| `keepalive` | `keepalive` | Preserve the cursor and connection; do not change UI state. |
|
||||||
|
|
||||||
|
Each SSE `id` equals the opaque cursor inside its JSON event. Cursors are signed,
|
||||||
|
short-lived, monotonically ordered, and bound to the complete filter. A missing,
|
||||||
|
expired, corrupted, foreign, future, or replay-gapped cursor produces `reset`
|
||||||
|
instead of a potentially incomplete view. Do not parse or retain it as a stable
|
||||||
|
identifier.
|
||||||
|
|
||||||
|
Updates cover creation after fresh UDP presence, public-field/capacity changes,
|
||||||
|
presence staleness and recovery, lease expiry, deregistration, operator or
|
||||||
|
principal revocation, and visibility/region/protocol changes. Events contain the
|
||||||
|
same bounded public `SessionListing` as snapshots. They never contain raw peer
|
||||||
|
endpoints, lease tokens, punch capabilities, tickets, publisher subjects, or
|
||||||
|
internal store identifiers.
|
||||||
|
|
||||||
|
## SDK and polling fallback
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
BrowseSessionsRequest filter = new()
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ExcludeFull = true,
|
||||||
|
};
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> snapshot =
|
||||||
|
await browser.BrowseAsync(filter, cancellationToken);
|
||||||
|
|
||||||
|
await foreach (RendezvousClientResult<SessionStreamEvent> update in
|
||||||
|
browser.StreamAsync(filter, snapshot.Value!.StreamCursor, cancellationToken))
|
||||||
|
{
|
||||||
|
if (!update.IsSuccess)
|
||||||
|
{
|
||||||
|
// Switch to bounded polling with jittered backoff.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Apply upsert/remove by listing ID. On reset, discard and browse again.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Cancellation or enumerator disposal closes the response and releases the server
|
||||||
|
subscription. A normal connection-duration close is a reconnect signal: use the
|
||||||
|
last applied event cursor. Repeated failures, unsupported platform HTTP stacks,
|
||||||
|
and restrictive proxies fall back to snapshots with exponential jittered
|
||||||
|
backoff, a capped interval, and `Retry-After`. Never open parallel streams to
|
||||||
|
compensate for a slow UI.
|
||||||
|
|
||||||
|
## TestClient
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||||
|
--configuration Release --no-build -- \
|
||||||
|
watch --service https://rendezvous.example.invalid/ \
|
||||||
|
--game space-game --environment production --region eu-central --protocol 7 \
|
||||||
|
--run-seconds 60 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
`watch.snapshot`, `watch.session-upsert`, `watch.session-remove`,
|
||||||
|
`watch.keepalive`, and `watch.reconnect` are stable diagnostics. Add
|
||||||
|
`--exercise-reset --script` to corrupt the snapshot cursor deliberately and
|
||||||
|
verify a typed reset plus snapshot refresh. Use `--exercise-reconnect --script`
|
||||||
|
while producing one update to close the first stream deliberately, reconnect
|
||||||
|
from its prior cursor, and verify that the same ordered event is replayed.
|
||||||
|
Polished list diffing, selection retention, animation, and accessibility remain
|
||||||
|
in each game.
|
||||||
|
|
||||||
|
## Bounds and slow consumers
|
||||||
|
|
||||||
|
The v1 journal retains at most 4,096 public-only changes. It admits at most 256
|
||||||
|
subscribers total and 64 per tenant, reads at most 128 changes per batch,
|
||||||
|
waits a configurable 50 milliseconds after a live change and coalesces the
|
||||||
|
resulting batch to the final change per listing, sends a keepalive every 15
|
||||||
|
seconds, and closes a connection after five minutes. A consumer behind the
|
||||||
|
replay window receives `reset`; it never acquires an unbounded queue.
|
||||||
|
|
||||||
|
Normal optional-work concurrency and per-source/tenant rate controls apply for
|
||||||
|
the stream lifetime. Exhaustion returns typed HTTP `429` before streaming.
|
||||||
|
Shutdown cancels streams; reconnect only after readiness returns and expect a
|
||||||
|
reset after a single-active restart because listings and replay are ephemeral.
|
||||||
|
|
||||||
|
## Reverse proxy
|
||||||
|
|
||||||
|
- Disable response buffering (`X-Accel-Buffering: no` is also emitted),
|
||||||
|
compression, transformation, and caching for `text/event-stream`.
|
||||||
|
- Preserve `Last-Event-ID`; set upstream/read timeouts above the 15-second
|
||||||
|
keepalive and around six minutes for the five-minute connection ceiling.
|
||||||
|
- Flush events promptly and use HTTP/2 only when streaming semantics survive.
|
||||||
|
- Preserve the source-IP trust boundary and abuse controls; do not add a bypass.
|
||||||
|
|
||||||
|
Verify the deployed proxy with an idle keepalive, update, reconnect, invalid
|
||||||
|
cursor reset, slow reader, and graceful shutdown. An in-process pass does not
|
||||||
|
prove that a production proxy is non-buffering.
|
||||||
@@ -70,6 +70,7 @@ thread and do not also call `NetManager.PollEvents()` during that period.
|
|||||||
```csharp
|
```csharp
|
||||||
RendezvousNetListener networkEvents = new();
|
RendezvousNetListener networkEvents = new();
|
||||||
NetManager gameplayNetwork = networkEvents.CreateManager();
|
NetManager gameplayNetwork = networkEvents.CreateManager();
|
||||||
|
gameplayNetwork.ChannelsCount = 3; // set the game's required count before Start
|
||||||
if (!gameplayNetwork.Start(gameplayPort))
|
if (!gameplayNetwork.Start(gameplayPort))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Gameplay UDP socket could not start.");
|
throw new InvalidOperationException("Gameplay UDP socket could not start.");
|
||||||
@@ -85,6 +86,10 @@ using RendezvousHostCoordinator host = new(
|
|||||||
host.Poll(); // call each game frame while this coordinator owns polling
|
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
|
Register normal game callbacks on `networkEvents.GameplayEvents`. Rendezvous
|
||||||
reserves only its authenticated direct requests and forwards other callbacks.
|
reserves only its authenticated direct requests and forwards other callbacks.
|
||||||
The same socket sends host presence, punches through the mediator, establishes
|
The same socket sends host presence, punches through the mediator, establishes
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -116,6 +116,9 @@ rm deploy/compose/secrets/signing-key
|
|||||||
codes are stable automation contracts. Informational events use stdout and
|
codes are stable automation contracts. Informational events use stdout and
|
||||||
failures use stderr.
|
failures use stderr.
|
||||||
|
|
||||||
|
Successful direct-connection and direct-traffic events include the coarse
|
||||||
|
`addressFamily` value `ipv4` or `ipv6`. They never include the peer address.
|
||||||
|
|
||||||
The deployment smoke performs the full health, publish, join, mediation, direct
|
The deployment smoke performs the full health, publish, join, mediation, direct
|
||||||
traffic, outcome-report, and cleanup flow using bounded waits:
|
traffic, outcome-report, and cleanup flow using bounded waits:
|
||||||
|
|
||||||
@@ -143,7 +146,14 @@ Never use an unbounded sleep to orchestrate processes. Wait for versioned events
|
|||||||
such as `host.ready` and apply a deadline. Useful success events are
|
such as `host.ready` and apply a deadline. Useful success events are
|
||||||
`host.registered`, `host.ready`, `host.direct-traffic`, `host.deregistered`,
|
`host.registered`, `host.ready`, `host.direct-traffic`, `host.deregistered`,
|
||||||
`browse.completed`, `browse.session`, `join.connected`, `join.direct-traffic`,
|
`browse.completed`, `browse.session`, `join.connected`, `join.direct-traffic`,
|
||||||
and `join.outcome-report`.
|
`join.outcome-report`, `watch.snapshot`, `watch.session-upsert`,
|
||||||
|
`watch.session-remove`, `watch.reset`, `watch.reconnect`, and `watch.complete`.
|
||||||
|
|
||||||
|
For a bounded live-directory diagnostic, use `watch --run-seconds 60`. Add
|
||||||
|
`--exercise-reset --script` to prove fail-closed cursor recovery, or
|
||||||
|
`--exercise-reconnect --script` while changing one listing to prove ordered
|
||||||
|
`Last-Event-ID` replay after a deliberate disconnect. The full event and proxy
|
||||||
|
contract is in [live session-list updates](live-session-updates.md).
|
||||||
|
|
||||||
| Exit | Meaning |
|
| Exit | Meaning |
|
||||||
| ---: | --- |
|
| ---: | --- |
|
||||||
@@ -220,3 +230,6 @@ least-scope publisher credential from the deployment secret boundary and set the
|
|||||||
external service, mediator, and matching scope variables described in the
|
external service, mediator, and matching scope variables described in the
|
||||||
[secure Linux deployment smoke](../deployment/linux.md#http-and-udp-smoke).
|
[secure Linux deployment smoke](../deployment/linux.md#http-and-udp-smoke).
|
||||||
Run representative external-network tests; loopback success is not NAT coverage.
|
Run representative external-network tests; loopback success is not NAT coverage.
|
||||||
|
Use the redacting, bounded
|
||||||
|
[real-network canary procedure](../operations/production-readiness.md) for formal
|
||||||
|
production evidence rather than committing raw TestClient JSON.
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Unscouted consumer pilot
|
||||||
|
|
||||||
|
Tracking: Rendezvous #22 and Unscouted #459.
|
||||||
|
|
||||||
|
The current checkpoint independently proves that the v1 contracts are not
|
||||||
|
shaped only around SpaceGame. A real Godot Unscouted host and clients consume
|
||||||
|
the same Client and Contracts package surface, use one caller-owned LiteNetLib
|
||||||
|
socket for NAT callbacks and gameplay, perform Unscouted's own keypair
|
||||||
|
authentication and host admission, exchange gameplay, and exercise a
|
||||||
|
game-owned fallback. The public package restore and representative external
|
||||||
|
NAT/CGNAT canary remain required before #22 can close.
|
||||||
|
|
||||||
|
## Pinned checkpoint
|
||||||
|
|
||||||
|
| Input | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Rendezvous configuration source | `f368fec6eb4344a6042974f58f888cf0f1ac8e8e` |
|
||||||
|
| Rendezvous package source | `07004cd75fe172aa5dfdb3edda22fc280a4c4477` |
|
||||||
|
| Unscouted implementation | `1e5886aa7f1e44689b4c75e32693eb7b19fd72d7` |
|
||||||
|
| Unscouted evidence | `f0574a7de82aadff6495ca5657dfc19cf7c2f67c` |
|
||||||
|
| Client package | `FinalFactory.Rendezvous.Client` `1.0.0` |
|
||||||
|
| Contracts package | `FinalFactory.Rendezvous.Contracts` `1.0.0` |
|
||||||
|
| LiteNetLib | `2.1.4` |
|
||||||
|
| Godot | `4.7.stable.mono.arch_linux.5b4e0cb0f` |
|
||||||
|
| Game / environment / region | `unscouted` / `smoke` / `local` |
|
||||||
|
| Rendezvous and gameplay protocol | `1` |
|
||||||
|
|
||||||
|
The exact package hashes are recorded in
|
||||||
|
[`unscouted.json`](../evidence/consumers/unscouted.json). A clean restore into an
|
||||||
|
empty package directory using only the consumer's checked-in `NuGet.config`
|
||||||
|
returns `NU1101` for both packages. The verified local run used those exact
|
||||||
|
candidate package files from the existing cache. This proves compatibility,
|
||||||
|
not immutable registry publication.
|
||||||
|
|
||||||
|
## Game-neutral service boundary
|
||||||
|
|
||||||
|
Rendezvous #22 adds provisioning data, not an Unscouted branch in the server or
|
||||||
|
SDK. The local production-shaped tenant permits protocol `1`, region `local`,
|
||||||
|
public managed-dedicated listings, and the three bounded presentation keys
|
||||||
|
`mode`, `world`, and `mods`. The short-lived credential helper accepts only the
|
||||||
|
explicitly provisioned `space-game` and `unscouted` scopes and selects a
|
||||||
|
distinct game-scoped signing-key ID and subject.
|
||||||
|
|
||||||
|
The consumer rejects any metadata key outside its three-key presentation
|
||||||
|
schema and neutralizes control/BBCode characters before display. Rendezvous
|
||||||
|
never receives Unscouted player keys or resolved identities, colony authority,
|
||||||
|
simulation or persistence state, fog/interest state, or gameplay packets.
|
||||||
|
|
||||||
|
## Proven real Godot path
|
||||||
|
|
||||||
|
The normal `NetLaunch` argument path recognizes `--rendezvous-pilot` and opens a
|
||||||
|
dedicated scene. That scene uses Unscouted's real `LiteNetLibTransport`,
|
||||||
|
`GameServer`, `GameClient`, `ServerAuthenticator`, and `ClientAuthenticator`.
|
||||||
|
It is not a copied SDK adapter.
|
||||||
|
|
||||||
|
One bounded run against the hardened Compose service started a host plus:
|
||||||
|
|
||||||
|
- a protocol-`999` client that found no compatible listing;
|
||||||
|
- a direct client that received an authorized introduction, completed
|
||||||
|
same-socket traversal, passed Unscouted keypair admission, and exchanged an
|
||||||
|
Unscouted gameplay ping/pong; and
|
||||||
|
- a client pointed at a non-listening mediator that received a typed traversal
|
||||||
|
failure, applied the fallback decision in Unscouted code, repeated admission,
|
||||||
|
and exchanged the same gameplay ping/pong through the ordinary game
|
||||||
|
transport.
|
||||||
|
|
||||||
|
The direct client also proved that both a `space-game` join request and a
|
||||||
|
`production` environment join request return exact `NotFound` results for the
|
||||||
|
Unscouted listing. The host renewed its lease, admitted two independently
|
||||||
|
authenticated sessions, completed two gameplay exchanges, and deregistered the
|
||||||
|
listing on shutdown.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Rendezvous Debug and Release: 299 tests passed in each configuration, zero
|
||||||
|
failures.
|
||||||
|
- Unscouted Debug and Release: non-incremental builds passed; 3,310 tests passed
|
||||||
|
with 15 intentional skips in each configuration.
|
||||||
|
- Unscouted gdUnit/Godot: 360 tests passed, zero skipped or failed. The harness
|
||||||
|
fix in Unscouted #461 keeps compilation headless and leaves the open editor's
|
||||||
|
build tree unchanged.
|
||||||
|
- The final Godot pilot, ShellCheck, JSON/whitespace checks, formatting gate,
|
||||||
|
and adversarial branch review passed.
|
||||||
|
- Export is not applicable because the Unscouted checkout has no
|
||||||
|
`export_presets.cfg`; both C# configurations and the actual Godot entry point
|
||||||
|
were exercised.
|
||||||
|
|
||||||
|
## Remaining acceptance gates
|
||||||
|
|
||||||
|
Do not mark #22 passed until both external gates have direct evidence:
|
||||||
|
|
||||||
|
1. publish or expose the exact immutable `1.0.0` packages on the configured
|
||||||
|
Gitea feed and repeat the empty-cache consumer restore; and
|
||||||
|
2. run the same Godot host/client path across representative residential,
|
||||||
|
CGNAT, and IPv6/multi-host networks, recording the topology and typed
|
||||||
|
direct/fallback outcome.
|
||||||
|
|
||||||
|
The loopback run proves the real process, socket, authentication, and gameplay
|
||||||
|
shape. It does not claim production Internet traversal coverage.
|
||||||
@@ -81,7 +81,7 @@ concurrent build, thermal throttling, or oversubscribed CI host.
|
|||||||
The checked-in baseline is
|
The checked-in baseline is
|
||||||
[`candidate-2cpu.json`](../evidence/capacity/v2/candidate-2cpu.json). It was
|
[`candidate-2cpu.json`](../evidence/capacity/v2/candidate-2cpu.json). It was
|
||||||
produced on .NET 10.0.9/Linux x64 with CPU affinity restricted to two logical
|
produced on .NET 10.0.9/Linux x64 with CPU affinity restricted to two logical
|
||||||
CPUs. It filled 25,000 listings and 10,000 attempts, peaked at about 162 MiB,
|
CPUs. It filled 25,000 listings and 10,000 attempts, peaked at about 169 MiB,
|
||||||
and cleared all active/retained state. The five-minute baseline supersedes any
|
and cleared all active/retained state. The five-minute baseline supersedes any
|
||||||
earlier local probe when its timestamp and target duration differ.
|
earlier local probe when its timestamp and target duration differ.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Diagnostic dashboards
|
||||||
|
|
||||||
|
Tracking: #27
|
||||||
|
|
||||||
|
Rendezvous provides two deliberately separate, optional views. The public
|
||||||
|
session diagnostic helps a player or integration operator understand safe
|
||||||
|
session-list state using only the public browse contract. The private Grafana
|
||||||
|
dashboard exposes aggregate operational health through authenticated metrics.
|
||||||
|
Neither view grants operator privileges or exposes player identity, endpoints,
|
||||||
|
credentials, capabilities, or raw session metadata beyond the explicitly
|
||||||
|
allowlisted public browse fields.
|
||||||
|
|
||||||
|
## Public read-only session diagnostic
|
||||||
|
|
||||||
|
The static diagnostic is disabled by default. Enable it only for approved
|
||||||
|
game/environment scopes and keep the allowlist narrow:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"Diagnostics": {
|
||||||
|
"Enabled": true,
|
||||||
|
"PollIntervalSeconds": 10,
|
||||||
|
"MaximumRenderedSessions": 100,
|
||||||
|
"Scopes": [
|
||||||
|
{
|
||||||
|
"GameId": "space-game",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"ProtocolVersions": [1, 2],
|
||||||
|
"Regions": ["local"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `/diagnostics` on the same origin as Rendezvous. The page cannot choose a
|
||||||
|
different backend, request private visibility, join a session, or call the
|
||||||
|
operator surface. It renders a bounded snapshot, then applies ordered SSE
|
||||||
|
updates. A replay reset, corrupt cursor, incomplete snapshot, transport failure,
|
||||||
|
or deliberate reconnect returns to a fresh authoritative snapshot and bounded
|
||||||
|
polling. Apply filter changes explicitly; **Reset filters** restores the
|
||||||
|
configured defaults, while **Reconnect now** tests recovery without changing
|
||||||
|
the selection.
|
||||||
|
|
||||||
|
The page uses semantic HTML, labelled controls, visible keyboard focus, status
|
||||||
|
text in addition to color, a reduced-motion mode, and a 320-pixel reflow. It
|
||||||
|
creates untrusted content with `textContent` only. The endpoint sets a restrictive
|
||||||
|
same-origin content-security policy, denies framing, disables MIME sniffing and
|
||||||
|
browser capabilities, and marks every asset/config response `no-store`.
|
||||||
|
|
||||||
|
This is a diagnostics convenience, not a game browser, management console, or
|
||||||
|
availability monitor. Disable it independently by setting `Enabled` to `false`;
|
||||||
|
all diagnostic paths then return `404` without affecting game traffic, metrics,
|
||||||
|
or health endpoints.
|
||||||
|
|
||||||
|
## Private Prometheus and Grafana view
|
||||||
|
|
||||||
|
The observability overlay pins Prometheus 3.13.1 and Grafana 13.1.0 by immutable
|
||||||
|
multi-platform image digest. Prometheus is not published to the host. Grafana is
|
||||||
|
bound to host loopback, disables anonymous access and sign-up, and reads its
|
||||||
|
administrator password from a file. The service and Prometheus share only the
|
||||||
|
metrics bearer-token file. All three secrets remain ignored by Git.
|
||||||
|
|
||||||
|
Create independent random secrets, then start the base service and overlay:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -d -m 0700 deploy/compose/secrets deploy/observability/secrets
|
||||||
|
umask 077
|
||||||
|
openssl rand -out deploy/compose/secrets/signing-key 32
|
||||||
|
openssl rand -hex 32 >deploy/observability/secrets/rendezvous-metrics-token
|
||||||
|
openssl rand -base64 36 >deploy/observability/secrets/grafana-admin-password
|
||||||
|
export RENDEZVOUS_UID="$(id -u)"
|
||||||
|
export RENDEZVOUS_GID="$(id -g)"
|
||||||
|
test "$RENDEZVOUS_UID" -ne 0
|
||||||
|
docker compose \
|
||||||
|
-f deploy/compose/compose.yaml \
|
||||||
|
-f deploy/observability/compose.yaml \
|
||||||
|
up --build --detach
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://127.0.0.1:3000`, sign in as `rendezvous-admin`, and open the
|
||||||
|
**Rendezvous operational overview** folder/dashboard. The provisioned panels
|
||||||
|
cover scrape/store/drain health, listing and join capacity, HTTP volume/errors
|
||||||
|
and p95, browse/SSE load, lease and join operations, UDP results/latency/bytes,
|
||||||
|
admission drops, connection outcomes, pairing latency, presence/expiry state,
|
||||||
|
signing windows, security/audit results, and process/GC/descriptor pressure.
|
||||||
|
Capacity gauges use the approved single-process envelope of 25,000 listings and
|
||||||
|
10,000 active attempts, with 70% warning and 90% critical thresholds. The UDP
|
||||||
|
response series is a conservative admitted maximum, not observed egress.
|
||||||
|
|
||||||
|
Validate merged configuration and checked-in dashboard structure before every
|
||||||
|
rollout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RENDEZVOUS_UID="$(id -u)" RENDEZVOUS_GID="$(id -g)" \
|
||||||
|
docker compose \
|
||||||
|
-f deploy/compose/compose.yaml \
|
||||||
|
-f deploy/observability/compose.yaml \
|
||||||
|
config --quiet
|
||||||
|
./scripts/test-observability-assets.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For a real deployment, keep Grafana on a private authenticated management
|
||||||
|
network instead of host loopback, replace the local admin login with the
|
||||||
|
organization's supported identity boundary, enforce TLS at the edge, and set
|
||||||
|
retention to the approved operational period. Do not make Prometheus public.
|
||||||
|
Provisioning is read-only so local UI edits cannot silently drift from source.
|
||||||
|
|
||||||
|
## Verify, rotate, and disable
|
||||||
|
|
||||||
|
After startup, verify the dashboard shows `UP`, store `AVAILABLE`, a nonzero
|
||||||
|
signing window, and changing request/UDP panels during a smoke run. Confirm an
|
||||||
|
unauthenticated `/metrics` request returns `404`, the bearer-authenticated
|
||||||
|
collector target is healthy, Prometheus is not bound on a host port, Grafana is
|
||||||
|
not anonymously accessible, and dashboard query labels contain no identifiers.
|
||||||
|
|
||||||
|
Rotate metrics access by writing a new 32-128 character token to the secret file
|
||||||
|
with private permissions and restarting Rendezvous and Prometheus together.
|
||||||
|
Rotate the Grafana administrator password through the same protected secret
|
||||||
|
workflow. Delete both secret files after a disposable local run.
|
||||||
|
|
||||||
|
To disable aggregate observability independently, stop/remove the overlay and
|
||||||
|
set `Rendezvous:Metrics:Enabled` to `false`; `/metrics` returns `404` and the
|
||||||
|
core service continues. To disable only the public session diagnostic, leave the
|
||||||
|
overlay running and set `Rendezvous:Diagnostics:Enabled` to `false`.
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
This runbook defines the production signals and privileged controls for the
|
This runbook defines the production signals and privileged controls for the
|
||||||
Rendezvous service. The service emits `System.Diagnostics.Metrics` instruments
|
Rendezvous service. The service emits `System.Diagnostics.Metrics` instruments
|
||||||
from the `FinalFactory.Rendezvous` meter and distributed-tracing activities from
|
from the `FinalFactory.Rendezvous` meter, distributed-tracing activities from
|
||||||
`FinalFactory.Rendezvous.Server`. Connect those sources to the deployment's
|
`FinalFactory.Rendezvous.Server`, and an optional bearer-protected Prometheus
|
||||||
OpenTelemetry or equivalent collector. Do not add identifiers to metric labels.
|
endpoint. Connect only a private collector network. Do not add identifiers to
|
||||||
|
metric labels.
|
||||||
|
|
||||||
Concrete detect/contain/recover/verify procedures for abuse, key compromise,
|
Concrete detect/contain/recover/verify procedures for abuse, key compromise,
|
||||||
targeted revocation, restart, rollback, saturation, privacy incidents, and
|
targeted revocation, restart, rollback, saturation, privacy incidents, and
|
||||||
@@ -38,6 +39,25 @@ dependency upgrades are in the [incident and change runbooks](incident-runbooks.
|
|||||||
| `rendezvous.store.expiry_churn` | Cumulative natural expiry activity | none |
|
| `rendezvous.store.expiry_churn` | Cumulative natural expiry activity | none |
|
||||||
| `rendezvous.store.available` | Store health (`1` available, `0` unavailable) | none |
|
| `rendezvous.store.available` | Store health (`1` available, `0` unavailable) | none |
|
||||||
|
|
||||||
|
The Prometheus exporter additionally exposes active/fresh/awaiting store state,
|
||||||
|
drain state, SSE subscriber/tenant/replay gauges, bounded HTTP and UDP
|
||||||
|
histograms, UDP ingress and conservative admitted-response budgets, signing-key
|
||||||
|
state/window gauges, and process/.NET pressure. Its only labels are the fixed
|
||||||
|
operation, status, result, transport, partition, action, outcome, elapsed-bucket,
|
||||||
|
key-state, and GC-generation dimensions. Unknown or unsafe values normalize to
|
||||||
|
`other`; identifiers, metadata, addresses, endpoints, tokens, and capabilities
|
||||||
|
are never labels.
|
||||||
|
|
||||||
|
The exporter is disabled by default. Enabling `Rendezvous:Metrics:Enabled`
|
||||||
|
requires `BearerTokenSecretReference` to be an external `env:` or absolute
|
||||||
|
`file:` secret containing 32-128 visible ASCII bytes. Unauthorized requests get
|
||||||
|
the same `404` as a disabled endpoint, and accepted responses are `no-store`.
|
||||||
|
Expose `/metrics` only to the private collector network, rotate its token as a
|
||||||
|
deployment secret, and never place the token in a URL, Compose environment
|
||||||
|
value, dashboard, log, or issue. The checked-in Prometheus/Grafana provisioning
|
||||||
|
and its verification procedure are in
|
||||||
|
[diagnostic dashboards](diagnostic-dashboards.md).
|
||||||
|
|
||||||
HTTP responses include `X-Rendezvous-Correlation-ID`. It is a generated trace ID
|
HTTP responses include `X-Rendezvous-Correlation-ID`. It is a generated trace ID
|
||||||
or random value, never a caller-supplied session or player identifier. UDP and
|
or random value, never a caller-supplied session or player identifier. UDP and
|
||||||
HTTP activities contain operation-level data only. Logs and traces must not add
|
HTTP activities contain operation-level data only. Logs and traces must not add
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Production-readiness decision and real-network canary
|
||||||
|
|
||||||
|
Tracking: #23
|
||||||
|
|
||||||
|
Rendezvous v1 is **not production-ready** until every required gate in
|
||||||
|
[`production-readiness-v1.json`](../evidence/production-readiness-v1.json) is
|
||||||
|
recorded as `pass`. The machine-checkable decision is intentionally fail-closed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check-production-readiness.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit `0` means every required gate is present and passing, exit `3` means the
|
||||||
|
record is valid but at least one gate is pending or failed, and exit `2` means
|
||||||
|
the record itself is malformed or contains identifier-, endpoint-, account-, or
|
||||||
|
credential-shaped data. Editing only the top-level decision cannot make the
|
||||||
|
check pass.
|
||||||
|
|
||||||
|
The checked-in record is an index, not a log archive. It contains one
|
||||||
|
repository-relative evidence reference and a short categorical note per gate.
|
||||||
|
Raw packet captures, client event streams, publisher credentials, public or
|
||||||
|
private network endpoints, listing IDs, and player/account identifiers must not
|
||||||
|
be committed.
|
||||||
|
|
||||||
|
## Required decision matrix
|
||||||
|
|
||||||
|
The local matrix covers immutable artifacts, Debug and Release verification,
|
||||||
|
both real game consumers, the candidate capacity/resilience profile,
|
||||||
|
production-process recovery, and the combined security/privacy/observability
|
||||||
|
gate. These may be reproduced by the project team on a clean candidate commit.
|
||||||
|
|
||||||
|
The external matrix remains distinct because a local namespace, loopback,
|
||||||
|
container bridge, or second process on one machine cannot prove it:
|
||||||
|
|
||||||
|
| Gate | Required evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| Public package empty-cache restore | A clean machine restores the exact Client and Contracts version using only the documented public sources. |
|
||||||
|
| Signed publication | The immutable tag publishes packages, image digest, SBOMs, provenance, checksums, and verifiable signatures through the protected release workflow. |
|
||||||
|
| Source-preserving UDP ingress | Packet capture on the service host proves the mediator observes each peer's real source tuple and replies from the advertised public tuple; no UDP proxy rewrites either direction. |
|
||||||
|
| Same-LAN direct canary | Two independently operated game clients establish authenticated direct LiteNetLib traffic. |
|
||||||
|
| Home-NAT direct canary | Host and joiner on distinct residential networks establish authenticated direct LiteNetLib traffic. |
|
||||||
|
| Restrictive/CGNAT and blocked-UDP canaries | Each bounded join exits `12`, records a typed terminal category, and exposes the game-owned fallback policy without hanging or claiming success. |
|
||||||
|
| IPv6 direct canary | Two external IPv6 clients record authenticated direct traffic and an observed `ipv6` peer address family. |
|
||||||
|
| Public rate-shaped capacity | The documented HTTP/UDP workload mix meets its objectives through TLS, Kestrel, JSON, LiteNetLib, kernel sockets, and public ingress. |
|
||||||
|
| One-hour endurance | The immutable production-shaped candidate completes the one-hour profile without a state, handle, memory, readiness, or latency failure. |
|
||||||
|
| Alert delivery | A real alert sink receives both trigger and recovery notifications for the rehearsed outage. |
|
||||||
|
| Cold-standby rollback | Drain, stop, socket release, replacement start, host re-registration, and rollback meet the process and host-visible recovery objectives. |
|
||||||
|
| Documentation-only exercise | An operator who did not author the runbooks completes key rotation/revocation, outage, restart, re-registration, and rollback using only the checked-in documentation. |
|
||||||
|
|
||||||
|
Failure or missing evidence is blocking. It is never converted into an accepted
|
||||||
|
risk by changing the wording of the readiness note.
|
||||||
|
|
||||||
|
When an external gate passes, add a redacted repository JSON attestation and
|
||||||
|
point that gate's `evidenceRef` to it. The checker requires this exact shape and
|
||||||
|
binds the gate to the evaluated candidate commit. `artifactDigest` is the SHA-256
|
||||||
|
of the protected evidence bundle or public release record, not a peer endpoint,
|
||||||
|
listing identifier, account identifier, or credential:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "rendezvous-external-gate-attestation",
|
||||||
|
"gateId": "replace-with-the-exact-gate-id",
|
||||||
|
"candidateCommit": "replace-with-the-40-character-candidate-commit",
|
||||||
|
"result": "pass",
|
||||||
|
"performedAtUtc": "2026-01-01T00:00:00Z",
|
||||||
|
"artifactDigest": "replace-with-the-64-character-sha256",
|
||||||
|
"evidenceLocation": "protected-operations-record",
|
||||||
|
"reviewerRole": "independent-operator"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed evidence locations are `protected-operations-record` and
|
||||||
|
`public-release-record`. Allowed reviewer roles are `release-operator`,
|
||||||
|
`network-operator`, `security-operator`, and `independent-operator`. The checker
|
||||||
|
rejects a missing file, wrong gate, wrong candidate, malformed digest, naive
|
||||||
|
timestamp, extra fields, or sensitive-data-shaped contents.
|
||||||
|
|
||||||
|
## Prepare one immutable canary build
|
||||||
|
|
||||||
|
Use the exact release candidate on every canary machine. Verify a clean checkout,
|
||||||
|
restore in locked mode, and build the TestClient before changing networks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -z "$(git status --porcelain)"
|
||||||
|
dotnet restore Rendezvous.slnx --locked-mode
|
||||||
|
dotnet build Rendezvous.slnx --configuration Release --no-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep shell tracing disabled. The host receives a short-lived, least-scope
|
||||||
|
publisher credential through `RENDEZVOUS_PUBLISHER_CREDENTIAL`; it must never be
|
||||||
|
put in an argument, coordination file, evidence file, command transcript, or
|
||||||
|
support message. Set the public HTTPS service URL and advertised UDP mediator
|
||||||
|
tuple separately. TestClient rejects credentials embedded in the service URL.
|
||||||
|
|
||||||
|
## Run a success canary across two machines
|
||||||
|
|
||||||
|
On the host machine, choose `same-lan`, `home-nat`, or `ipv6-direct`. The
|
||||||
|
coordination file is mode `0600` and contains only the temporary listing UUID.
|
||||||
|
It is not evidence; transfer it through an approved private channel, then delete
|
||||||
|
both copies.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set +x
|
||||||
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL='supplied-by-the-approved-secret-boundary'
|
||||||
|
export RENDEZVOUS_CANARY_ROLE=host
|
||||||
|
export RENDEZVOUS_CANARY_TOPOLOGY=home-nat
|
||||||
|
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
|
||||||
|
export RENDEZVOUS_CANARY_HTTP_URL='https://service.example.invalid/'
|
||||||
|
export RENDEZVOUS_CANARY_UDP_ENDPOINT='203.0.113.10:9050'
|
||||||
|
export RENDEZVOUS_CANARY_COORDINATION_FILE="$HOME/.local/state/rendezvous-canary-listing"
|
||||||
|
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/home-nat-host.json"
|
||||||
|
./scripts/run-real-network-canary.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The host prints only that it is ready and waits for the authenticated exchange.
|
||||||
|
On the joiner, read the securely transferred UUID without placing it in shell
|
||||||
|
history and run the matching topology:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set +x
|
||||||
|
read -r RENDEZVOUS_CANARY_LISTING_ID < "$HOME/.local/state/rendezvous-canary-listing"
|
||||||
|
export RENDEZVOUS_CANARY_LISTING_ID
|
||||||
|
export RENDEZVOUS_CANARY_ROLE=client-success
|
||||||
|
export RENDEZVOUS_CANARY_TOPOLOGY=home-nat
|
||||||
|
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
|
||||||
|
export RENDEZVOUS_CANARY_HTTP_URL='https://service.example.invalid/'
|
||||||
|
export RENDEZVOUS_CANARY_UDP_ENDPOINT='203.0.113.10:9050'
|
||||||
|
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/home-nat-client.json"
|
||||||
|
./scripts/run-real-network-canary.sh
|
||||||
|
unset RENDEZVOUS_CANARY_LISTING_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
The host summary requires authenticated direct traffic and deregistration. The
|
||||||
|
client summary requires connection, authenticated direct traffic, accepted
|
||||||
|
outcome reporting, and the declared address family observed on the actual peer.
|
||||||
|
The summaries deliberately contain no network tuple or listing identifier.
|
||||||
|
|
||||||
|
For IPv6, set the topology to `ipv6-direct`, the family to `ipv6`, and use the
|
||||||
|
deployment's bracketed IPv6 mediator form. Record unsupported operating systems,
|
||||||
|
console platforms, VPNs, and address families as untested; an IPv4 pass is not
|
||||||
|
evidence for IPv6 or a platform network policy.
|
||||||
|
|
||||||
|
## Run a bounded failure canary
|
||||||
|
|
||||||
|
Start the host from an independently reachable network as above. On the joiner,
|
||||||
|
apply the reviewed firewall rule that blocks the relevant UDP path, or use the
|
||||||
|
known restrictive carrier network, then set `client-expected-failure` and the
|
||||||
|
matching topology:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RENDEZVOUS_CANARY_ROLE=client-expected-failure
|
||||||
|
export RENDEZVOUS_CANARY_TOPOLOGY=firewall-blocked-udp
|
||||||
|
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
|
||||||
|
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/firewall-blocked-client.json"
|
||||||
|
./scripts/run-real-network-canary.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This role passes only when TestClient exits exactly `12`, emits a non-empty typed
|
||||||
|
authorization/traversal outcome, and emits the authoritative fallback category.
|
||||||
|
A timeout without the typed terminal outcome, exit `0`, direct-traffic success,
|
||||||
|
or an unbounded process is a failed canary. Restore the firewall after the drill
|
||||||
|
and verify normal traffic again.
|
||||||
|
|
||||||
|
## Private diagnostics and retention
|
||||||
|
|
||||||
|
The harness creates raw JSON events under a randomly named `0700`-equivalent
|
||||||
|
temporary directory with a process `umask` of `077`. Successful raw events are
|
||||||
|
deleted automatically. On failure they remain in that private directory so the
|
||||||
|
operator can triage locally; do not attach them to an issue before removing
|
||||||
|
listing IDs and reviewing every field. Set `RENDEZVOUS_CANARY_KEEP_RAW=true`
|
||||||
|
only for an approved short-lived diagnostic capture, then delete it manually.
|
||||||
|
|
||||||
|
The sanitized summary contains the commit, clean/dirty tree state, UTC time,
|
||||||
|
role, declared topology, observed address-family gate, aggregate booleans, and
|
||||||
|
the retention policy. Formal evidence requires the default clean-tree check.
|
||||||
|
|
||||||
|
## Public ingress proof
|
||||||
|
|
||||||
|
Success through a public hostname is insufficient proof that UDP source/reply
|
||||||
|
addressing is preserved. During a canary, an authorized operator must capture
|
||||||
|
only packet headers at the service host and verify:
|
||||||
|
|
||||||
|
1. each authenticated contribution reaches the mediator with the external peer
|
||||||
|
source tuple visible to the server;
|
||||||
|
2. introductions are sent from the same advertised public mediator tuple;
|
||||||
|
3. no load balancer, user-space proxy, service mesh, or destination NAT changes
|
||||||
|
the source or reply tuple expected by LiteNetLib; and
|
||||||
|
4. malformed or unauthenticated traffic receives no amplified response.
|
||||||
|
|
||||||
|
Store the approval, capture time window, candidate digest, topology category,
|
||||||
|
and pass/fail result. Do not retain packet payloads or peer tuples in the
|
||||||
|
repository. A failed tuple check blocks release even if one canary happened to
|
||||||
|
connect.
|
||||||
|
|
||||||
|
## Rehearsal and triage
|
||||||
|
|
||||||
|
Run the security, capacity, observability, deployment, rollback, privacy, and
|
||||||
|
incident procedures against the same immutable candidate. The independent
|
||||||
|
operator records which runbook revision they followed, start/end time, observed
|
||||||
|
alerts, recovery time, unexpected decisions, and pass/fail result. Update the
|
||||||
|
documentation and repeat any failed or ambiguous step.
|
||||||
|
|
||||||
|
Before changing the readiness record, reconcile every open roadmap issue as one
|
||||||
|
of: `blocking` with an owner and evidence needed, `accepted-v1` with a bounded
|
||||||
|
documented limitation, or `post-v1` with a filed issue. HA, active-active or
|
||||||
|
multi-region routing, relays, platform authentication, and scale above the
|
||||||
|
single-active v1 envelope are not silently accepted; each needs a traceable
|
||||||
|
post-v1 issue. The current follow-ups are relay decision [#24], HA/multi-region
|
||||||
|
shared state and routing [#28], scale beyond the measured envelope [#29], and
|
||||||
|
platform authentication adapters [#30]. Run the checker after every evidence
|
||||||
|
update. Only its `READY` result may support a production-ready claim.
|
||||||
|
|
||||||
|
[#24]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/24
|
||||||
|
[#28]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/28
|
||||||
|
[#29]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/29
|
||||||
|
[#30]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/30
|
||||||
Executable
+309
@@ -0,0 +1,309 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate the redacted v1 readiness record and emit the release decision."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL_GATES = {
|
||||||
|
"immutable-release-artifacts",
|
||||||
|
"debug-and-release-verification",
|
||||||
|
"real-consumer-pilots",
|
||||||
|
"candidate-capacity-resilience",
|
||||||
|
"production-process-recovery",
|
||||||
|
"security-privacy-observability",
|
||||||
|
}
|
||||||
|
EXTERNAL_GATES = {
|
||||||
|
"public-package-empty-cache-restore",
|
||||||
|
"signed-publication",
|
||||||
|
"source-preserving-udp-ingress",
|
||||||
|
"same-lan-direct-canary",
|
||||||
|
"home-nat-direct-canary",
|
||||||
|
"restrictive-cgnat-typed-failure",
|
||||||
|
"firewall-blocked-udp-typed-failure",
|
||||||
|
"ipv6-direct-canary",
|
||||||
|
"public-rate-shaped-capacity",
|
||||||
|
"one-hour-candidate-endurance",
|
||||||
|
"alert-delivery",
|
||||||
|
"cold-standby-rollback-drill",
|
||||||
|
"documentation-only-runbook-exercise",
|
||||||
|
}
|
||||||
|
STATUSES = {"pass", "pending", "fail"}
|
||||||
|
FORBIDDEN_KEY_PARTS = {
|
||||||
|
"address",
|
||||||
|
"credential",
|
||||||
|
"endpoint",
|
||||||
|
"listingid",
|
||||||
|
"password",
|
||||||
|
"playerid",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"userid",
|
||||||
|
}
|
||||||
|
UUID = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F-]{27,}\b")
|
||||||
|
IPV4 = re.compile(r"(?<![0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9])")
|
||||||
|
IPV6 = re.compile(
|
||||||
|
r"(?i)(?:\b[0-9a-f]{0,4}:[0-9a-f:]*::[0-9a-f:]*\b|\b(?:[0-9a-f]{1,4}:){4,}[0-9a-f:]{1,39}\b)"
|
||||||
|
)
|
||||||
|
COMMIT = re.compile(r"[0-9a-f]{40}")
|
||||||
|
DIGEST = re.compile(r"[0-9a-f]{64}")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRecord(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def reject_sensitive(value: Any, path: str = "$") -> None:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, child in value.items():
|
||||||
|
normalized = re.sub(r"[^a-z0-9]", "", key.lower())
|
||||||
|
if any(part in normalized for part in FORBIDDEN_KEY_PARTS):
|
||||||
|
raise InvalidRecord(f"{path}.{key} uses a forbidden sensitive-data key")
|
||||||
|
reject_sensitive(child, f"{path}.{key}")
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for index, child in enumerate(value):
|
||||||
|
reject_sensitive(child, f"{path}[{index}]")
|
||||||
|
elif isinstance(value, str):
|
||||||
|
if UUID.search(value) or IPV4.search(value) or IPV6.search(value) \
|
||||||
|
or "://" in value or "@" in value:
|
||||||
|
raise InvalidRecord(f"{path} contains endpoint, identifier, or account-shaped data")
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_path(repository_root: pathlib.Path, value: str, path: str) -> pathlib.Path:
|
||||||
|
relative = pathlib.PurePosixPath(value)
|
||||||
|
if relative.is_absolute() or ".." in relative.parts or not value:
|
||||||
|
raise InvalidRecord(f"{path} must be a repository-relative reference")
|
||||||
|
candidate = (repository_root / pathlib.Path(*relative.parts)).resolve()
|
||||||
|
if not candidate.is_relative_to(repository_root.resolve()) or not candidate.is_file():
|
||||||
|
raise InvalidRecord(f"{path} does not resolve to a repository evidence file")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: pathlib.Path, label: str) -> Any:
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as source:
|
||||||
|
return json.load(source)
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise InvalidRecord(f"{label} is not readable JSON: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def validate_gate_set(
|
||||||
|
items: Any,
|
||||||
|
expected: set[str],
|
||||||
|
path: str,
|
||||||
|
repository_root: pathlib.Path,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
if not isinstance(items, list):
|
||||||
|
raise InvalidRecord(f"{path} must be an array")
|
||||||
|
gates: list[dict[str, str]] = []
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
if not isinstance(item, dict) or set(item) != {"id", "status", "evidenceRef", "note"}:
|
||||||
|
raise InvalidRecord(f"{path}[{index}] has an invalid shape")
|
||||||
|
if not all(isinstance(item[key], str) for key in item):
|
||||||
|
raise InvalidRecord(f"{path}[{index}] fields must be strings")
|
||||||
|
if item["status"] not in STATUSES:
|
||||||
|
raise InvalidRecord(f"{path}[{index}] has an invalid status")
|
||||||
|
evidence_path(repository_root, item["evidenceRef"], f"{path}[{index}].evidenceRef")
|
||||||
|
if len(item["note"]) > 240:
|
||||||
|
raise InvalidRecord(f"{path}[{index}].note is too long")
|
||||||
|
gates.append(item)
|
||||||
|
identifiers = [gate["id"] for gate in gates]
|
||||||
|
if len(identifiers) != len(set(identifiers)):
|
||||||
|
raise InvalidRecord(f"{path} contains duplicate gate identifiers")
|
||||||
|
if set(identifiers) != expected:
|
||||||
|
missing = sorted(expected - set(identifiers))
|
||||||
|
extra = sorted(set(identifiers) - expected)
|
||||||
|
raise InvalidRecord(f"{path} gate mismatch; missing={missing}, extra={extra}")
|
||||||
|
return gates
|
||||||
|
|
||||||
|
|
||||||
|
def validate_local_evidence(
|
||||||
|
record: dict[str, Any],
|
||||||
|
gates: list[dict[str, str]],
|
||||||
|
repository_root: pathlib.Path,
|
||||||
|
) -> None:
|
||||||
|
if any(gate["status"] != "pass" for gate in gates):
|
||||||
|
return
|
||||||
|
commit = record["evaluatedCommit"]
|
||||||
|
release_path = evidence_path(
|
||||||
|
repository_root,
|
||||||
|
"docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||||
|
"local release evidence",
|
||||||
|
)
|
||||||
|
release = load_json(release_path, "local release evidence")
|
||||||
|
if not isinstance(release, dict) or release.get("schemaVersion") != 1 \
|
||||||
|
or release.get("kind") != "rendezvous-local-release-candidate" \
|
||||||
|
or release.get("sourceCommit") != commit \
|
||||||
|
or release.get("treeState") != "clean" \
|
||||||
|
or release.get("result") != "pass":
|
||||||
|
raise InvalidRecord("local release evidence is not a passing clean build of evaluatedCommit")
|
||||||
|
verification = release.get("verification")
|
||||||
|
if not isinstance(verification, dict):
|
||||||
|
raise InvalidRecord("local release evidence has no verification object")
|
||||||
|
exact_passes = {
|
||||||
|
"lockedRestore": "pass",
|
||||||
|
"format": "pass",
|
||||||
|
"byteReproduciblePackages": "pass",
|
||||||
|
"byteReproducibleServerArchive": "pass",
|
||||||
|
"sbomChecksumsAndProvenance": "pass",
|
||||||
|
"candidateConsumerFixtures": "pass",
|
||||||
|
"realConsumerRestores": "pass",
|
||||||
|
}
|
||||||
|
if any(verification.get(key) != value for key, value in exact_passes.items()) \
|
||||||
|
or verification.get("reportedVulnerabilities") != 0 \
|
||||||
|
or verification.get("releaseBuildWarnings") != 0 \
|
||||||
|
or verification.get("releaseBuildErrors") != 0 \
|
||||||
|
or verification.get("debugTestsPassed", 0) < 300 \
|
||||||
|
or verification.get("debugTestsFailed") != 0 \
|
||||||
|
or verification.get("releaseTestsPassed", 0) < 300 \
|
||||||
|
or verification.get("releaseTestsFailed") != 0 \
|
||||||
|
or verification.get("selectedProductionFaultTestsPassed", 0) < 17:
|
||||||
|
raise InvalidRecord("local release evidence does not satisfy every required verification")
|
||||||
|
consumers = release.get("consumers")
|
||||||
|
if not isinstance(consumers, list) or {
|
||||||
|
item.get("name") for item in consumers if isinstance(item, dict)
|
||||||
|
} != {"SpaceGame", "Unscouted"} or any(
|
||||||
|
not isinstance(item, dict)
|
||||||
|
or item.get("candidateRestore") != "pass"
|
||||||
|
or item.get("directTrafficPilot") != "pass"
|
||||||
|
for item in consumers
|
||||||
|
):
|
||||||
|
raise InvalidRecord("local release evidence does not prove both required consumers")
|
||||||
|
|
||||||
|
capacity_path = evidence_path(
|
||||||
|
repository_root,
|
||||||
|
"docs/evidence/capacity/v2/candidate-2cpu.json",
|
||||||
|
"candidate capacity evidence",
|
||||||
|
)
|
||||||
|
capacity = load_json(capacity_path, "candidate capacity evidence")
|
||||||
|
runtime = capacity.get("runtime") if isinstance(capacity, dict) else None
|
||||||
|
state = capacity.get("state") if isinstance(capacity, dict) else None
|
||||||
|
if not isinstance(runtime, dict) or not isinstance(state, dict) \
|
||||||
|
or capacity.get("schemaVersion") != 2 \
|
||||||
|
or capacity.get("profile") != "candidate" \
|
||||||
|
or capacity.get("passed") is not True \
|
||||||
|
or capacity.get("failures") != [] \
|
||||||
|
or runtime.get("commitSha") != commit \
|
||||||
|
or runtime.get("treeState") != "clean" \
|
||||||
|
or runtime.get("processorCount") != 2 \
|
||||||
|
or state.get("soakDurationSeconds", 0) < 300 \
|
||||||
|
or state.get("finalListings") != 0 \
|
||||||
|
or state.get("finalAttempts") != 0 \
|
||||||
|
or state.get("finalReplayMarkers") != 0 \
|
||||||
|
or state.get("restartStartedEmpty") is not True \
|
||||||
|
or state.get("overloadWasTyped") is not True \
|
||||||
|
or state.get("recoverySucceeded") is not True:
|
||||||
|
raise InvalidRecord("candidate capacity evidence does not satisfy the clean evaluated commit")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_external_attestations(
|
||||||
|
record: dict[str, Any],
|
||||||
|
gates: list[dict[str, str]],
|
||||||
|
repository_root: pathlib.Path,
|
||||||
|
) -> None:
|
||||||
|
for gate in gates:
|
||||||
|
if gate["status"] != "pass":
|
||||||
|
continue
|
||||||
|
path = evidence_path(repository_root, gate["evidenceRef"], f"{gate['id']} evidence")
|
||||||
|
attestation = load_json(path, f"{gate['id']} evidence")
|
||||||
|
if not isinstance(attestation, dict) or set(attestation) != {
|
||||||
|
"schemaVersion",
|
||||||
|
"kind",
|
||||||
|
"gateId",
|
||||||
|
"candidateCommit",
|
||||||
|
"result",
|
||||||
|
"performedAtUtc",
|
||||||
|
"artifactDigest",
|
||||||
|
"evidenceLocation",
|
||||||
|
"reviewerRole",
|
||||||
|
}:
|
||||||
|
raise InvalidRecord(f"{gate['id']} requires a complete external-gate attestation")
|
||||||
|
reject_sensitive(attestation, f"external evidence {gate['id']}")
|
||||||
|
if attestation["schemaVersion"] != 1 \
|
||||||
|
or attestation["kind"] != "rendezvous-external-gate-attestation" \
|
||||||
|
or attestation["gateId"] != gate["id"] \
|
||||||
|
or attestation["candidateCommit"] != record["evaluatedCommit"] \
|
||||||
|
or attestation["result"] != "pass" \
|
||||||
|
or not isinstance(attestation["artifactDigest"], str) \
|
||||||
|
or not DIGEST.fullmatch(attestation["artifactDigest"]) \
|
||||||
|
or attestation["evidenceLocation"] not in {
|
||||||
|
"protected-operations-record",
|
||||||
|
"public-release-record",
|
||||||
|
} \
|
||||||
|
or attestation["reviewerRole"] not in {
|
||||||
|
"release-operator",
|
||||||
|
"network-operator",
|
||||||
|
"security-operator",
|
||||||
|
"independent-operator",
|
||||||
|
}:
|
||||||
|
raise InvalidRecord(f"{gate['id']} external attestation does not match the candidate gate")
|
||||||
|
try:
|
||||||
|
performed = datetime.fromisoformat(attestation["performedAtUtc"].replace("Z", "+00:00"))
|
||||||
|
except (AttributeError, ValueError) as error:
|
||||||
|
raise InvalidRecord(f"{gate['id']} has an invalid performedAtUtc") from error
|
||||||
|
if performed.tzinfo is None or performed.utcoffset() != timedelta(0):
|
||||||
|
raise InvalidRecord(f"{gate['id']} performedAtUtc must be UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def validate(record: Any, repository_root: pathlib.Path) -> tuple[bool, list[str]]:
|
||||||
|
if not isinstance(record, dict) or set(record) != {
|
||||||
|
"schemaVersion",
|
||||||
|
"kind",
|
||||||
|
"evaluatedCommit",
|
||||||
|
"decision",
|
||||||
|
"localGates",
|
||||||
|
"externalGates",
|
||||||
|
}:
|
||||||
|
raise InvalidRecord("The top-level readiness record shape is invalid")
|
||||||
|
if record["schemaVersion"] != 1 or record["kind"] != "rendezvous-production-readiness":
|
||||||
|
raise InvalidRecord("The readiness schema identity is invalid")
|
||||||
|
if not isinstance(record["evaluatedCommit"], str) or not COMMIT.fullmatch(record["evaluatedCommit"]):
|
||||||
|
raise InvalidRecord("evaluatedCommit must be a full lowercase Git commit")
|
||||||
|
reject_sensitive(record)
|
||||||
|
local_gates = validate_gate_set(
|
||||||
|
record["localGates"], LOCAL_GATES, "$.localGates", repository_root
|
||||||
|
)
|
||||||
|
external_gates = validate_gate_set(
|
||||||
|
record["externalGates"], EXTERNAL_GATES, "$.externalGates", repository_root
|
||||||
|
)
|
||||||
|
validate_local_evidence(record, local_gates, repository_root)
|
||||||
|
validate_external_attestations(record, external_gates, repository_root)
|
||||||
|
gates = local_gates + external_gates
|
||||||
|
blockers = sorted(gate["id"] for gate in gates if gate["status"] != "pass")
|
||||||
|
ready = not blockers
|
||||||
|
expected_decision = "ready" if ready else "not-ready"
|
||||||
|
if record["decision"] != expected_decision:
|
||||||
|
raise InvalidRecord(
|
||||||
|
f"decision must be {expected_decision!r} for the recorded gate statuses"
|
||||||
|
)
|
||||||
|
return ready, blockers
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("usage: check_production_readiness.py RECORD", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as source:
|
||||||
|
record = json.load(source)
|
||||||
|
ready, blockers = validate(record, pathlib.Path(__file__).resolve().parent.parent)
|
||||||
|
except (OSError, json.JSONDecodeError, InvalidRecord) as error:
|
||||||
|
print(f"INVALID: {error}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not ready:
|
||||||
|
print(f"NOT READY: {len(blockers)} required gate(s) are not passing.")
|
||||||
|
for blocker in blockers:
|
||||||
|
print(f"- {blocker}")
|
||||||
|
return 3
|
||||||
|
print("READY: every required v1 production gate is recorded as passing.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -4,13 +4,13 @@
|
|||||||
{
|
{
|
||||||
"name": "SpaceGame",
|
"name": "SpaceGame",
|
||||||
"repository": "https://git.finalfactory.de/Kyuubi/SpaceGame.git",
|
"repository": "https://git.finalfactory.de/Kyuubi/SpaceGame.git",
|
||||||
"revision": "77519b0cc418a27f8d408ae2d7b8812fbe087c04",
|
"revision": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
|
||||||
"project": "SpaceGame.csproj"
|
"project": "SpaceGame.csproj"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Unscouted",
|
"name": "Unscouted",
|
||||||
"repository": "https://git.finalfactory.de/HeiKyu/Unscouted.git",
|
"repository": "https://git.finalfactory.de/HeiKyu/Unscouted.git",
|
||||||
"revision": "7807dbee86eb8b98e702f1eb89c88adff728f635",
|
"revision": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
|
||||||
"project": "Net.Core/Net.Core.csproj"
|
"project": "Net.Core/Net.Core.csproj"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||||
FROM python:3.12.11-slim-bookworm@sha256:c00fc7b44d844b6da22861ec24af43968a5200eac4ec607b4725d585165d6b49 AS release-python
|
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 ghcr.io/jqlang/jq:1.8.1@sha256:95de8f005ca027686a1ca3b0853e2bb219062438015862816159f3f25a4d4230 AS release-jq
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:8.0.28-noble@sha256:19a311642eb7ee9c985bd71b9e5618123879df4e8d948f7388a41b0ee4788e25 AS dotnet-runtime-8
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS release-builder
|
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-python /usr/local/ /usr/local/
|
||||||
COPY --from=release-jq /jq /usr/local/bin/jq
|
COPY --from=release-jq /jq /usr/local/bin/jq
|
||||||
|
COPY --from=dotnet-runtime-8 /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.28/ /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.28/
|
||||||
RUN dotnet --version \
|
RUN dotnet --version \
|
||||||
|
&& dotnet --list-runtimes \
|
||||||
&& python3 --version \
|
&& python3 --version \
|
||||||
&& git --version \
|
&& git --version \
|
||||||
&& tar --version \
|
&& tar --version \
|
||||||
|
|||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
RECORD="${1:-$ROOT/docs/evidence/production-readiness-v1.json}"
|
||||||
|
|
||||||
|
exec python3 "$ROOT/eng/check_production_readiness.py" "$RECORD"
|
||||||
@@ -3,9 +3,25 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
LOCAL_KEY="${RENDEZVOUS_SMOKE_LOCAL_KEY:-$ROOT/deploy/compose/secrets/signing-key}"
|
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
|
if (( $# != 0 )); then
|
||||||
printf 'This helper accepts no arguments and mints only the fixed local Compose smoke scope.\n' >&2
|
printf 'This helper accepts no arguments; select only a provisioned local game through RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID.\n' >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -17,7 +33,7 @@ command -v python3 >/dev/null || {
|
|||||||
# This is deliberately a local-fixture tool, not a general credential issuer.
|
# 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
|
# 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.
|
# a child process argument, environment value, temporary file, or command output.
|
||||||
python3 - "$LOCAL_KEY" <<'PY'
|
python3 - "$LOCAL_KEY" "$GAME_ID" "$KEY_ID" "$SUBJECT" <<'PY'
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
@@ -29,6 +45,9 @@ import sys
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
key_path = sys.argv[1]
|
key_path = sys.argv[1]
|
||||||
|
game_id = sys.argv[2]
|
||||||
|
key_id = sys.argv[3]
|
||||||
|
subject = sys.argv[4]
|
||||||
try:
|
try:
|
||||||
metadata = os.lstat(key_path)
|
metadata = os.lstat(key_path)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -42,8 +61,8 @@ 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}")
|
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:
|
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}")
|
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 & 0o022 or metadata.st_nlink != 1:
|
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 not group/world writable: {key_path}")
|
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:
|
with open(key_path, "rb") as key_file:
|
||||||
key = key_file.read(33)
|
key = key_file.read(33)
|
||||||
@@ -55,9 +74,9 @@ payload = {
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"issuer": "final-factory-rendezvous-smoke",
|
"issuer": "final-factory-rendezvous-smoke",
|
||||||
"audience": "rendezvous-service",
|
"audience": "rendezvous-service",
|
||||||
"subject": "local-smoke-host",
|
"subject": subject,
|
||||||
"kind": "dedicatedPublisher",
|
"kind": "dedicatedPublisher",
|
||||||
"gameId": "space-game",
|
"gameId": game_id,
|
||||||
"environmentId": "smoke",
|
"environmentId": "smoke",
|
||||||
"regions": ["local"],
|
"regions": ["local"],
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
@@ -71,7 +90,7 @@ def base64url(value: bytes) -> str:
|
|||||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
encoded = base64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
encoded = base64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||||
signed = f"rv1.local-smoke-1.{encoded}"
|
signed = f"rv1.{key_id}.{encoded}"
|
||||||
signature = base64url(hmac.new(key, signed.encode("ascii"), hashlib.sha256).digest())
|
signature = base64url(hmac.new(key, signed.encode("ascii"), hashlib.sha256).digest())
|
||||||
print(f"{signed}.{signature}")
|
print(f"{signed}.{signature}")
|
||||||
PY
|
PY
|
||||||
|
|||||||
Executable
+243
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PROJECT="$ROOT/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj"
|
||||||
|
ROLE="${RENDEZVOUS_CANARY_ROLE:-}"
|
||||||
|
TOPOLOGY="${RENDEZVOUS_CANARY_TOPOLOGY:-}"
|
||||||
|
ADDRESS_FAMILY="${RENDEZVOUS_CANARY_ADDRESS_FAMILY:-ipv4}"
|
||||||
|
SERVICE_URL="${RENDEZVOUS_CANARY_HTTP_URL:-}"
|
||||||
|
MEDIATOR="${RENDEZVOUS_CANARY_UDP_ENDPOINT:-}"
|
||||||
|
GAME_ID="${RENDEZVOUS_CANARY_GAME_ID:-space-game}"
|
||||||
|
ENVIRONMENT_ID="${RENDEZVOUS_CANARY_ENVIRONMENT_ID:-production-canary}"
|
||||||
|
REGION="${RENDEZVOUS_CANARY_REGION:-production-canary}"
|
||||||
|
PROTOCOL_VERSION="${RENDEZVOUS_CANARY_PROTOCOL_VERSION:-1}"
|
||||||
|
TIMEOUT_SECONDS="${RENDEZVOUS_CANARY_TIMEOUT_SECONDS:-60}"
|
||||||
|
RUN_SECONDS="${RENDEZVOUS_CANARY_RUN_SECONDS:-900}"
|
||||||
|
OUTPUT="${RENDEZVOUS_CANARY_OUTPUT:-$ROOT/artifacts/canary/${ROLE:-unknown}-${TOPOLOGY:-unknown}.json}"
|
||||||
|
COORDINATION_FILE="${RENDEZVOUS_CANARY_COORDINATION_FILE:-}"
|
||||||
|
LISTING_ID="${RENDEZVOUS_CANARY_LISTING_ID:-}"
|
||||||
|
REQUIRE_CLEAN="${RENDEZVOUS_CANARY_REQUIRE_CLEAN:-true}"
|
||||||
|
KEEP_RAW="${RENDEZVOUS_CANARY_KEEP_RAW:-false}"
|
||||||
|
UUID_PATTERN='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf '%s\n' \
|
||||||
|
'Set RENDEZVOUS_CANARY_ROLE to host, client-success, or client-expected-failure.' \
|
||||||
|
'Also set RENDEZVOUS_CANARY_TOPOLOGY, RENDEZVOUS_CANARY_HTTP_URL, and' \
|
||||||
|
'RENDEZVOUS_CANARY_UDP_ENDPOINT. See docs/operations/production-readiness.md.' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
for command in date dotnet git jq mktemp tail; do
|
||||||
|
command -v "$command" >/dev/null || {
|
||||||
|
printf 'Missing required command: %s\n' "$command" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$ROLE" in
|
||||||
|
host|client-success|client-expected-failure) ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
case "$TOPOLOGY" in
|
||||||
|
same-lan|home-nat|firewall-blocked-udp|restrictive-cgnat|ipv6-direct) ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
case "$ADDRESS_FAMILY" in
|
||||||
|
ipv4|ipv6) ;;
|
||||||
|
*) printf 'RENDEZVOUS_CANARY_ADDRESS_FAMILY must be ipv4 or ipv6.\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
if [[ "$TOPOLOGY" == ipv6-direct && "$ADDRESS_FAMILY" != ipv6 ]]; then
|
||||||
|
printf 'The ipv6-direct topology requires RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv6.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$TOPOLOGY" =~ ^(firewall-blocked-udp|restrictive-cgnat)$ \
|
||||||
|
&& "$ROLE" == client-success ]]; then
|
||||||
|
printf 'Failure topologies must use the client-expected-failure role.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "$SERVICE_URL" || -z "$MEDIATOR" ]]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] \
|
||||||
|
|| (( TIMEOUT_SECONDS < 1 || TIMEOUT_SECONDS > 300 )); then
|
||||||
|
printf 'RENDEZVOUS_CANARY_TIMEOUT_SECONDS must be an integer from 1 through 300.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! "$RUN_SECONDS" =~ ^[0-9]+$ ]] \
|
||||||
|
|| (( RUN_SECONDS < 60 || RUN_SECONDS > 3600 )); then
|
||||||
|
printf 'RENDEZVOUS_CANARY_RUN_SECONDS must be an integer from 60 through 3600.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! "$PROTOCOL_VERSION" =~ ^[0-9]+$ ]] || (( PROTOCOL_VERSION < 1 )); then
|
||||||
|
printf 'RENDEZVOUS_CANARY_PROTOCOL_VERSION must be a positive integer.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$REQUIRE_CLEAN" != true && "$REQUIRE_CLEAN" != false ]]; then
|
||||||
|
printf 'RENDEZVOUS_CANARY_REQUIRE_CLEAN must be true or false.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$KEEP_RAW" != true && "$KEEP_RAW" != false ]]; then
|
||||||
|
printf 'RENDEZVOUS_CANARY_KEEP_RAW must be true or false.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
commit="$(git rev-parse HEAD)"
|
||||||
|
tree_state=clean
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
tree_state=dirty
|
||||||
|
fi
|
||||||
|
if [[ "$REQUIRE_CLEAN" == true && "$tree_state" != clean ]]; then
|
||||||
|
printf 'Formal canary evidence requires a clean source tree.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ROLE" == host ]]; then
|
||||||
|
if [[ -z "$COORDINATION_FILE" ]]; then
|
||||||
|
printf 'The host role requires RENDEZVOUS_CANARY_COORDINATION_FILE.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -e "$COORDINATION_FILE" ]]; then
|
||||||
|
printf 'The host coordination file already exists; remove it explicitly before a new canary.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}" ]]; then
|
||||||
|
printf 'The host role requires RENDEZVOUS_PUBLISHER_CREDENTIAL.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ ! "$LISTING_ID" =~ $UUID_PATTERN ]]; then
|
||||||
|
printf 'A client role requires a UUID in RENDEZVOUS_CANARY_LISTING_ID.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
raw_dir="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-canary.XXXXXXXX")"
|
||||||
|
raw_log="$raw_dir/events.jsonl"
|
||||||
|
run_succeeded=false
|
||||||
|
host_pid=''
|
||||||
|
cleanup() {
|
||||||
|
local status="$?"
|
||||||
|
if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then
|
||||||
|
kill -TERM "$host_pid" 2>/dev/null || true
|
||||||
|
wait "$host_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ "$run_succeeded" == true && "$KEEP_RAW" == false ]]; then
|
||||||
|
rm -rf "$raw_dir"
|
||||||
|
else
|
||||||
|
printf 'Private raw canary events retained at %s\n' "$raw_dir" >&2
|
||||||
|
fi
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
common_arguments=(
|
||||||
|
--service "$SERVICE_URL"
|
||||||
|
--mediator "$MEDIATOR"
|
||||||
|
--game "$GAME_ID"
|
||||||
|
--environment "$ENVIRONMENT_ID"
|
||||||
|
--region "$REGION"
|
||||||
|
--protocol "$PROTOCOL_VERSION"
|
||||||
|
--script
|
||||||
|
--json
|
||||||
|
--timeout-seconds "$TIMEOUT_SECONDS"
|
||||||
|
)
|
||||||
|
|
||||||
|
exit_code=0
|
||||||
|
if [[ "$ROLE" == host ]]; then
|
||||||
|
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
||||||
|
host "${common_arguments[@]}" --exit-after-echo --run-seconds "$RUN_SECONDS" \
|
||||||
|
>"$raw_log" 2>&1 &
|
||||||
|
host_pid="$!"
|
||||||
|
ready=false
|
||||||
|
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
|
||||||
|
if jq -e 'select(.event == "host.ready" and .status == "ready")' "$raw_log" \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
ready=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$host_pid" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
if [[ "$ready" != true ]]; then
|
||||||
|
printf 'The canary host did not become ready within the bounded startup window.\n' >&2
|
||||||
|
kill -TERM "$host_pid" 2>/dev/null || true
|
||||||
|
wait "$host_pid" 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
observed_listing="$(jq -r 'select(.event == "host.registered") | .listingId' "$raw_log" | tail -n 1)"
|
||||||
|
if [[ ! "$observed_listing" =~ $UUID_PATTERN ]]; then
|
||||||
|
printf 'The canary host did not produce a valid coordination identifier.\n' >&2
|
||||||
|
kill -TERM "$host_pid" 2>/dev/null || true
|
||||||
|
wait "$host_pid" 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
coordination_parent="$(dirname "$COORDINATION_FILE")"
|
||||||
|
mkdir -p "$coordination_parent"
|
||||||
|
coordination_temp="$(mktemp "$COORDINATION_FILE.tmp.XXXXXXXX")"
|
||||||
|
printf '%s\n' "$observed_listing" >"$coordination_temp"
|
||||||
|
mv "$coordination_temp" "$COORDINATION_FILE"
|
||||||
|
printf 'Host ready; securely transfer the private coordination file to the client operator.\n'
|
||||||
|
set +e
|
||||||
|
wait "$host_pid"
|
||||||
|
exit_code="$?"
|
||||||
|
set -e
|
||||||
|
elif [[ "$ROLE" == client-success ]]; then
|
||||||
|
set +e
|
||||||
|
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
||||||
|
join "${common_arguments[@]}" --listing "$LISTING_ID" >"$raw_log" 2>&1
|
||||||
|
exit_code="$?"
|
||||||
|
set -e
|
||||||
|
else
|
||||||
|
set +e
|
||||||
|
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
||||||
|
join "${common_arguments[@]}" --listing "$LISTING_ID" >"$raw_log" 2>&1
|
||||||
|
exit_code="$?"
|
||||||
|
set -e
|
||||||
|
fi
|
||||||
|
|
||||||
|
checks='{}'
|
||||||
|
if [[ "$ROLE" == host ]]; then
|
||||||
|
[[ "$exit_code" -eq 0 ]]
|
||||||
|
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "host.direct-traffic" and .status == "verified" and .addressFamily == $family)' "$raw_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "host.deregistered" and .status == "complete")' "$raw_log" >/dev/null
|
||||||
|
checks='{"authenticatedDirectTraffic":true,"deregistered":true}'
|
||||||
|
elif [[ "$ROLE" == client-success ]]; then
|
||||||
|
[[ "$exit_code" -eq 0 ]]
|
||||||
|
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "join.connected" and .status == "connected" and .addressFamily == $family)' "$raw_log" >/dev/null
|
||||||
|
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "join.direct-traffic" and .status == "verified" and .addressFamily == $family)' "$raw_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "join.outcome-report" and .status == "accepted")' "$raw_log" >/dev/null
|
||||||
|
checks='{"authenticatedDirectTraffic":true,"typedOutcomeReported":true}'
|
||||||
|
else
|
||||||
|
[[ "$exit_code" -eq 12 ]]
|
||||||
|
jq -e 'select((.event == "join.traversal" or .event == "join.authorization") and .status == "failed" and (.outcome | type == "string") and (.outcome | length > 0))' "$raw_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "join.fallback" and (.status == "available" or .status == "unavailable") and (.outcome | type == "string") and (.outcome | length > 0))' "$raw_log" >/dev/null
|
||||||
|
checks='{"boundedTypedFailure":true,"fallbackPolicyReported":true}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$OUTPUT")"
|
||||||
|
raw_retention=deleted-after-success
|
||||||
|
if [[ "$KEEP_RAW" == true ]]; then
|
||||||
|
raw_retention=retained-private-on-request
|
||||||
|
fi
|
||||||
|
jq -n \
|
||||||
|
--arg commit "$commit" \
|
||||||
|
--arg treeState "$tree_state" \
|
||||||
|
--arg timestampUtc "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
--arg role "$ROLE" \
|
||||||
|
--arg topology "$TOPOLOGY" \
|
||||||
|
--arg addressFamily "$ADDRESS_FAMILY" \
|
||||||
|
--arg rawEvents "$raw_retention" \
|
||||||
|
--argjson checks "$checks" \
|
||||||
|
'{schemaVersion:1,kind:"rendezvous-real-network-canary",commit:$commit,treeState:$treeState,timestampUtc:$timestampUtc,role:$role,topology:$topology,addressFamily:$addressFamily,result:"pass",checks:$checks,dataRetention:{rawEvents:$rawEvents,identifiers:"not-in-summary",networkEndpoints:"not-in-summary"}}' \
|
||||||
|
>"$OUTPUT"
|
||||||
|
|
||||||
|
run_succeeded=true
|
||||||
|
printf 'Real-network canary passed; sanitized evidence: %s\n' "$OUTPUT"
|
||||||
+31
-15
@@ -58,6 +58,14 @@ temp_dir="$(mktemp -d)"
|
|||||||
host_log="$temp_dir/host.jsonl"
|
host_log="$temp_dir/host.jsonl"
|
||||||
join_log="$temp_dir/join.jsonl"
|
join_log="$temp_dir/join.jsonl"
|
||||||
host_pid=''
|
host_pid=''
|
||||||
|
sanitize_log() {
|
||||||
|
jq -Rrc 'fromjson? | {
|
||||||
|
event: (.event // "unknown"),
|
||||||
|
status: (.status // "unknown"),
|
||||||
|
phase: (.phase // "unknown"),
|
||||||
|
hasListingId: (((.listingId // "") | length) > 0)
|
||||||
|
}' "$1"
|
||||||
|
}
|
||||||
cleanup() {
|
cleanup() {
|
||||||
local status="$?"
|
local status="$?"
|
||||||
if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then
|
if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then
|
||||||
@@ -66,8 +74,8 @@ cleanup() {
|
|||||||
fi
|
fi
|
||||||
if [[ "$status" -ne 0 ]]; then
|
if [[ "$status" -ne 0 ]]; then
|
||||||
printf 'Deployment smoke failed; sanitized diagnostic events follow.\n' >&2
|
printf 'Deployment smoke failed; sanitized diagnostic events follow.\n' >&2
|
||||||
[[ -f "$host_log" ]] && jq -c . "$host_log" >&2 || true
|
[[ -f "$host_log" ]] && sanitize_log "$host_log" >&2 || true
|
||||||
[[ -f "$join_log" ]] && jq -c . "$join_log" >&2 || true
|
[[ -f "$join_log" ]] && sanitize_log "$join_log" >&2 || true
|
||||||
fi
|
fi
|
||||||
rm -rf "$temp_dir"
|
rm -rf "$temp_dir"
|
||||||
return "$status"
|
return "$status"
|
||||||
@@ -84,26 +92,24 @@ dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-buil
|
|||||||
host_pid="$!"
|
host_pid="$!"
|
||||||
|
|
||||||
ready=false
|
ready=false
|
||||||
|
listing_id=''
|
||||||
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
|
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
|
||||||
if jq -e 'select(.event == "host.ready")' "$host_log" >/dev/null 2>&1; then
|
if listing_id="$(jq -er '
|
||||||
|
select(.event == "host.ready" and .status == "ready")
|
||||||
|
| .listingId // empty
|
||||||
|
' "$host_log" 2>/dev/null | tail -n 1)" && [[ -n "$listing_id" ]]; then
|
||||||
ready=true
|
ready=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if ! kill -0 "$host_pid" 2>/dev/null; then
|
if ! kill -0 "$host_pid" 2>/dev/null; then
|
||||||
printf 'Host diagnostic stopped before it became ready.\n' >&2
|
printf 'Host diagnostic stopped before it became ready.\n' >&2
|
||||||
jq -c . "$host_log" >&2 || true
|
sanitize_log "$host_log" >&2 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
sleep 0.25
|
sleep 0.25
|
||||||
done
|
done
|
||||||
if [[ "$ready" != true ]]; then
|
if [[ "$ready" != true ]]; then
|
||||||
printf 'Host diagnostic did not become ready within %s seconds.\n' "$TIMEOUT_SECONDS" >&2
|
printf 'Host diagnostic did not report a ready listing within %s seconds.\n' "$TIMEOUT_SECONDS" >&2
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
listing_id="$(jq -r 'select(.event == "host.registered") | .listingId' "$host_log" | tail -n 1)"
|
|
||||||
if [[ -z "$listing_id" || "$listing_id" == null ]]; then
|
|
||||||
printf 'Host diagnostic did not report a listing ID.\n' >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -115,9 +121,19 @@ dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-buil
|
|||||||
wait "$host_pid"
|
wait "$host_pid"
|
||||||
host_pid=''
|
host_pid=''
|
||||||
|
|
||||||
jq -e 'select(.event == "host.direct-traffic" and .status == "verified")' "$host_log" >/dev/null
|
if ! jq -s -e '
|
||||||
jq -e 'select(.event == "host.deregistered" and .status == "complete")' "$host_log" >/dev/null
|
any(.[]; .event == "host.direct-traffic" and .status == "verified")
|
||||||
jq -e 'select(.event == "join.direct-traffic" and .status == "verified")' "$join_log" >/dev/null
|
and any(.[]; .event == "host.deregistered" and .status == "complete")
|
||||||
jq -e 'select(.event == "join.outcome-report" and .status == "accepted")' "$join_log" >/dev/null
|
' "$host_log" >/dev/null; then
|
||||||
|
printf 'Host diagnostic did not complete authenticated traffic and deregistration.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! jq -s -e '
|
||||||
|
any(.[]; .event == "join.direct-traffic" and .status == "verified")
|
||||||
|
and any(.[]; .event == "join.outcome-report" and .status == "accepted")
|
||||||
|
' "$join_log" >/dev/null; then
|
||||||
|
printf 'Join diagnostic did not complete authenticated traffic and outcome reporting.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
printf 'Rendezvous deployment smoke passed: HTTP live/ready and authenticated UDP mediation/direct traffic.\n'
|
printf 'Rendezvous deployment smoke passed: HTTP live/ready and authenticated UDP mediation/direct traffic.\n'
|
||||||
|
|||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$root"
|
||||||
|
node --test tests/Diagnostics/*.test.mjs
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
node --test tests/Observability/observability-assets.test.mjs
|
||||||
@@ -47,6 +47,8 @@ for ((index = 0; index < count; index++)); do
|
|||||||
cat >"$targets" <<EOF
|
cat >"$targets" <<EOF
|
||||||
<Project>
|
<Project>
|
||||||
<ItemGroup Condition="'\$(MSBuildProjectFullPath)' == '$project'">
|
<ItemGroup Condition="'\$(MSBuildProjectFullPath)' == '$project'">
|
||||||
|
<PackageReference Remove="FinalFactory.Rendezvous.Client" />
|
||||||
|
<PackageReference Remove="FinalFactory.Rendezvous.Contracts" />
|
||||||
<PackageReference Include="FinalFactory.Rendezvous.Client" Version="[$version]" />
|
<PackageReference Include="FinalFactory.Rendezvous.Client" Version="[$version]" />
|
||||||
<PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="[$version]" />
|
<PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="[$version]" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -67,12 +67,17 @@ factory does not open a socket, and synchronized events must remain enabled:
|
|||||||
```csharp
|
```csharp
|
||||||
RendezvousNetListener networkEvents = new();
|
RendezvousNetListener networkEvents = new();
|
||||||
NetManager gameplayNetManager = networkEvents.CreateManager();
|
NetManager gameplayNetManager = networkEvents.CreateManager();
|
||||||
|
gameplayNetManager.ChannelsCount = 3; // example: configure the game protocol first
|
||||||
if (!gameplayNetManager.Start(0))
|
if (!gameplayNetManager.Start(0))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The gameplay UDP socket could not start.");
|
throw new InvalidOperationException("The gameplay UDP socket could not start.");
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
LiteNetLib defaults to one QoS channel. Set `ChannelsCount` before `Start` when
|
||||||
|
the game protocol uses more than one; both game processes must agree. Rendezvous
|
||||||
|
does not reserve or reinterpret any gameplay channel.
|
||||||
|
|
||||||
The host polls join invitations asynchronously; that method only queues a
|
The host polls join invitations asynchronously; that method only queues a
|
||||||
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
|
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
|
||||||
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
|
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ public interface IRendezvousSessionBrowserClient
|
|||||||
EnvironmentId environmentId,
|
EnvironmentId environmentId,
|
||||||
uint protocolVersion,
|
uint protocolVersion,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
string streamCursor,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IRendezvousJoinClient
|
public interface IRendezvousJoinClient
|
||||||
|
|||||||
@@ -114,6 +114,49 @@ internal sealed class RendezvousHttpTransport
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal async Task<RendezvousClientResult<HttpResponseMessage>> OpenStreamAsync(
|
||||||
|
Func<HttpRequestMessage> requestFactory,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
using CancellationTokenSource requestTimeout =
|
||||||
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
requestTimeout.CancelAfter(_options.RequestTimeout);
|
||||||
|
CancellationToken requestCancellation = requestTimeout.Token;
|
||||||
|
using HttpRequestMessage request = requestFactory();
|
||||||
|
HttpResponseMessage? response = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await _httpClient.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
requestCancellation).ConfigureAwait(false);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
HttpResponseMessage ownedResponse = response;
|
||||||
|
response = null;
|
||||||
|
return RendezvousClientResult.Success(ownedResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiError error = await ReadErrorAsync(response, requestCancellation).ConfigureAwait(false);
|
||||||
|
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
|
||||||
|
return RendezvousClientResult.Failure<HttpResponseMessage>(
|
||||||
|
error.Code,
|
||||||
|
error.Message,
|
||||||
|
retryAfter);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<HttpResponseMessage>(
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
"The Rendezvous event stream could not be opened.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
response?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static HttpRequestMessage JsonRequest<T>(
|
internal static HttpRequestMessage JsonRequest<T>(
|
||||||
HttpMethod method,
|
HttpMethod method,
|
||||||
string uri,
|
string uri,
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
|
|||||||
{
|
{
|
||||||
ContractVersion = request.ContractVersion,
|
ContractVersion = request.ContractVersion,
|
||||||
LeaseToken = session.LeaseToken,
|
LeaseToken = session.LeaseToken,
|
||||||
|
RegionId = request.RegionId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
Visibility = request.Visibility,
|
||||||
BuildVersion = request.BuildVersion,
|
BuildVersion = request.BuildVersion,
|
||||||
DisplayName = request.DisplayName,
|
DisplayName = request.DisplayName,
|
||||||
Capacity = CopyCapacity(request.Capacity),
|
Capacity = CopyCapacity(request.Capacity),
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Client;
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
@@ -106,5 +110,264 @@ public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserCl
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
string streamCursor,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(streamCursor)
|
||||||
|
|| !ContractValidation.IsCursorValid(streamCursor))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A valid snapshot stream cursor is required.", nameof(streamCursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
string query = $"v1/sessions/stream?contractVersion={request.ContractVersion}"
|
||||||
|
+ $"&gameId={Escape(request.GameId.Value)}"
|
||||||
|
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
|
||||||
|
+ $"&protocolVersion={request.ProtocolVersion}"
|
||||||
|
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
|
||||||
|
+ (request.RegionId.HasValue ? $"®ionId={Escape(request.RegionId.Value.Value)}" : string.Empty);
|
||||||
|
RendezvousClientResult<HttpResponseMessage> opened = await _transport.OpenStreamAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
HttpRequestMessage message = new(HttpMethod.Get, query);
|
||||||
|
message.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||||
|
message.Headers.TryAddWithoutValidation("Last-Event-ID", streamCursor);
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!opened.IsSuccess || opened.Value is null)
|
||||||
|
{
|
||||||
|
yield return RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||||
|
opened.Error,
|
||||||
|
opened.Message,
|
||||||
|
opened.RetryAfterSeconds);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using HttpResponseMessage response = opened.Value;
|
||||||
|
if (!string.Equals(
|
||||||
|
response.Content.Headers.ContentType?.MediaType,
|
||||||
|
"text/event-stream",
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
yield return RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an invalid event-stream content type.");
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using Stream source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||||
|
using SseLineReader reader = new(source);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
SseReadResult? read = null;
|
||||||
|
RendezvousClientResult<SessionStreamEvent>? readFailure = null;
|
||||||
|
bool cancelled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
read = await ReadEventAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
cancelled = true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or JsonException or InvalidDataException)
|
||||||
|
{
|
||||||
|
readFailure = RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an invalid or oversized event stream.");
|
||||||
|
}
|
||||||
|
if (cancelled)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
if (readFailure is not null)
|
||||||
|
{
|
||||||
|
yield return readFailure;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read!.EndOfStream)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
yield return read.Result!;
|
||||||
|
if (!read.Result!.IsSuccess)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SseReadResult> ReadEventAsync(
|
||||||
|
SseLineReader reader,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? eventName = null;
|
||||||
|
string? id = null;
|
||||||
|
string? data = null;
|
||||||
|
int bytes = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
string? line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (line is null)
|
||||||
|
{
|
||||||
|
return eventName is null && id is null && data is null
|
||||||
|
? SseReadResult.End
|
||||||
|
: throw new InvalidDataException("The final SSE event was incomplete.");
|
||||||
|
}
|
||||||
|
bytes += Encoding.UTF8.GetByteCount(line) + 1;
|
||||||
|
if (bytes > ContractLimits.SessionStreamEventMaxBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("The SSE event exceeded the contract limit.");
|
||||||
|
}
|
||||||
|
if (line.Length == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("event: ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
eventName = line[7..];
|
||||||
|
}
|
||||||
|
else if (line.StartsWith("id: ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
id = line[4..];
|
||||||
|
}
|
||||||
|
else if (line.StartsWith("data: ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
data = line[6..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionStreamEvent? item = data is null
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Deserialize<SessionStreamEvent>(data, ContractJson.Options);
|
||||||
|
if (item is null
|
||||||
|
|| !string.Equals(item.Cursor, id, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(eventName, EventName(item.Kind), StringComparison.Ordinal)
|
||||||
|
|| !IsValidShape(item))
|
||||||
|
{
|
||||||
|
return new(false, RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an invalid event envelope."));
|
||||||
|
}
|
||||||
|
return new(false, RendezvousClientResult.Success(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EventName(SessionStreamEventKind kind) => kind switch
|
||||||
|
{
|
||||||
|
SessionStreamEventKind.SessionUpsert => "session_upsert",
|
||||||
|
SessionStreamEventKind.SessionRemove => "session_remove",
|
||||||
|
SessionStreamEventKind.Reset => "reset",
|
||||||
|
SessionStreamEventKind.Keepalive => "keepalive",
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool IsValidShape(SessionStreamEvent item) =>
|
||||||
|
item.ContractVersion == ContractLimits.ContractVersion
|
||||||
|
&& !string.IsNullOrWhiteSpace(item.Cursor)
|
||||||
|
&& ContractValidation.IsCursorValid(item.Cursor)
|
||||||
|
&& (item.Kind == SessionStreamEventKind.SessionUpsert
|
||||||
|
&& item.Session is not null
|
||||||
|
&& IsValidListing(item.Session)
|
||||||
|
&& item.ListingId is null
|
||||||
|
|| item.Kind == SessionStreamEventKind.SessionRemove
|
||||||
|
&& item.Session is null
|
||||||
|
&& item.ListingId.HasValue
|
||||||
|
&& item.ListingId.Value.Value != Guid.Empty
|
||||||
|
|| item.Kind is SessionStreamEventKind.Reset or SessionStreamEventKind.Keepalive
|
||||||
|
&& item.Session is null
|
||||||
|
&& item.ListingId is null);
|
||||||
|
|
||||||
|
private static bool IsValidListing(SessionListing listing) =>
|
||||||
|
listing.ContractVersion == ContractLimits.ContractVersion
|
||||||
|
&& listing.ListingId.Value != Guid.Empty
|
||||||
|
&& !string.IsNullOrWhiteSpace(listing.GameId.Value)
|
||||||
|
&& !string.IsNullOrWhiteSpace(listing.EnvironmentId.Value)
|
||||||
|
&& !string.IsNullOrWhiteSpace(listing.RegionId.Value)
|
||||||
|
&& listing.ProtocolVersion != 0
|
||||||
|
&& ContractValidation.IsBuildVersionValid(listing.BuildVersion)
|
||||||
|
&& ContractValidation.IsDisplayNameValid(listing.DisplayName)
|
||||||
|
&& listing.Visibility == ListingVisibility.Public
|
||||||
|
&& Enum.IsDefined(typeof(PublisherTrustMode), listing.PublisherTrustMode)
|
||||||
|
&& ContractValidation.IsCapacityValid(listing.Capacity)
|
||||||
|
&& ContractValidation.IsMetadataValid(listing.Metadata)
|
||||||
|
&& (listing.DedicatedFallback is null
|
||||||
|
|| ContractValidation.IsNetworkEndpointValid(listing.DedicatedFallback));
|
||||||
|
|
||||||
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
|
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
|
||||||
|
|
||||||
|
private sealed class SseReadResult
|
||||||
|
{
|
||||||
|
public SseReadResult(
|
||||||
|
bool endOfStream,
|
||||||
|
RendezvousClientResult<SessionStreamEvent>? result)
|
||||||
|
{
|
||||||
|
EndOfStream = endOfStream;
|
||||||
|
Result = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool EndOfStream { get; }
|
||||||
|
public RendezvousClientResult<SessionStreamEvent>? Result { get; }
|
||||||
|
public static SseReadResult End { get; } = new(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SseLineReader(Stream source) : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly UTF8Encoding Utf8 = new(false, true);
|
||||||
|
private readonly byte[] _buffer = new byte[4096];
|
||||||
|
private readonly MemoryStream _line = new();
|
||||||
|
private int _offset;
|
||||||
|
private int _count;
|
||||||
|
|
||||||
|
public async Task<string?> ReadLineAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (_offset >= _count)
|
||||||
|
{
|
||||||
|
_count = await source.ReadAsync(
|
||||||
|
_buffer.AsMemory(),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
_offset = 0;
|
||||||
|
if (_count == 0)
|
||||||
|
{
|
||||||
|
if (_line.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return TakeLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte value = _buffer[_offset++];
|
||||||
|
if (value == (byte)'\n')
|
||||||
|
{
|
||||||
|
return TakeLine();
|
||||||
|
}
|
||||||
|
if (_line.Length >= ContractLimits.SessionStreamEventMaxBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("An SSE line exceeded the contract limit.");
|
||||||
|
}
|
||||||
|
_line.WriteByte(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _line.Dispose();
|
||||||
|
|
||||||
|
private string TakeLine()
|
||||||
|
{
|
||||||
|
byte[] bytes = _line.ToArray();
|
||||||
|
_line.SetLength(0);
|
||||||
|
int length = bytes.Length > 0 && bytes[^1] == (byte)'\r'
|
||||||
|
? bytes.Length - 1
|
||||||
|
: bytes.Length;
|
||||||
|
return Utf8.GetString(bytes, 0, length);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public static class ContractLimits
|
|||||||
public const int ContractVersion = 1;
|
public const int ContractVersion = 1;
|
||||||
public const int HttpRequestMaxBytes = 16 * 1024;
|
public const int HttpRequestMaxBytes = 16 * 1024;
|
||||||
public const int BrowserResponseMaxBytes = 256 * 1024;
|
public const int BrowserResponseMaxBytes = 256 * 1024;
|
||||||
|
public const int SessionStreamEventMaxBytes = 32 * 1024;
|
||||||
public const int UdpDatagramMaxBytes = 1_200;
|
public const int UdpDatagramMaxBytes = 1_200;
|
||||||
public const int MetadataMaxBytes = 4 * 1024;
|
public const int MetadataMaxBytes = 4 * 1024;
|
||||||
public const int MetadataMaxKeys = 32;
|
public const int MetadataMaxKeys = 32;
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ public sealed class UpdateSessionRequest
|
|||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string LeaseToken { get; set; } = string.Empty;
|
public string LeaseToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public RegionId? RegionId { get; set; }
|
||||||
|
public uint? ProtocolVersion { get; set; }
|
||||||
|
public ListingVisibility? Visibility { get; set; }
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string BuildVersion { get; set; } = string.Empty;
|
public string BuildVersion { get; set; } = string.Empty;
|
||||||
|
|
||||||
@@ -194,6 +198,32 @@ public sealed class BrowseSessionsResponse
|
|||||||
public List<SessionListing> Items { get; set; } = [];
|
public List<SessionListing> Items { get; set; } = [];
|
||||||
|
|
||||||
public string? NextCursor { get; set; }
|
public string? NextCursor { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string StreamCursor { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SessionStreamEventKind
|
||||||
|
{
|
||||||
|
SessionUpsert = 1,
|
||||||
|
SessionRemove = 2,
|
||||||
|
Reset = 3,
|
||||||
|
Keepalive = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SessionStreamEvent
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public SessionStreamEventKind Kind { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string Cursor { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public SessionListing? Session { get; set; }
|
||||||
|
public SessionListingId? ListingId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class GetSessionResponse
|
public sealed class GetSessionResponse
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Val
|
|||||||
internal sealed class SessionBrowserService(
|
internal sealed class SessionBrowserService(
|
||||||
IEphemeralRendezvousStore store,
|
IEphemeralRendezvousStore store,
|
||||||
SessionBrowserCursorCodec cursors,
|
SessionBrowserCursorCodec cursors,
|
||||||
|
SessionStreamCursorCodec streamCursors,
|
||||||
|
SessionChangeJournal changes,
|
||||||
IWallClock clock)
|
IWallClock clock)
|
||||||
{
|
{
|
||||||
public BrowserServiceResult<BrowseSessionsResponse> Browse(
|
public BrowserServiceResult<BrowseSessionsResponse> Browse(
|
||||||
@@ -45,9 +47,25 @@ internal sealed class SessionBrowserService(
|
|||||||
request.PageSize + 1,
|
request.PageSize + 1,
|
||||||
after,
|
after,
|
||||||
request.ExcludeFull);
|
request.ExcludeFull);
|
||||||
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
|
StoreResult<IReadOnlyList<StoredListing>> found = default!;
|
||||||
query,
|
long streamRevision = 0;
|
||||||
cancellationToken);
|
bool stableSnapshot = false;
|
||||||
|
for (int attempt = 0; attempt < 3; attempt++)
|
||||||
|
{
|
||||||
|
long before = changes.CurrentRevision;
|
||||||
|
found = store.BrowseVisibleListings(query, cancellationToken);
|
||||||
|
long afterRevision = changes.CurrentRevision;
|
||||||
|
if (before == afterRevision)
|
||||||
|
{
|
||||||
|
streamRevision = afterRevision;
|
||||||
|
stableSnapshot = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stableSnapshot)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.ServiceUnavailable);
|
||||||
|
}
|
||||||
if (!found.Succeeded || found.Value is null)
|
if (!found.Succeeded || found.Value is null)
|
||||||
{
|
{
|
||||||
return new(found.Code == StoreResultCode.ServiceUnavailable
|
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||||
@@ -65,7 +83,12 @@ internal sealed class SessionBrowserService(
|
|||||||
string? nextCursor = hasMore
|
string? nextCursor = hasMore
|
||||||
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
|
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
|
||||||
: null;
|
: null;
|
||||||
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
|
BrowseSessionsResponse response = new()
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
NextCursor = nextCursor,
|
||||||
|
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
|
||||||
|
};
|
||||||
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
|
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
|
||||||
<= ContractLimits.BrowserResponseMaxBytes)
|
<= ContractLimits.BrowserResponseMaxBytes)
|
||||||
{
|
{
|
||||||
@@ -76,7 +99,10 @@ internal sealed class SessionBrowserService(
|
|||||||
hasMore = true;
|
hasMore = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
|
return new(RendezvousErrorCode.None, new BrowseSessionsResponse
|
||||||
|
{
|
||||||
|
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public BrowserServiceResult<GetSessionResponse> Get(
|
public BrowserServiceResult<GetSessionResponse> Get(
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed record SessionChangeJournalOptions
|
||||||
|
{
|
||||||
|
public int ReplayCapacity { get; init; } = 4096;
|
||||||
|
public int MaximumSubscribers { get; init; } = 256;
|
||||||
|
public int MaximumSubscribersPerTenant { get; init; } = 64;
|
||||||
|
public int MaximumBatchSize { get; init; } = 128;
|
||||||
|
public TimeSpan CoalesceInterval { get; init; } = TimeSpan.FromMilliseconds(50);
|
||||||
|
public TimeSpan KeepaliveInterval { get; init; } = TimeSpan.FromSeconds(15);
|
||||||
|
public TimeSpan MaximumConnectionDuration { get; init; } = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (ReplayCapacity is < 64 or > 65_536
|
||||||
|
|| MaximumSubscribers is < 1 or > 4096
|
||||||
|
|| MaximumSubscribersPerTenant < 1
|
||||||
|
|| MaximumSubscribersPerTenant > MaximumSubscribers
|
||||||
|
|| MaximumBatchSize is < 1 or > 1024
|
||||||
|
|| CoalesceInterval < TimeSpan.Zero
|
||||||
|
|| CoalesceInterval > TimeSpan.FromSeconds(1)
|
||||||
|
|| KeepaliveInterval < TimeSpan.FromSeconds(1)
|
||||||
|
|| KeepaliveInterval > TimeSpan.FromMinutes(1)
|
||||||
|
|| MaximumConnectionDuration < KeepaliveInterval
|
||||||
|
|| MaximumConnectionDuration > TimeSpan.FromMinutes(30))
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(SessionChangeJournalOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SessionChange(
|
||||||
|
long Revision,
|
||||||
|
SessionListingProjection? Before,
|
||||||
|
SessionListingProjection? After)
|
||||||
|
{
|
||||||
|
public SessionListingId ListingId => (After ?? Before)!.Listing.ListingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SessionChangeBatch(
|
||||||
|
long CurrentRevision,
|
||||||
|
bool RequiresReset,
|
||||||
|
IReadOnlyList<SessionChange> Changes);
|
||||||
|
|
||||||
|
internal sealed class SessionListingProjection
|
||||||
|
{
|
||||||
|
private SessionListingProjection(SessionListing listing, bool visible)
|
||||||
|
{
|
||||||
|
Listing = listing;
|
||||||
|
Visible = visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionListing Listing { get; }
|
||||||
|
public bool Visible { get; }
|
||||||
|
|
||||||
|
public static SessionListingProjection From(StoredListing stored) => new(
|
||||||
|
new SessionListing
|
||||||
|
{
|
||||||
|
ListingId = stored.Definition.ListingId,
|
||||||
|
GameId = stored.Definition.Scope.GameId,
|
||||||
|
EnvironmentId = stored.Definition.Scope.EnvironmentId,
|
||||||
|
RegionId = stored.Definition.RegionId,
|
||||||
|
ProtocolVersion = stored.Definition.ProtocolVersion,
|
||||||
|
BuildVersion = stored.Definition.BuildVersion,
|
||||||
|
DisplayName = stored.Definition.DisplayName,
|
||||||
|
Visibility = stored.Definition.Visibility,
|
||||||
|
PublisherTrustMode = stored.Definition.TrustMode,
|
||||||
|
Capacity = new SessionCapacity
|
||||||
|
{
|
||||||
|
CurrentPlayers = stored.Definition.CurrentPlayers,
|
||||||
|
MaximumPlayers = stored.Definition.MaximumPlayers,
|
||||||
|
},
|
||||||
|
Metadata = new Dictionary<string, string>(stored.Definition.Metadata, StringComparer.Ordinal),
|
||||||
|
DedicatedFallback = StoredListing.CopyEndpoint(stored.Definition.DedicatedFallback),
|
||||||
|
},
|
||||||
|
stored.HasFreshPresence && stored.Definition.Visibility == ListingVisibility.Public);
|
||||||
|
|
||||||
|
public bool Matches(VisibleListingQuery query) => Visible
|
||||||
|
&& Listing.GameId == query.Scope.GameId
|
||||||
|
&& Listing.EnvironmentId == query.Scope.EnvironmentId
|
||||||
|
&& Listing.ProtocolVersion == query.ProtocolVersion
|
||||||
|
&& (!query.RegionId.HasValue || Listing.RegionId == query.RegionId.Value)
|
||||||
|
&& (!query.ExcludeFull
|
||||||
|
|| Listing.Capacity.CurrentPlayers < Listing.Capacity.MaximumPlayers);
|
||||||
|
|
||||||
|
public static bool Equivalent(SessionListingProjection? left, SessionListingProjection? right)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(left, right))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (left is null || right is null || left.Visible != right.Visible)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionListing a = left.Listing;
|
||||||
|
SessionListing b = right.Listing;
|
||||||
|
return a.ListingId == b.ListingId
|
||||||
|
&& a.GameId == b.GameId
|
||||||
|
&& a.EnvironmentId == b.EnvironmentId
|
||||||
|
&& a.RegionId == b.RegionId
|
||||||
|
&& a.ProtocolVersion == b.ProtocolVersion
|
||||||
|
&& string.Equals(a.BuildVersion, b.BuildVersion, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(a.DisplayName, b.DisplayName, StringComparison.Ordinal)
|
||||||
|
&& a.Visibility == b.Visibility
|
||||||
|
&& a.PublisherTrustMode == b.PublisherTrustMode
|
||||||
|
&& a.Capacity.CurrentPlayers == b.Capacity.CurrentPlayers
|
||||||
|
&& a.Capacity.MaximumPlayers == b.Capacity.MaximumPlayers
|
||||||
|
&& a.Metadata.Count == b.Metadata.Count
|
||||||
|
&& a.Metadata.All(item => b.Metadata.TryGetValue(item.Key, out string? value)
|
||||||
|
&& string.Equals(item.Value, value, StringComparison.Ordinal))
|
||||||
|
&& EndpointEquals(a.DedicatedFallback, b.DedicatedFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool EndpointEquals(NetworkEndpoint? left, NetworkEndpoint? right) =>
|
||||||
|
left is null && right is null
|
||||||
|
|| left is not null && right is not null
|
||||||
|
&& left.AddressFamily == right.AddressFamily
|
||||||
|
&& string.Equals(left.Address, right.Address, StringComparison.Ordinal)
|
||||||
|
&& left.Port == right.Port;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionChangeJournal
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly SessionChangeJournalOptions _options;
|
||||||
|
private readonly Queue<SessionChange> _changes = [];
|
||||||
|
private TaskCompletionSource<long> _changed = NewSignal();
|
||||||
|
private long _revision;
|
||||||
|
private int _subscribers;
|
||||||
|
private readonly Dictionary<TenantScope, int> _subscribersByTenant = [];
|
||||||
|
|
||||||
|
public SessionChangeJournal(SessionChangeJournalOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionChangeJournalOptions Options => _options;
|
||||||
|
|
||||||
|
public int ReplayCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _changes.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int SubscriberCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _subscribers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int SubscribedTenantCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _subscribersByTenant.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long CurrentRevision
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _revision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Publish(StoredListing? before, StoredListing? after)
|
||||||
|
{
|
||||||
|
SessionListingProjection? previous = before is null ? null : SessionListingProjection.From(before);
|
||||||
|
SessionListingProjection? current = after is null ? null : SessionListingProjection.From(after);
|
||||||
|
if (SessionListingProjection.Equivalent(previous, current)
|
||||||
|
|| previous is { Visible: false } && current is null
|
||||||
|
|| previous is null && current is { Visible: false })
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskCompletionSource<long> signal;
|
||||||
|
long revision;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
revision = ++_revision;
|
||||||
|
_changes.Enqueue(new SessionChange(revision, previous, current));
|
||||||
|
while (_changes.Count > _options.ReplayCapacity)
|
||||||
|
{
|
||||||
|
_changes.Dequeue();
|
||||||
|
}
|
||||||
|
signal = _changed;
|
||||||
|
_changed = NewSignal();
|
||||||
|
}
|
||||||
|
signal.TrySetResult(revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionChangeBatch ReadAfter(long revision)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
long oldest = _changes.TryPeek(out SessionChange? first)
|
||||||
|
? first.Revision
|
||||||
|
: _revision + 1;
|
||||||
|
if (revision < oldest - 1 || revision > _revision)
|
||||||
|
{
|
||||||
|
return new(_revision, true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionChange[] changes = _changes
|
||||||
|
.Where(change => change.Revision > revision)
|
||||||
|
.Take(_options.MaximumBatchSize)
|
||||||
|
.ToArray();
|
||||||
|
return new(_revision, false, changes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> WaitForChangeAsync(
|
||||||
|
long revision,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Task<long> signal;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_revision > revision)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
signal = _changed.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await signal.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TrySubscribe(TenantScope scope, out IDisposable? lease)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_subscribers >= _options.MaximumSubscribers
|
||||||
|
|| _subscribersByTenant.GetValueOrDefault(scope)
|
||||||
|
>= _options.MaximumSubscribersPerTenant)
|
||||||
|
{
|
||||||
|
lease = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_subscribers++;
|
||||||
|
_subscribersByTenant[scope] = _subscribersByTenant.GetValueOrDefault(scope) + 1;
|
||||||
|
lease = new Subscription(this, scope);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Release(TenantScope scope)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_subscribers--;
|
||||||
|
int remaining = _subscribersByTenant[scope] - 1;
|
||||||
|
if (remaining == 0)
|
||||||
|
{
|
||||||
|
_subscribersByTenant.Remove(scope);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_subscribersByTenant[scope] = remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskCompletionSource<long> NewSignal() => new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
private sealed class Subscription(
|
||||||
|
SessionChangeJournal owner,
|
||||||
|
TenantScope scope) : IDisposable
|
||||||
|
{
|
||||||
|
private SessionChangeJournal? _owner = owner;
|
||||||
|
|
||||||
|
public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Release(scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed class SessionStreamCursorCodec : IDisposable
|
||||||
|
{
|
||||||
|
private const string Prefix = "rvs1";
|
||||||
|
private readonly EphemeralCursorProtector _protector = new();
|
||||||
|
|
||||||
|
public string Encode(VisibleListingQuery query, long revision, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(revision);
|
||||||
|
SessionStreamCursorPayload payload = new()
|
||||||
|
{
|
||||||
|
GameId = query.Scope.GameId.Value,
|
||||||
|
EnvironmentId = query.Scope.EnvironmentId.Value,
|
||||||
|
ProtocolVersion = query.ProtocolVersion,
|
||||||
|
RegionId = query.RegionId?.Value,
|
||||||
|
ExcludeFull = query.ExcludeFull,
|
||||||
|
Revision = revision,
|
||||||
|
ExpiresAtUnixSeconds = now.AddMinutes(10).ToUnixTimeSeconds(),
|
||||||
|
};
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _protector.Protect(Prefix, encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDecode(
|
||||||
|
string? cursor,
|
||||||
|
VisibleListingQuery query,
|
||||||
|
DateTimeOffset now,
|
||||||
|
out long revision)
|
||||||
|
{
|
||||||
|
revision = 0;
|
||||||
|
if (cursor is null || !_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionStreamCursorPayload? payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.Deserialize<SessionStreamCursorPayload>(
|
||||||
|
encodedPayload,
|
||||||
|
ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload is null
|
||||||
|
|| payload.Revision < 0
|
||||||
|
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||||
|
|| !string.Equals(payload.GameId, query.Scope.GameId.Value, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(payload.EnvironmentId, query.Scope.EnvironmentId.Value, StringComparison.Ordinal)
|
||||||
|
|| payload.ProtocolVersion != query.ProtocolVersion
|
||||||
|
|| !string.Equals(payload.RegionId, query.RegionId?.Value, StringComparison.Ordinal)
|
||||||
|
|| payload.ExcludeFull != query.ExcludeFull)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
revision = payload.Revision;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _protector.Dispose();
|
||||||
|
|
||||||
|
public override string ToString() => "[SessionStreamCursorCodec: key and cursors redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionStreamCursorPayload
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public string GameId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string EnvironmentId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public uint ProtocolVersion { get; set; }
|
||||||
|
|
||||||
|
public string? RegionId { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool ExcludeFull { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public long Revision { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public long ExpiresAtUnixSeconds { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed record SessionStreamReadResult(
|
||||||
|
bool RequiresReset,
|
||||||
|
IReadOnlyList<SessionStreamEvent> Events);
|
||||||
|
|
||||||
|
internal sealed class SessionStreamSubscription : IDisposable
|
||||||
|
{
|
||||||
|
private IDisposable? _lease;
|
||||||
|
|
||||||
|
public SessionStreamSubscription(
|
||||||
|
VisibleListingQuery query,
|
||||||
|
long revision,
|
||||||
|
bool requiresReset,
|
||||||
|
IDisposable lease)
|
||||||
|
{
|
||||||
|
Query = query;
|
||||||
|
Revision = revision;
|
||||||
|
RequiresReset = requiresReset;
|
||||||
|
_lease = lease;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VisibleListingQuery Query { get; }
|
||||||
|
public long Revision { get; set; }
|
||||||
|
public bool RequiresReset { get; set; }
|
||||||
|
|
||||||
|
public void Dispose() => Interlocked.Exchange(ref _lease, null)?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionStreamService(
|
||||||
|
SessionChangeJournal changes,
|
||||||
|
SessionStreamCursorCodec cursors,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public BrowserServiceResult<SessionStreamSubscription> Subscribe(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
string? cursor)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = Validate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
VisibleListingQuery query = new(
|
||||||
|
new TenantScope(request.GameId, request.EnvironmentId),
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.RegionId,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
ExcludeFull: request.ExcludeFull);
|
||||||
|
if (!changes.TrySubscribe(query.Scope, out IDisposable? lease) || lease is null)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.CapacityExceeded);
|
||||||
|
}
|
||||||
|
bool validCursor = cursors.TryDecode(cursor, query, clock.UtcNow, out long revision);
|
||||||
|
if (!validCursor)
|
||||||
|
{
|
||||||
|
revision = changes.CurrentRevision;
|
||||||
|
}
|
||||||
|
return new(RendezvousErrorCode.None, new SessionStreamSubscription(
|
||||||
|
query,
|
||||||
|
revision,
|
||||||
|
requiresReset: !validCursor,
|
||||||
|
lease));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionStreamReadResult Read(SessionStreamSubscription subscription)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(subscription);
|
||||||
|
if (subscription.RequiresReset)
|
||||||
|
{
|
||||||
|
subscription.RequiresReset = false;
|
||||||
|
return new(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionChangeBatch batch = changes.ReadAfter(subscription.Revision);
|
||||||
|
if (batch.RequiresReset)
|
||||||
|
{
|
||||||
|
subscription.Revision = batch.CurrentRevision;
|
||||||
|
return new(true, []);
|
||||||
|
}
|
||||||
|
if (batch.Changes.Count == 0)
|
||||||
|
{
|
||||||
|
return new(false, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<SessionListingId, PendingDelta> coalesced = [];
|
||||||
|
foreach (SessionChange change in batch.Changes)
|
||||||
|
{
|
||||||
|
bool beforeMatches = change.Before?.Matches(subscription.Query) == true;
|
||||||
|
bool afterMatches = change.After?.Matches(subscription.Query) == true;
|
||||||
|
if (!beforeMatches && !afterMatches)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
coalesced[change.ListingId] = afterMatches
|
||||||
|
? new(change.Revision, SessionStreamEventKind.SessionUpsert, change.After!.Listing)
|
||||||
|
: new(change.Revision, SessionStreamEventKind.SessionRemove, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.Revision = batch.Changes[^1].Revision;
|
||||||
|
SessionStreamEvent[] events = coalesced
|
||||||
|
.OrderBy(static item => item.Value.Revision)
|
||||||
|
.Select(item => ToEvent(item.Key, item.Value, subscription.Query))
|
||||||
|
.ToArray();
|
||||||
|
return new(false, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> WaitForChangeAsync(
|
||||||
|
SessionStreamSubscription subscription,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
bool changed = await changes.WaitForChangeAsync(
|
||||||
|
subscription.Revision,
|
||||||
|
changes.Options.KeepaliveInterval,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (changed && changes.Options.CoalesceInterval > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
await Task.Delay(changes.Options.CoalesceInterval, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionStreamEvent ResetEvent(SessionStreamSubscription subscription) => new()
|
||||||
|
{
|
||||||
|
Kind = SessionStreamEventKind.Reset,
|
||||||
|
Cursor = cursors.Encode(subscription.Query, subscription.Revision, clock.UtcNow),
|
||||||
|
};
|
||||||
|
|
||||||
|
public SessionStreamEvent KeepaliveEvent(SessionStreamSubscription subscription) => new()
|
||||||
|
{
|
||||||
|
Kind = SessionStreamEventKind.Keepalive,
|
||||||
|
Cursor = cursors.Encode(subscription.Query, subscription.Revision, clock.UtcNow),
|
||||||
|
};
|
||||||
|
|
||||||
|
public TimeSpan MaximumConnectionDuration => changes.Options.MaximumConnectionDuration;
|
||||||
|
|
||||||
|
private SessionStreamEvent ToEvent(
|
||||||
|
SessionListingId listingId,
|
||||||
|
PendingDelta delta,
|
||||||
|
VisibleListingQuery query) => new()
|
||||||
|
{
|
||||||
|
Kind = delta.Kind,
|
||||||
|
Cursor = cursors.Encode(query, delta.Revision, clock.UtcNow),
|
||||||
|
Session = delta.Session,
|
||||||
|
ListingId = delta.Kind == SessionStreamEventKind.SessionRemove ? listingId : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
return string.IsNullOrEmpty(request.GameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||||
|
|| request.ProtocolVersion == 0
|
||||||
|
|| request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value)
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PendingDelta(
|
||||||
|
long Revision,
|
||||||
|
SessionStreamEventKind Kind,
|
||||||
|
SessionListing? Session);
|
||||||
|
}
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
const CONTRACT_VERSION = 1;
|
||||||
|
const BROWSER_PAGE_LIMIT = 100;
|
||||||
|
const REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
const STREAM_FAILURE_LIMIT = 3;
|
||||||
|
|
||||||
|
export class SessionProjection {
|
||||||
|
constructor(maximumSessions = 100) {
|
||||||
|
if (!Number.isInteger(maximumSessions) || maximumSessions < 1 || maximumSessions > 500) {
|
||||||
|
throw new RangeError("maximumSessions must be between 1 and 500");
|
||||||
|
}
|
||||||
|
this.maximumSessions = maximumSessions;
|
||||||
|
this.entries = new Map();
|
||||||
|
this.cursor = "";
|
||||||
|
this.hasMore = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(items, cursor, hasMore = false, updatedAt = Date.now()) {
|
||||||
|
if (!Array.isArray(items) || items.length > this.maximumSessions) {
|
||||||
|
return "overflow";
|
||||||
|
}
|
||||||
|
const next = new Map();
|
||||||
|
for (const session of items) {
|
||||||
|
if (!isPublicSession(session) || next.has(session.listingId)) {
|
||||||
|
return "invalid";
|
||||||
|
}
|
||||||
|
next.set(session.listingId, { session, updatedAt });
|
||||||
|
}
|
||||||
|
this.entries = next;
|
||||||
|
this.cursor = validCursor(cursor) ? cursor : "";
|
||||||
|
this.hasMore = Boolean(hasMore);
|
||||||
|
return "applied";
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(event, updatedAt = Date.now()) {
|
||||||
|
if (!event || event.contractVersion !== CONTRACT_VERSION || !validCursor(event.cursor)) {
|
||||||
|
return "invalid";
|
||||||
|
}
|
||||||
|
if (event.kind === "sessionUpsert" && isPublicSession(event.session) && event.listingId == null) {
|
||||||
|
if (!this.entries.has(event.session.listingId)
|
||||||
|
&& this.entries.size >= this.maximumSessions) {
|
||||||
|
return "overflow";
|
||||||
|
}
|
||||||
|
this.entries.set(event.session.listingId, { session: event.session, updatedAt });
|
||||||
|
this.cursor = event.cursor;
|
||||||
|
return "applied";
|
||||||
|
}
|
||||||
|
if (event.kind === "sessionRemove"
|
||||||
|
&& typeof event.listingId === "string"
|
||||||
|
&& event.listingId.length > 0
|
||||||
|
&& event.session == null) {
|
||||||
|
this.entries.delete(event.listingId);
|
||||||
|
this.cursor = event.cursor;
|
||||||
|
return this.hasMore ? "refresh" : "applied";
|
||||||
|
}
|
||||||
|
if (event.kind === "keepalive" && event.session == null && event.listingId == null) {
|
||||||
|
this.cursor = event.cursor;
|
||||||
|
return "keepalive";
|
||||||
|
}
|
||||||
|
if (event.kind === "reset" && event.session == null && event.listingId == null) {
|
||||||
|
this.cursor = event.cursor;
|
||||||
|
return "reset";
|
||||||
|
}
|
||||||
|
return "invalid";
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions() {
|
||||||
|
return [...this.entries.values()]
|
||||||
|
.sort((left, right) => left.session.listingId.localeCompare(right.session.listingId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeText(value, maximumLength = 160) {
|
||||||
|
const text = typeof value === "string" ? value : String(value ?? "");
|
||||||
|
const visible = text.replace(/[\u0000-\u001f\u007f]/gu, "�");
|
||||||
|
return visible.length <= maximumLength
|
||||||
|
? visible
|
||||||
|
: `${visible.slice(0, Math.max(0, maximumLength - 1))}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBrowseUrl(filter) {
|
||||||
|
validateFilter(filter);
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
contractVersion: String(CONTRACT_VERSION),
|
||||||
|
gameId: filter.gameId,
|
||||||
|
environmentId: filter.environmentId,
|
||||||
|
protocolVersion: String(filter.protocolVersion),
|
||||||
|
regionId: filter.regionId,
|
||||||
|
pageSize: String(Math.min(BROWSER_PAGE_LIMIT, filter.pageSize ?? BROWSER_PAGE_LIMIT)),
|
||||||
|
excludeFull: String(Boolean(filter.excludeFull)),
|
||||||
|
});
|
||||||
|
return `/v1/sessions?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStreamUrl(filter, cursor) {
|
||||||
|
validateFilter(filter);
|
||||||
|
if (!validCursor(cursor)) {
|
||||||
|
throw new TypeError("A bounded stream cursor is required");
|
||||||
|
}
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
contractVersion: String(CONTRACT_VERSION),
|
||||||
|
gameId: filter.gameId,
|
||||||
|
environmentId: filter.environmentId,
|
||||||
|
protocolVersion: String(filter.protocolVersion),
|
||||||
|
regionId: filter.regionId,
|
||||||
|
excludeFull: String(Boolean(filter.excludeFull)),
|
||||||
|
streamCursor: cursor,
|
||||||
|
});
|
||||||
|
return `/v1/sessions/stream?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chooseSnapshotTransport(hasMore, pollingOnly) {
|
||||||
|
if (pollingOnly) {
|
||||||
|
return "pollingOnly";
|
||||||
|
}
|
||||||
|
return hasMore ? "boundedPolling" : "stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCursor(value) {
|
||||||
|
return typeof value === "string"
|
||||||
|
&& value.length > 0
|
||||||
|
&& value.length <= 1024
|
||||||
|
&& /^[\x21-\x7e]+$/u.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicSession(session) {
|
||||||
|
return session != null
|
||||||
|
&& session.contractVersion === CONTRACT_VERSION
|
||||||
|
&& typeof session.listingId === "string"
|
||||||
|
&& session.listingId.length > 0
|
||||||
|
&& typeof session.gameId === "string"
|
||||||
|
&& typeof session.environmentId === "string"
|
||||||
|
&& typeof session.regionId === "string"
|
||||||
|
&& Number.isInteger(session.protocolVersion)
|
||||||
|
&& session.protocolVersion > 0
|
||||||
|
&& typeof session.buildVersion === "string"
|
||||||
|
&& typeof session.displayName === "string"
|
||||||
|
&& session.visibility === "public"
|
||||||
|
&& session.capacity != null
|
||||||
|
&& Number.isInteger(session.capacity.currentPlayers)
|
||||||
|
&& Number.isInteger(session.capacity.maximumPlayers)
|
||||||
|
&& session.capacity.currentPlayers >= 0
|
||||||
|
&& session.capacity.maximumPlayers >= 1
|
||||||
|
&& session.capacity.currentPlayers <= session.capacity.maximumPlayers
|
||||||
|
&& session.metadata != null
|
||||||
|
&& typeof session.metadata === "object"
|
||||||
|
&& !Array.isArray(session.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFilter(filter) {
|
||||||
|
if (!filter
|
||||||
|
|| typeof filter.gameId !== "string"
|
||||||
|
|| typeof filter.environmentId !== "string"
|
||||||
|
|| typeof filter.regionId !== "string"
|
||||||
|
|| !Number.isInteger(filter.protocolVersion)
|
||||||
|
|| filter.protocolVersion <= 0) {
|
||||||
|
throw new TypeError("The selected diagnostic filter is invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDashboard() {
|
||||||
|
const elements = {
|
||||||
|
form: document.querySelector("#filters"),
|
||||||
|
game: document.querySelector("#game-filter"),
|
||||||
|
environment: document.querySelector("#environment-filter"),
|
||||||
|
protocol: document.querySelector("#protocol-filter"),
|
||||||
|
region: document.querySelector("#region-filter"),
|
||||||
|
capacity: document.querySelector("#capacity-filter"),
|
||||||
|
error: document.querySelector("#filter-error"),
|
||||||
|
reconnect: document.querySelector("#reconnect-button"),
|
||||||
|
reset: document.querySelector("#reset-button"),
|
||||||
|
poll: document.querySelector("#poll-button"),
|
||||||
|
streamStatus: document.querySelector("#stream-status"),
|
||||||
|
transportState: document.querySelector("#transport-state"),
|
||||||
|
projectionState: document.querySelector("#projection-state"),
|
||||||
|
lastUpdate: document.querySelector("#last-update"),
|
||||||
|
sessionCount: document.querySelector("#session-count"),
|
||||||
|
results: document.querySelector(".results-panel"),
|
||||||
|
resultsSummary: document.querySelector("#results-summary"),
|
||||||
|
list: document.querySelector("#session-list"),
|
||||||
|
empty: document.querySelector("#empty-state"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
configuration: null,
|
||||||
|
projection: null,
|
||||||
|
activeFilter: null,
|
||||||
|
source: null,
|
||||||
|
pollingTimer: null,
|
||||||
|
requestController: null,
|
||||||
|
streamFailures: 0,
|
||||||
|
pollingOnly: false,
|
||||||
|
renderPending: false,
|
||||||
|
lastSuccess: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setStatus(kind, message, transport = message) {
|
||||||
|
document.body.dataset.streamState = kind;
|
||||||
|
elements.streamStatus.textContent = message;
|
||||||
|
elements.transportState.textContent = transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormError(message = "") {
|
||||||
|
elements.error.textContent = message;
|
||||||
|
elements.error.hidden = message.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populate(select, values, previous) {
|
||||||
|
select.replaceChildren();
|
||||||
|
for (const value of values) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = String(value);
|
||||||
|
option.textContent = safeText(value, 80);
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
if (values.some((value) => String(value) === previous)) {
|
||||||
|
select.value = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedScope() {
|
||||||
|
return state.configuration?.scopes.find((scope) =>
|
||||||
|
scope.gameId === elements.game.value
|
||||||
|
&& scope.environmentId === elements.environment.value) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEnvironmentOptions() {
|
||||||
|
const prior = elements.environment.value;
|
||||||
|
const environments = state.configuration.scopes
|
||||||
|
.filter((scope) => scope.gameId === elements.game.value)
|
||||||
|
.map((scope) => scope.environmentId);
|
||||||
|
populate(elements.environment, environments, prior);
|
||||||
|
updateScopeOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScopeOptions() {
|
||||||
|
const scope = selectedScope();
|
||||||
|
populate(elements.protocol, scope?.protocolVersions ?? [], elements.protocol.value);
|
||||||
|
populate(elements.region, scope?.regions ?? [], elements.region.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFilter() {
|
||||||
|
const scope = selectedScope();
|
||||||
|
const protocolVersion = Number(elements.protocol.value);
|
||||||
|
if (!scope
|
||||||
|
|| !scope.protocolVersions.includes(protocolVersion)
|
||||||
|
|| !scope.regions.includes(elements.region.value)) {
|
||||||
|
throw new TypeError("Choose one of the configured game, environment, protocol, and region combinations.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
gameId: scope.gameId,
|
||||||
|
environmentId: scope.environmentId,
|
||||||
|
protocolVersion,
|
||||||
|
regionId: elements.region.value,
|
||||||
|
excludeFull: elements.capacity.value === "open",
|
||||||
|
pageSize: Math.min(BROWSER_PAGE_LIMIT, state.configuration.maximumRenderedSessions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLiveWork() {
|
||||||
|
if (state.source) {
|
||||||
|
state.source.close();
|
||||||
|
state.source = null;
|
||||||
|
}
|
||||||
|
if (state.pollingTimer) {
|
||||||
|
clearTimeout(state.pollingTimer);
|
||||||
|
state.pollingTimer = null;
|
||||||
|
}
|
||||||
|
if (state.requestController) {
|
||||||
|
state.requestController.abort();
|
||||||
|
state.requestController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSuccess(message) {
|
||||||
|
state.lastSuccess = Date.now();
|
||||||
|
elements.lastUpdate.dateTime = new Date(state.lastSuccess).toISOString();
|
||||||
|
elements.lastUpdate.textContent = "Just now";
|
||||||
|
elements.resultsSummary.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRender() {
|
||||||
|
if (state.renderPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.renderPending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
state.renderPending = false;
|
||||||
|
renderProjection();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDetail(list, term, description) {
|
||||||
|
const dt = document.createElement("dt");
|
||||||
|
dt.textContent = term;
|
||||||
|
const dd = document.createElement("dd");
|
||||||
|
dd.textContent = safeText(description, 160);
|
||||||
|
list.append(dt, dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionCard(entry) {
|
||||||
|
const session = entry.session;
|
||||||
|
const article = document.createElement("article");
|
||||||
|
article.className = "session-card";
|
||||||
|
article.setAttribute("role", "listitem");
|
||||||
|
article.dataset.updatedAt = String(entry.updatedAt);
|
||||||
|
|
||||||
|
const heading = document.createElement("div");
|
||||||
|
heading.className = "card-heading";
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
title.textContent = safeText(session.displayName, 120);
|
||||||
|
const region = document.createElement("span");
|
||||||
|
region.className = "region-badge";
|
||||||
|
region.textContent = safeText(session.regionId, 48);
|
||||||
|
heading.append(title, region);
|
||||||
|
|
||||||
|
const details = document.createElement("dl");
|
||||||
|
details.className = "session-detail";
|
||||||
|
appendDetail(details, "Build", session.buildVersion);
|
||||||
|
appendDetail(details, "Protocol", session.protocolVersion);
|
||||||
|
appendDetail(details, "Visibility", "Public");
|
||||||
|
appendDetail(details, "Presence", "Recently verified");
|
||||||
|
|
||||||
|
const capacity = document.createElement("div");
|
||||||
|
capacity.className = "capacity-row";
|
||||||
|
const capacityCopy = document.createElement("div");
|
||||||
|
capacityCopy.className = "capacity-copy";
|
||||||
|
const capacityLabel = document.createElement("span");
|
||||||
|
capacityLabel.textContent = "Advisory capacity";
|
||||||
|
const capacityValue = document.createElement("span");
|
||||||
|
capacityValue.textContent = `${session.capacity.currentPlayers} / ${session.capacity.maximumPlayers}`;
|
||||||
|
capacityCopy.append(capacityLabel, capacityValue);
|
||||||
|
const meter = document.createElement("meter");
|
||||||
|
meter.min = 0;
|
||||||
|
meter.max = session.capacity.maximumPlayers;
|
||||||
|
meter.value = session.capacity.currentPlayers;
|
||||||
|
meter.setAttribute("aria-label", `Advisory capacity ${capacityValue.textContent}`);
|
||||||
|
capacity.append(capacityCopy, meter);
|
||||||
|
|
||||||
|
const metadata = document.createElement("ul");
|
||||||
|
metadata.className = "metadata-list";
|
||||||
|
for (const [key, value] of Object.entries(session.metadata).slice(0, 16)) {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.textContent = `${safeText(key, 48)}: ${safeText(value, 96)}`;
|
||||||
|
metadata.append(item);
|
||||||
|
}
|
||||||
|
if (metadata.childElementCount === 0) {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.textContent = "No public metadata";
|
||||||
|
metadata.append(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = document.createElement("p");
|
||||||
|
updated.className = "updated-age";
|
||||||
|
updated.textContent = "Updated just now";
|
||||||
|
article.append(heading, details, capacity, metadata, updated);
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjection() {
|
||||||
|
const entries = state.projection?.sessions() ?? [];
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const entry of entries) {
|
||||||
|
fragment.append(sessionCard(entry));
|
||||||
|
}
|
||||||
|
elements.list.replaceChildren(fragment);
|
||||||
|
elements.sessionCount.textContent = String(entries.length);
|
||||||
|
elements.empty.hidden = entries.length !== 0;
|
||||||
|
elements.projectionState.textContent = state.projection?.hasMore
|
||||||
|
? "Bounded snapshot; polling"
|
||||||
|
: "Snapshot plus ordered deltas";
|
||||||
|
elements.results.setAttribute("aria-busy", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAges() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (state.lastSuccess > 0) {
|
||||||
|
const age = Math.max(0, Math.floor((now - state.lastSuccess) / 1000));
|
||||||
|
elements.lastUpdate.textContent = age < 5 ? "Just now" : `${age} seconds ago`;
|
||||||
|
}
|
||||||
|
for (const card of elements.list.querySelectorAll(".session-card")) {
|
||||||
|
const age = Math.max(0, Math.floor((now - Number(card.dataset.updatedAt)) / 1000));
|
||||||
|
const copy = card.querySelector(".updated-age");
|
||||||
|
copy.textContent = age < 5 ? "Updated just now" : `Updated ${age} seconds ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(url) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
state.requestController = controller;
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`The service returned HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (state.requestController === controller) {
|
||||||
|
state.requestController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePolling() {
|
||||||
|
if (state.pollingTimer) {
|
||||||
|
clearTimeout(state.pollingTimer);
|
||||||
|
}
|
||||||
|
state.pollingTimer = setTimeout(
|
||||||
|
() => fetchSnapshot("poll").catch(showServiceError),
|
||||||
|
state.configuration.pollIntervalSeconds * 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePolling(message = "Polling fallback active") {
|
||||||
|
if (state.source) {
|
||||||
|
state.source.close();
|
||||||
|
state.source = null;
|
||||||
|
}
|
||||||
|
setStatus("polling", message, "Polling fallback");
|
||||||
|
schedulePolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showServiceError(error) {
|
||||||
|
const message = error?.name === "AbortError"
|
||||||
|
? "The service did not answer within the request deadline."
|
||||||
|
: safeText(error?.message || "The service is unavailable.", 200);
|
||||||
|
setStatus("error", "Service unavailable", "Unavailable");
|
||||||
|
elements.resultsSummary.textContent = `${message} Retrying with bounded polling.`;
|
||||||
|
elements.results.setAttribute("aria-busy", "false");
|
||||||
|
usePolling("Service unavailable; polling retry scheduled");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSnapshot(reason = "manual") {
|
||||||
|
if (!state.activeFilter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.requestController) {
|
||||||
|
state.requestController.abort();
|
||||||
|
}
|
||||||
|
elements.results.setAttribute("aria-busy", "true");
|
||||||
|
if (reason !== "poll") {
|
||||||
|
setStatus("reconnecting", "Loading a fresh snapshot", "Snapshot request");
|
||||||
|
}
|
||||||
|
const response = await readJson(buildBrowseUrl(state.activeFilter));
|
||||||
|
if (response.contractVersion !== CONTRACT_VERSION
|
||||||
|
|| !Array.isArray(response.items)
|
||||||
|
|| !validCursor(response.streamCursor)) {
|
||||||
|
throw new Error("The service returned an invalid browser snapshot.");
|
||||||
|
}
|
||||||
|
const outcome = state.projection.replace(
|
||||||
|
response.items,
|
||||||
|
response.streamCursor,
|
||||||
|
Boolean(response.nextCursor),
|
||||||
|
);
|
||||||
|
if (outcome !== "applied") {
|
||||||
|
throw new Error("The bounded browser snapshot could not be applied safely.");
|
||||||
|
}
|
||||||
|
scheduleRender();
|
||||||
|
markSuccess(`${response.items.length} public session${response.items.length === 1 ? "" : "s"} in the fresh snapshot.`);
|
||||||
|
const transport = chooseSnapshotTransport(Boolean(response.nextCursor), state.pollingOnly);
|
||||||
|
if (transport !== "stream") {
|
||||||
|
if (transport === "pollingOnly") {
|
||||||
|
usePolling("Polling only selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usePolling("More sessions exist than this bounded view; polling keeps it authoritative");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectStream(response.streamCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStreamEvent(serialized, expectedKind) {
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(serialized);
|
||||||
|
} catch {
|
||||||
|
usePolling("Invalid stream data; polling fallback active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.kind !== expectedKind) {
|
||||||
|
usePolling("Unexpected stream event; polling fallback active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const outcome = state.projection.apply(event);
|
||||||
|
if (outcome === "invalid" || outcome === "overflow" || outcome === "refresh") {
|
||||||
|
fetchSnapshot("stream-recovery").catch(showServiceError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outcome === "reset") {
|
||||||
|
setStatus("reset", "Cursor reset; refreshing snapshot", "Cursor reset");
|
||||||
|
fetchSnapshot("reset").catch(showServiceError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outcome === "keepalive") {
|
||||||
|
markSuccess("Live connection healthy; no listing changes.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.streamFailures = 0;
|
||||||
|
scheduleRender();
|
||||||
|
markSuccess(expectedKind === "sessionUpsert"
|
||||||
|
? "Applied a live session add or update."
|
||||||
|
: "Applied a live session removal.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectStream(cursor, corrupt = false) {
|
||||||
|
if (state.pollingTimer) {
|
||||||
|
clearTimeout(state.pollingTimer);
|
||||||
|
state.pollingTimer = null;
|
||||||
|
}
|
||||||
|
if (state.source) {
|
||||||
|
state.source.close();
|
||||||
|
}
|
||||||
|
const selectedCursor = corrupt
|
||||||
|
? `${cursor.slice(0, -1)}${cursor.endsWith("a") ? "b" : "a"}`
|
||||||
|
: cursor;
|
||||||
|
setStatus(corrupt ? "reset" : "reconnecting",
|
||||||
|
corrupt ? "Testing cursor reset" : "Connecting live updates",
|
||||||
|
corrupt ? "Cursor reset test" : "SSE reconnecting");
|
||||||
|
const source = new EventSource(buildStreamUrl(state.activeFilter, selectedCursor));
|
||||||
|
state.source = source;
|
||||||
|
source.addEventListener("open", () => {
|
||||||
|
if (state.source !== source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.streamFailures = 0;
|
||||||
|
setStatus("connected", "Live updates connected", "SSE live");
|
||||||
|
});
|
||||||
|
for (const [name, kind] of [
|
||||||
|
["session_upsert", "sessionUpsert"],
|
||||||
|
["session_remove", "sessionRemove"],
|
||||||
|
["reset", "reset"],
|
||||||
|
["keepalive", "keepalive"],
|
||||||
|
]) {
|
||||||
|
source.addEventListener(name, (message) => {
|
||||||
|
if (state.source === source) {
|
||||||
|
handleStreamEvent(message.data, kind);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
source.addEventListener("error", () => {
|
||||||
|
if (state.source !== source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.streamFailures += 1;
|
||||||
|
if (state.streamFailures >= STREAM_FAILURE_LIMIT || source.readyState === EventSource.CLOSED) {
|
||||||
|
usePolling("Live stream unavailable; polling fallback active");
|
||||||
|
} else {
|
||||||
|
setStatus("reconnecting", "Live stream interrupted; reconnecting", "SSE reconnecting");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfiguration() {
|
||||||
|
const configuration = await readJson("/diagnostics/config.json");
|
||||||
|
if (configuration.contractVersion !== CONTRACT_VERSION
|
||||||
|
|| !Array.isArray(configuration.scopes)
|
||||||
|
|| configuration.scopes.length < 1
|
||||||
|
|| !Number.isInteger(configuration.pollIntervalSeconds)
|
||||||
|
|| !Number.isInteger(configuration.maximumRenderedSessions)) {
|
||||||
|
throw new Error("The diagnostic configuration is invalid.");
|
||||||
|
}
|
||||||
|
state.configuration = configuration;
|
||||||
|
state.projection = new SessionProjection(configuration.maximumRenderedSessions);
|
||||||
|
populate(elements.game, [...new Set(configuration.scopes.map((scope) => scope.gameId))], "");
|
||||||
|
updateEnvironmentOptions();
|
||||||
|
state.activeFilter = currentFilter();
|
||||||
|
await fetchSnapshot("startup");
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.game.addEventListener("change", updateEnvironmentOptions);
|
||||||
|
elements.environment.addEventListener("change", updateScopeOptions);
|
||||||
|
elements.form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
setFormError();
|
||||||
|
stopLiveWork();
|
||||||
|
state.pollingOnly = false;
|
||||||
|
state.activeFilter = currentFilter();
|
||||||
|
state.projection = new SessionProjection(state.configuration.maximumRenderedSessions);
|
||||||
|
fetchSnapshot("filter").catch(showServiceError);
|
||||||
|
} catch (error) {
|
||||||
|
setFormError(safeText(error.message, 200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
elements.reconnect.addEventListener("click", () => {
|
||||||
|
if (state.projection?.cursor) {
|
||||||
|
state.pollingOnly = false;
|
||||||
|
connectStream(state.projection.cursor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
elements.reset.addEventListener("click", () => {
|
||||||
|
if (state.projection?.cursor) {
|
||||||
|
state.pollingOnly = false;
|
||||||
|
connectStream(state.projection.cursor, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
elements.poll.addEventListener("click", () => {
|
||||||
|
state.pollingOnly = true;
|
||||||
|
usePolling("Polling only selected deliberately");
|
||||||
|
fetchSnapshot("poll").catch(showServiceError);
|
||||||
|
});
|
||||||
|
window.addEventListener("pagehide", stopLiveWork, { once: true });
|
||||||
|
setInterval(updateAges, 5000);
|
||||||
|
loadConfiguration().catch(showServiceError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
startDashboard();
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>Session diagnostics — Rendezvous</title>
|
||||||
|
<link rel="stylesheet" href="/diagnostics/styles.css">
|
||||||
|
<script type="module" src="/diagnostics/app.mjs"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main-content">Skip to session results</a>
|
||||||
|
<header class="site-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Final Factory infrastructure</p>
|
||||||
|
<h1>Rendezvous session diagnostics</h1>
|
||||||
|
<p class="lede">A read-only view of public session discovery. Capacity is advisory; joining always revalidates current state.</p>
|
||||||
|
</div>
|
||||||
|
<div class="connection-state" aria-live="polite" aria-atomic="true">
|
||||||
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
|
<span id="stream-status">Loading configuration</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main-content" tabindex="-1">
|
||||||
|
<section class="filter-panel" aria-labelledby="filter-heading">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Public browser scope</p>
|
||||||
|
<h2 id="filter-heading">Choose a configured session view</h2>
|
||||||
|
</div>
|
||||||
|
<p class="boundary-note">This page has no join, publish, or operator authority.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="filters" novalidate>
|
||||||
|
<div class="filter-grid">
|
||||||
|
<label>
|
||||||
|
<span>Game</span>
|
||||||
|
<select id="game-filter" name="game" required></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Environment</span>
|
||||||
|
<select id="environment-filter" name="environment" required></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Protocol</span>
|
||||||
|
<select id="protocol-filter" name="protocol" required></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Region</span>
|
||||||
|
<select id="region-filter" name="region" required></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Availability</span>
|
||||||
|
<select id="capacity-filter" name="capacity">
|
||||||
|
<option value="open">Open slots only</option>
|
||||||
|
<option value="all">Include full sessions</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Visibility</span>
|
||||||
|
<select id="visibility-filter" name="visibility" disabled>
|
||||||
|
<option value="public">Public only</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p id="filter-error" class="form-error" role="alert" hidden></p>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="button primary" type="submit">Apply filters</button>
|
||||||
|
<button class="button" id="reconnect-button" type="button">Reconnect now</button>
|
||||||
|
<button class="button" id="reset-button" type="button">Test reset recovery</button>
|
||||||
|
<button class="button" id="poll-button" type="button">Use polling only</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="summary-panel" aria-labelledby="summary-heading">
|
||||||
|
<h2 id="summary-heading" class="visually-hidden">Current diagnostic state</h2>
|
||||||
|
<dl class="summary-grid">
|
||||||
|
<div>
|
||||||
|
<dt>Sessions shown</dt>
|
||||||
|
<dd id="session-count">0</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Transport</dt>
|
||||||
|
<dd id="transport-state">Starting</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Last successful update</dt>
|
||||||
|
<dd><time id="last-update">Not yet</time></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Projection</dt>
|
||||||
|
<dd id="projection-state">Waiting for snapshot</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="results-panel" aria-labelledby="results-heading" aria-busy="true">
|
||||||
|
<div class="section-heading results-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Bounded public projection</p>
|
||||||
|
<h2 id="results-heading">Available sessions</h2>
|
||||||
|
</div>
|
||||||
|
<p id="results-summary" role="status" aria-live="polite">Loading a fresh snapshot…</p>
|
||||||
|
</div>
|
||||||
|
<div id="session-list" class="session-grid" role="list"></div>
|
||||||
|
<div id="empty-state" class="empty-state" hidden>
|
||||||
|
<h3>No compatible public sessions</h3>
|
||||||
|
<p>The service answered successfully, but this configured filter currently has no listings.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Read-only diagnostics · no credentials stored · no gameplay or administration</p>
|
||||||
|
</footer>
|
||||||
|
<noscript>This diagnostic requires JavaScript to consume the public snapshot and event stream.</noscript>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #071019;
|
||||||
|
--surface: #101c28;
|
||||||
|
--surface-raised: #172737;
|
||||||
|
--border: #375066;
|
||||||
|
--text: #f2f7fb;
|
||||||
|
--muted: #b6c8d6;
|
||||||
|
--accent: #55d7b5;
|
||||||
|
--accent-strong: #7ce9cc;
|
||||||
|
--accent-ink: #04231c;
|
||||||
|
--warning: #ffd479;
|
||||||
|
--danger: #ff9f9f;
|
||||||
|
--focus: #ffd479;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--space-1: 0.5rem;
|
||||||
|
--space-2: 0.75rem;
|
||||||
|
--space-3: 1rem;
|
||||||
|
--space-4: 1.5rem;
|
||||||
|
--space-5: 2rem;
|
||||||
|
--space-6: 3rem;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 100%;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-width: 20rem;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 10% -10%, rgb(36 103 104 / 35%), transparent 32rem),
|
||||||
|
linear-gradient(180deg, #09141f 0%, var(--bg) 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 0.2rem solid var(--focus);
|
||||||
|
outline-offset: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10;
|
||||||
|
top: var(--space-2);
|
||||||
|
left: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
color: #071019;
|
||||||
|
background: var(--focus);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
transform: translateY(-200%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header,
|
||||||
|
main,
|
||||||
|
footer {
|
||||||
|
width: min(80rem, calc(100% - 2rem));
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-5);
|
||||||
|
padding-block: var(--space-6) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
max-width: 18ch;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
font-size: clamp(2rem, 6vw, 4.25rem);
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
font-size: clamp(1.35rem, 3vw, 2rem);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
max-width: 65ch;
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-state {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(16 28 40 / 88%);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 0.7rem;
|
||||||
|
height: 0.7rem;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-stream-state="connected"] .connection-state {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-stream-state="reconnecting"] .connection-state,
|
||||||
|
body[data-stream-state="polling"] .connection-state,
|
||||||
|
body[data-stream-state="reset"] .connection-state {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-stream-state="error"] .connection-state {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel,
|
||||||
|
.summary-panel,
|
||||||
|
.results-panel {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgb(16 28 40 / 94%);
|
||||||
|
box-shadow: 0 1rem 3rem rgb(0 0 0 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel,
|
||||||
|
.results-panel {
|
||||||
|
padding: clamp(1rem, 4vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boundary-note,
|
||||||
|
#results-summary {
|
||||||
|
max-width: 34rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0.6rem 2.4rem 0.6rem 0.75rem;
|
||||||
|
border: 1px solid #587189;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
color: var(--text);
|
||||||
|
background: #0b1722;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:disabled {
|
||||||
|
color: #9fb0bd;
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border: 1px solid #6a8298;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
background: #21384a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary {
|
||||||
|
color: var(--accent-ink);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary:hover {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin: var(--space-3) 0 0;
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-panel {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid > div:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid dd {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-heading {
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid #496177;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: #0b1722;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-heading h3 {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border: 1px solid #597187;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(5rem, auto) 1fr;
|
||||||
|
gap: 0.25rem var(--space-2);
|
||||||
|
margin: var(--space-3) 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-detail dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-detail dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity-row {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity-copy {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
meter {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.65rem;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: var(--space-3) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-list li {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
color: #d6e4ed;
|
||||||
|
background: #1b2b39;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updated-age {
|
||||||
|
margin: var(--space-3) 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: var(--space-6) var(--space-3);
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed #587189;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding-block: var(--space-5);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 56rem) {
|
||||||
|
.site-header,
|
||||||
|
.section-heading {
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid,
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid > div:nth-child(2) {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid > div:nth-child(-n + 2) {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 36rem) {
|
||||||
|
.site-header,
|
||||||
|
main,
|
||||||
|
footer {
|
||||||
|
width: min(100% - 1rem, 80rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
padding-block: var(--space-5) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid,
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid > div {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid > div:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Diagnostics;
|
||||||
|
|
||||||
|
internal static class DiagnosticDashboardEndpoints
|
||||||
|
{
|
||||||
|
private const string ContentSecurityPolicy =
|
||||||
|
"default-src 'none'; base-uri 'none'; connect-src 'self'; "
|
||||||
|
+ "font-src 'self'; form-action 'none'; frame-ancestors 'none'; "
|
||||||
|
+ "img-src 'self'; manifest-src 'none'; object-src 'none'; "
|
||||||
|
+ "script-src 'self'; style-src 'self'";
|
||||||
|
private static readonly byte[] Index = ReadAsset("index.html");
|
||||||
|
private static readonly byte[] Script = ReadAsset("app.mjs");
|
||||||
|
private static readonly byte[] Styles = ReadAsset("styles.css");
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapDiagnosticDashboardEndpoints(
|
||||||
|
this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
RouteGroupBuilder dashboard = endpoints.MapGroup("/diagnostics")
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
dashboard.MapGet("", ServeIndex);
|
||||||
|
dashboard.MapGet("/app.mjs", ServeScript);
|
||||||
|
dashboard.MapGet("/styles.css", ServeStyles);
|
||||||
|
dashboard.MapGet("/config.json", ServeConfiguration);
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult ServeIndex(
|
||||||
|
HttpContext context,
|
||||||
|
[FromServices] IOptions<DiagnosticDashboardOptions> configured) =>
|
||||||
|
ServeAsset(context, configured.Value, Index, "text/html; charset=utf-8");
|
||||||
|
|
||||||
|
private static IResult ServeScript(
|
||||||
|
HttpContext context,
|
||||||
|
[FromServices] IOptions<DiagnosticDashboardOptions> configured) =>
|
||||||
|
ServeAsset(context, configured.Value, Script, "text/javascript; charset=utf-8");
|
||||||
|
|
||||||
|
private static IResult ServeStyles(
|
||||||
|
HttpContext context,
|
||||||
|
[FromServices] IOptions<DiagnosticDashboardOptions> configured) =>
|
||||||
|
ServeAsset(context, configured.Value, Styles, "text/css; charset=utf-8");
|
||||||
|
|
||||||
|
private static IResult ServeConfiguration(
|
||||||
|
HttpContext context,
|
||||||
|
[FromServices] IOptions<DiagnosticDashboardOptions> configured)
|
||||||
|
{
|
||||||
|
DiagnosticDashboardOptions options = configured.Value;
|
||||||
|
if (!options.Enabled)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplySecurityHeaders(context.Response);
|
||||||
|
return Results.Json(new
|
||||||
|
{
|
||||||
|
contractVersion = ContractLimits.ContractVersion,
|
||||||
|
pollIntervalSeconds = options.PollIntervalSeconds,
|
||||||
|
maximumRenderedSessions = options.MaximumRenderedSessions,
|
||||||
|
scopes = options.Scopes.Select(static scope => new
|
||||||
|
{
|
||||||
|
gameId = scope.GameId,
|
||||||
|
environmentId = scope.EnvironmentId,
|
||||||
|
protocolVersions = scope.ProtocolVersions,
|
||||||
|
regions = scope.Regions,
|
||||||
|
visibility = "public",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult ServeAsset(
|
||||||
|
HttpContext context,
|
||||||
|
DiagnosticDashboardOptions options,
|
||||||
|
byte[] content,
|
||||||
|
string contentType)
|
||||||
|
{
|
||||||
|
if (!options.Enabled)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplySecurityHeaders(context.Response);
|
||||||
|
return Results.Bytes(content, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplySecurityHeaders(HttpResponse response)
|
||||||
|
{
|
||||||
|
response.Headers.CacheControl = "no-store";
|
||||||
|
response.Headers["Content-Security-Policy"] = ContentSecurityPolicy;
|
||||||
|
response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||||
|
response.Headers["X-Frame-Options"] = "DENY";
|
||||||
|
response.Headers["Cross-Origin-Opener-Policy"] = "same-origin";
|
||||||
|
response.Headers["Cross-Origin-Resource-Policy"] = "same-origin";
|
||||||
|
response.Headers["Permissions-Policy"] =
|
||||||
|
"camera=(), geolocation=(), microphone=(), payment=(), usb=()";
|
||||||
|
response.Headers["Referrer-Policy"] = "no-referrer";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ReadAsset(string fileName)
|
||||||
|
{
|
||||||
|
Assembly assembly = typeof(DiagnosticDashboardEndpoints).Assembly;
|
||||||
|
string resourceName = $"FinalFactory.Rendezvous.Server.Diagnostics.Assets.{fileName}";
|
||||||
|
using Stream source = assembly.GetManifestResourceStream(resourceName)
|
||||||
|
?? throw new InvalidOperationException($"Missing embedded dashboard asset {fileName}.");
|
||||||
|
using MemoryStream destination = new();
|
||||||
|
source.CopyTo(destination);
|
||||||
|
return destination.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Diagnostics;
|
||||||
|
|
||||||
|
internal sealed record DiagnosticDashboardOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Rendezvous:Diagnostics";
|
||||||
|
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
public int PollIntervalSeconds { get; init; } = 10;
|
||||||
|
public int MaximumRenderedSessions { get; init; } = 100;
|
||||||
|
public DiagnosticDashboardScope[] Scopes { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Validate()
|
||||||
|
{
|
||||||
|
List<string> errors = [];
|
||||||
|
if (PollIntervalSeconds is < 5 or > 60)
|
||||||
|
{
|
||||||
|
errors.Add($"{SectionName}:PollIntervalSeconds must be between 5 and 60.");
|
||||||
|
}
|
||||||
|
if (MaximumRenderedSessions is < 10 or > 500)
|
||||||
|
{
|
||||||
|
errors.Add($"{SectionName}:MaximumRenderedSessions must be between 10 and 500.");
|
||||||
|
}
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
if (Scopes is null || Scopes.Length is < 1 or > 32)
|
||||||
|
{
|
||||||
|
errors.Add($"{SectionName}:Scopes must contain between 1 and 32 allow-listed scopes when enabled.");
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> identities = new(StringComparer.Ordinal);
|
||||||
|
foreach (DiagnosticDashboardScope? scope in Scopes)
|
||||||
|
{
|
||||||
|
if (scope is null)
|
||||||
|
{
|
||||||
|
errors.Add($"{SectionName}:Scopes cannot contain null entries.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string gameId = scope.GameId ?? string.Empty;
|
||||||
|
string environmentId = scope.EnvironmentId ?? string.Empty;
|
||||||
|
uint[] protocolVersions = scope.ProtocolVersions ?? [];
|
||||||
|
string[] regions = scope.Regions ?? [];
|
||||||
|
if (!GameId.TryParse(gameId, out _)
|
||||||
|
|| !EnvironmentId.TryParse(environmentId, out _))
|
||||||
|
{
|
||||||
|
errors.Add($"{SectionName}:Scopes contains an invalid game or environment identifier.");
|
||||||
|
}
|
||||||
|
if (protocolVersions.Length is < 1 or > 16
|
||||||
|
|| protocolVersions.Any(static version => version == 0)
|
||||||
|
|| protocolVersions.Distinct().Count() != protocolVersions.Length)
|
||||||
|
{
|
||||||
|
errors.Add($"{SectionName}:Scopes protocol versions must contain 1-16 unique positive values.");
|
||||||
|
}
|
||||||
|
if (regions.Length is < 1 or > 16
|
||||||
|
|| regions.Any(static region => !RegionId.TryParse(region ?? string.Empty, out _))
|
||||||
|
|| regions.Distinct(StringComparer.Ordinal).Count() != regions.Length)
|
||||||
|
{
|
||||||
|
errors.Add($"{SectionName}:Scopes regions must contain 1-16 unique valid identifiers.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string identity = $"{gameId}\n{environmentId}";
|
||||||
|
if (!identities.Add(identity))
|
||||||
|
{
|
||||||
|
errors.Add($"{SectionName}:Scopes contains a duplicate game/environment pair.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record DiagnosticDashboardScope
|
||||||
|
{
|
||||||
|
public string GameId { get; init; } = string.Empty;
|
||||||
|
public string EnvironmentId { get; init; } = string.Empty;
|
||||||
|
public uint[] ProtocolVersions { get; init; } = [];
|
||||||
|
public string[] Regions { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -47,9 +47,14 @@
|
|||||||
<Compile Include="Browser/EphemeralCursorProtector.cs" />
|
<Compile Include="Browser/EphemeralCursorProtector.cs" />
|
||||||
<Compile Include="Browser/SessionBrowserCursorCodec.cs" />
|
<Compile Include="Browser/SessionBrowserCursorCodec.cs" />
|
||||||
<Compile Include="Browser/SessionBrowserService.cs" />
|
<Compile Include="Browser/SessionBrowserService.cs" />
|
||||||
|
<Compile Include="Browser/SessionChangeJournal.cs" />
|
||||||
|
<Compile Include="Browser/SessionStreamCursorCodec.cs" />
|
||||||
|
<Compile Include="Browser/SessionStreamService.cs" />
|
||||||
<Compile Include="ConnectionOutcomes/ConnectionOutcomeService.cs" />
|
<Compile Include="ConnectionOutcomes/ConnectionOutcomeService.cs" />
|
||||||
<Compile Include="Deployment/DeploymentOptions.cs" />
|
<Compile Include="Deployment/DeploymentOptions.cs" />
|
||||||
<Compile Include="Deployment/GracefulDrainService.cs" />
|
<Compile Include="Deployment/GracefulDrainService.cs" />
|
||||||
|
<Compile Include="Diagnostics/DiagnosticDashboardEndpoints.cs" />
|
||||||
|
<Compile Include="Diagnostics/DiagnosticDashboardOptions.cs" />
|
||||||
<Compile Include="Http/ContractEndpoints.cs" />
|
<Compile Include="Http/ContractEndpoints.cs" />
|
||||||
<Compile Include="Http/RendezvousExceptionHandler.cs" />
|
<Compile Include="Http/RendezvousExceptionHandler.cs" />
|
||||||
<Compile Include="JoinAttempts/JoinAttemptCursorCodec.cs" />
|
<Compile Include="JoinAttempts/JoinAttemptCursorCodec.cs" />
|
||||||
@@ -57,6 +62,7 @@
|
|||||||
<Compile Include="Observability/AuditOptions.cs" />
|
<Compile Include="Observability/AuditOptions.cs" />
|
||||||
<Compile Include="Observability/AuditTrail.cs" />
|
<Compile Include="Observability/AuditTrail.cs" />
|
||||||
<Compile Include="Observability/HealthEndpoints.cs" />
|
<Compile Include="Observability/HealthEndpoints.cs" />
|
||||||
|
<Compile Include="Observability/PrometheusMetricsEndpoint.cs" />
|
||||||
<Compile Include="Observability/RendezvousReadiness.cs" />
|
<Compile Include="Observability/RendezvousReadiness.cs" />
|
||||||
<Compile Include="Observability/RendezvousTelemetry.cs" />
|
<Compile Include="Observability/RendezvousTelemetry.cs" />
|
||||||
<Compile Include="Observability/TelemetryMiddleware.cs" />
|
<Compile Include="Observability/TelemetryMiddleware.cs" />
|
||||||
@@ -85,4 +91,15 @@
|
|||||||
<Compile Include="Transport/UdpMediatorOptions.cs" />
|
<Compile Include="Transport/UdpMediatorOptions.cs" />
|
||||||
<Compile Include="Transport/UdpMediatorService.cs" />
|
<Compile Include="Transport/UdpMediatorService.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Diagnostics/Assets/app.mjs">
|
||||||
|
<LogicalName>FinalFactory.Rendezvous.Server.Diagnostics.Assets.app.mjs</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Include="Diagnostics/Assets/index.html">
|
||||||
|
<LogicalName>FinalFactory.Rendezvous.Server.Diagnostics.Assets.index.html</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Include="Diagnostics/Assets/styles.css">
|
||||||
|
<LogicalName>FinalFactory.Rendezvous.Server.Diagnostics.Assets.styles.css</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
using FinalFactory.Rendezvous.Server.Abuse;
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
using FinalFactory.Rendezvous.Server.Browser;
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
@@ -69,6 +70,12 @@ internal static class ContractEndpoints
|
|||||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("BrowseSessions");
|
.WithName("BrowseSessions");
|
||||||
|
sessions.MapGet("/stream", StreamSessions)
|
||||||
|
.Produces<SessionStreamEvent>(StatusCodes.Status200OK, contentType: "text/event-stream")
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
.WithName("StreamSessions");
|
||||||
sessions.MapGet("/{listingId}", GetSession)
|
sessions.MapGet("/{listingId}", GetSession)
|
||||||
.Produces<GetSessionResponse>()
|
.Produces<GetSessionResponse>()
|
||||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
@@ -349,6 +356,123 @@ internal static class ContractEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> StreamSessions(
|
||||||
|
[FromQuery] int contractVersion,
|
||||||
|
[FromQuery] string gameId,
|
||||||
|
[FromQuery] string environmentId,
|
||||||
|
[FromQuery] uint protocolVersion,
|
||||||
|
[FromQuery] string? regionId,
|
||||||
|
[FromQuery] bool? excludeFull,
|
||||||
|
[FromQuery] string? streamCursor,
|
||||||
|
[FromHeader(Name = "Last-Event-ID")] string? lastEventId,
|
||||||
|
[FromServices] SessionStreamService streams,
|
||||||
|
[FromServices] AbuseProtectionService abuseProtection,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||||
|
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|
||||||
|
|| regionId is not null && !RegionId.TryParse(regionId, out _))
|
||||||
|
{
|
||||||
|
return Error(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
if (!TryAcquireIdentity(
|
||||||
|
abuseProtection,
|
||||||
|
httpContext,
|
||||||
|
"StreamSessions",
|
||||||
|
Tenant(parsedGameId, parsedEnvironmentId),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||||
|
{
|
||||||
|
return RateLimited(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (abuseLease)
|
||||||
|
{
|
||||||
|
BrowserServiceResult<SessionStreamSubscription> subscribed = streams.Subscribe(new()
|
||||||
|
{
|
||||||
|
ContractVersion = contractVersion,
|
||||||
|
GameId = parsedGameId,
|
||||||
|
EnvironmentId = parsedEnvironmentId,
|
||||||
|
ProtocolVersion = protocolVersion,
|
||||||
|
RegionId = regionId is null ? null : new RegionId(regionId),
|
||||||
|
ExcludeFull = excludeFull ?? false,
|
||||||
|
}, string.IsNullOrEmpty(lastEventId) ? streamCursor : lastEventId);
|
||||||
|
if (!subscribed.Succeeded || subscribed.Value is null)
|
||||||
|
{
|
||||||
|
return Error(subscribed.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
using SessionStreamSubscription subscription = subscribed.Value;
|
||||||
|
using CancellationTokenSource duration = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
cancellationToken);
|
||||||
|
duration.CancelAfter(streams.MaximumConnectionDuration);
|
||||||
|
HttpResponse response = httpContext.Response;
|
||||||
|
response.StatusCode = StatusCodes.Status200OK;
|
||||||
|
response.ContentType = "text/event-stream";
|
||||||
|
response.Headers.CacheControl = "no-cache, no-store";
|
||||||
|
response.Headers["X-Accel-Buffering"] = "no";
|
||||||
|
await response.StartAsync(duration.Token).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!duration.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
SessionStreamReadResult read = streams.Read(subscription);
|
||||||
|
if (read.RequiresReset)
|
||||||
|
{
|
||||||
|
await WriteSseAsync(response, streams.ResetEvent(subscription), duration.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (read.Events.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (SessionStreamEvent item in read.Events)
|
||||||
|
{
|
||||||
|
await WriteSseAsync(response, item, duration.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool changed = await streams.WaitForChangeAsync(subscription, duration.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!changed)
|
||||||
|
{
|
||||||
|
await WriteSseAsync(
|
||||||
|
response,
|
||||||
|
streams.KeepaliveEvent(subscription),
|
||||||
|
duration.Token).ConfigureAwait(false);
|
||||||
|
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (duration.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
return Results.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteSseAsync(
|
||||||
|
HttpResponse response,
|
||||||
|
SessionStreamEvent item,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string eventName = item.Kind switch
|
||||||
|
{
|
||||||
|
SessionStreamEventKind.SessionUpsert => "session_upsert",
|
||||||
|
SessionStreamEventKind.SessionRemove => "session_remove",
|
||||||
|
SessionStreamEventKind.Reset => "reset",
|
||||||
|
_ => "keepalive",
|
||||||
|
};
|
||||||
|
string data = JsonSerializer.Serialize(item, ContractJson.Options);
|
||||||
|
await response.WriteAsync(
|
||||||
|
$"id: {item.Cursor}\nevent: {eventName}\ndata: {data}\n\n",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult GetSession(
|
private static IResult GetSession(
|
||||||
SessionListingId listingId,
|
SessionListingId listingId,
|
||||||
[FromQuery] int contractVersion,
|
[FromQuery] int contractVersion,
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal sealed record PrometheusMetricsOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Rendezvous:Metrics";
|
||||||
|
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
public string BearerTokenSecretReference { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Validate()
|
||||||
|
{
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
string reference = BearerTokenSecretReference ?? string.Empty;
|
||||||
|
bool environmentReference = reference.StartsWith("env:", StringComparison.Ordinal)
|
||||||
|
&& reference.Length > "env:".Length;
|
||||||
|
bool absoluteFileReference = reference.StartsWith("file:", StringComparison.Ordinal)
|
||||||
|
&& Path.IsPathFullyQualified(reference["file:".Length..]);
|
||||||
|
return reference.Length is < 5 or > 512
|
||||||
|
|| !(environmentReference || absoluteFileReference)
|
||||||
|
? [$"{SectionName}:BearerTokenSecretReference must be a bounded env: or absolute file: secret reference when metrics are enabled."]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class MetricsAccessCredential : IDisposable
|
||||||
|
{
|
||||||
|
private byte[]? _token;
|
||||||
|
|
||||||
|
private MetricsAccessCredential(byte[] token) => _token = token;
|
||||||
|
|
||||||
|
public static bool TryCreate(
|
||||||
|
PrometheusMetricsOptions options,
|
||||||
|
ISecretProvider secrets,
|
||||||
|
out MetricsAccessCredential? credential)
|
||||||
|
{
|
||||||
|
credential = null;
|
||||||
|
if (!options.Enabled
|
||||||
|
|| !secrets.TryGetSecret(options.BearerTokenSecretReference, out SecretMaterial? material)
|
||||||
|
|| material is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (material)
|
||||||
|
{
|
||||||
|
byte[] bytes = material.CopyBytes();
|
||||||
|
int length = bytes.Length;
|
||||||
|
while (length > 0 && bytes[length - 1] is (byte)'\r' or (byte)'\n')
|
||||||
|
{
|
||||||
|
length--;
|
||||||
|
}
|
||||||
|
bool valid = length is >= 32 and <= 128
|
||||||
|
&& bytes.AsSpan(0, length).IndexOfAnyExceptInRange((byte)0x21, (byte)0x7e) < 0;
|
||||||
|
if (!valid)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] token = bytes.AsSpan(0, length).ToArray();
|
||||||
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
|
credential = new(token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Authorizes(HttpRequest request)
|
||||||
|
{
|
||||||
|
byte[]? expected = _token;
|
||||||
|
string authorization = request.Headers.Authorization.ToString();
|
||||||
|
const string prefix = "Bearer ";
|
||||||
|
if (expected is null
|
||||||
|
|| !authorization.StartsWith(prefix, StringComparison.Ordinal)
|
||||||
|
|| authorization.Length - prefix.Length is < 32 or > 128)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] supplied = Encoding.UTF8.GetBytes(authorization[prefix.Length..]);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return supplied.Length == expected.Length
|
||||||
|
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(supplied);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
byte[]? token = Interlocked.Exchange(ref _token, null);
|
||||||
|
if (token is not null)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => "[MetricsAccessCredential: REDACTED]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class PrometheusMetricsEndpoint
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapPrometheusMetricsEndpoint(
|
||||||
|
this IEndpointRouteBuilder endpoints,
|
||||||
|
PrometheusMetricsOptions options,
|
||||||
|
MetricsAccessCredential? credential)
|
||||||
|
{
|
||||||
|
endpoints.MapGet("/metrics", (HttpContext context, RendezvousTelemetry telemetry) =>
|
||||||
|
Export(context, telemetry, options, credential))
|
||||||
|
.WithName("MetricsScrape")
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult Export(
|
||||||
|
HttpContext context,
|
||||||
|
RendezvousTelemetry telemetry,
|
||||||
|
PrometheusMetricsOptions options,
|
||||||
|
MetricsAccessCredential? credential)
|
||||||
|
{
|
||||||
|
if (!options.Enabled)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
if (credential is null || !credential.Authorizes(context.Request))
|
||||||
|
{
|
||||||
|
telemetry.RecordMetricsScrape("rejected");
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
telemetry.RecordMetricsScrape("accepted");
|
||||||
|
context.Response.Headers.CacheControl = "no-store";
|
||||||
|
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||||
|
return Results.Text(
|
||||||
|
telemetry.RenderPrometheus(),
|
||||||
|
"text/plain; version=0.0.4; charset=utf-8",
|
||||||
|
Encoding.UTF8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
using FinalFactory.Rendezvous.Server.State;
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Observability;
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
@@ -10,6 +15,10 @@ internal sealed class RendezvousTelemetry : IDisposable
|
|||||||
public const string ActivitySourceName = "FinalFactory.Rendezvous.Server";
|
public const string ActivitySourceName = "FinalFactory.Rendezvous.Server";
|
||||||
|
|
||||||
private readonly InMemoryEphemeralRendezvousStore _store;
|
private readonly InMemoryEphemeralRendezvousStore _store;
|
||||||
|
private readonly SessionChangeJournal? _sessionChanges;
|
||||||
|
private readonly ProvisioningRuntime? _provisioning;
|
||||||
|
private readonly ConcurrentDictionary<MetricSeriesKey, long> _prometheusCounters = new();
|
||||||
|
private readonly ConcurrentDictionary<MetricSeriesKey, PrometheusHistogram> _prometheusHistograms = new();
|
||||||
private readonly Meter _meter = new(MeterName, "1.0.0");
|
private readonly Meter _meter = new(MeterName, "1.0.0");
|
||||||
private readonly ActivitySource _activities = new(ActivitySourceName, "1.0.0");
|
private readonly ActivitySource _activities = new(ActivitySourceName, "1.0.0");
|
||||||
private readonly Counter<long> _httpRequests;
|
private readonly Counter<long> _httpRequests;
|
||||||
@@ -22,9 +31,14 @@ internal sealed class RendezvousTelemetry : IDisposable
|
|||||||
private readonly Counter<long> _operatorAuthentication;
|
private readonly Counter<long> _operatorAuthentication;
|
||||||
private readonly Histogram<double> _pairingLatency;
|
private readonly Histogram<double> _pairingLatency;
|
||||||
|
|
||||||
public RendezvousTelemetry(InMemoryEphemeralRendezvousStore store)
|
public RendezvousTelemetry(
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
SessionChangeJournal? sessionChanges = null,
|
||||||
|
ProvisioningRuntime? provisioning = null)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
|
_sessionChanges = sessionChanges;
|
||||||
|
_provisioning = provisioning;
|
||||||
_httpRequests = _meter.CreateCounter<long>("rendezvous.http.requests");
|
_httpRequests = _meter.CreateCounter<long>("rendezvous.http.requests");
|
||||||
_httpDuration = _meter.CreateHistogram<double>(
|
_httpDuration = _meter.CreateHistogram<double>(
|
||||||
"rendezvous.http.duration",
|
"rendezvous.http.duration",
|
||||||
@@ -75,9 +89,21 @@ internal sealed class RendezvousTelemetry : IDisposable
|
|||||||
};
|
};
|
||||||
_httpRequests.Add(1, tags);
|
_httpRequests.Add(1, tags);
|
||||||
_httpDuration.Record(elapsedMilliseconds, tags);
|
_httpDuration.Record(elapsedMilliseconds, tags);
|
||||||
|
IncrementPrometheus(
|
||||||
|
"rendezvous_http_requests_total",
|
||||||
|
Labels(("operation", operation), ("status_code", statusCode.ToString(CultureInfo.InvariantCulture))));
|
||||||
|
ObservePrometheus(
|
||||||
|
"rendezvous_http_duration_milliseconds",
|
||||||
|
elapsedMilliseconds,
|
||||||
|
Labels(("operation", operation), ("status_code", statusCode.ToString(CultureInfo.InvariantCulture))));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RecordUdp(string operation, string result, double elapsedMilliseconds)
|
public void RecordUdp(
|
||||||
|
string operation,
|
||||||
|
string result,
|
||||||
|
double elapsedMilliseconds,
|
||||||
|
int receivedBytes = 0,
|
||||||
|
int responseBudgetBytes = 0)
|
||||||
{
|
{
|
||||||
TagList tags = new()
|
TagList tags = new()
|
||||||
{
|
{
|
||||||
@@ -86,41 +112,315 @@ internal sealed class RendezvousTelemetry : IDisposable
|
|||||||
};
|
};
|
||||||
_udpResults.Add(1, tags);
|
_udpResults.Add(1, tags);
|
||||||
_udpDuration.Record(elapsedMilliseconds, tags);
|
_udpDuration.Record(elapsedMilliseconds, tags);
|
||||||
|
IncrementPrometheus(
|
||||||
|
"rendezvous_udp_results_total",
|
||||||
|
Labels(("operation", operation), ("result", result)));
|
||||||
|
ObservePrometheus(
|
||||||
|
"rendezvous_udp_duration_milliseconds",
|
||||||
|
elapsedMilliseconds,
|
||||||
|
Labels(("operation", operation), ("result", result)));
|
||||||
|
AddPrometheus(
|
||||||
|
"rendezvous_udp_received_bytes_total",
|
||||||
|
Math.Max(0, receivedBytes),
|
||||||
|
Labels(("operation", operation)));
|
||||||
|
AddPrometheus(
|
||||||
|
"rendezvous_udp_response_budget_bytes_total",
|
||||||
|
Math.Max(0, responseBudgetBytes),
|
||||||
|
Labels(("operation", operation)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RecordLimiterDrop(string transport, string partition) =>
|
public void RecordLimiterDrop(string transport, string partition)
|
||||||
|
{
|
||||||
_limiterDrops.Add(1, new TagList
|
_limiterDrops.Add(1, new TagList
|
||||||
{
|
{
|
||||||
{ "transport", transport },
|
{ "transport", transport },
|
||||||
{ "partition", partition },
|
{ "partition", partition },
|
||||||
});
|
});
|
||||||
|
IncrementPrometheus(
|
||||||
|
"rendezvous_limiter_drops_total",
|
||||||
|
Labels(("transport", transport), ("partition", partition)));
|
||||||
|
}
|
||||||
|
|
||||||
public void RecordAudit(string action, string result) =>
|
public void RecordAudit(string action, string result)
|
||||||
|
{
|
||||||
_auditEvents.Add(1, new TagList
|
_auditEvents.Add(1, new TagList
|
||||||
{
|
{
|
||||||
{ "action", action },
|
{ "action", action },
|
||||||
{ "result", result },
|
{ "result", result },
|
||||||
});
|
});
|
||||||
|
IncrementPrometheus(
|
||||||
|
"rendezvous_audit_events_total",
|
||||||
|
Labels(("action", action), ("result", result)));
|
||||||
|
}
|
||||||
|
|
||||||
public void RecordConnectionOutcome(string outcome, string elapsedBucket) =>
|
public void RecordConnectionOutcome(string outcome, string elapsedBucket)
|
||||||
|
{
|
||||||
_connectionOutcomes.Add(1, new TagList
|
_connectionOutcomes.Add(1, new TagList
|
||||||
{
|
{
|
||||||
{ "outcome", outcome },
|
{ "outcome", outcome },
|
||||||
{ "elapsed_bucket", elapsedBucket },
|
{ "elapsed_bucket", elapsedBucket },
|
||||||
});
|
});
|
||||||
|
IncrementPrometheus(
|
||||||
|
"rendezvous_connection_outcomes_total",
|
||||||
|
Labels(("outcome", outcome), ("elapsed_bucket", elapsedBucket)));
|
||||||
|
}
|
||||||
|
|
||||||
public void RecordOperatorAuthentication(string result) =>
|
public void RecordOperatorAuthentication(string result)
|
||||||
|
{
|
||||||
_operatorAuthentication.Add(1, new TagList
|
_operatorAuthentication.Add(1, new TagList
|
||||||
{
|
{
|
||||||
{ "result", result },
|
{ "result", result },
|
||||||
});
|
});
|
||||||
|
IncrementPrometheus(
|
||||||
|
"rendezvous_operator_authentication_total",
|
||||||
|
Labels(("result", result)));
|
||||||
|
}
|
||||||
|
|
||||||
public void RecordPairingLatency(double elapsedMilliseconds) =>
|
public void RecordPairingLatency(double elapsedMilliseconds)
|
||||||
|
{
|
||||||
_pairingLatency.Record(elapsedMilliseconds);
|
_pairingLatency.Record(elapsedMilliseconds);
|
||||||
|
ObservePrometheus("rendezvous_pairing_latency_milliseconds", elapsedMilliseconds, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordMetricsScrape(string result) => IncrementPrometheus(
|
||||||
|
"rendezvous_metrics_scrapes_total",
|
||||||
|
Labels(("result", result)));
|
||||||
|
|
||||||
|
public string RenderPrometheus()
|
||||||
|
{
|
||||||
|
StringBuilder output = new(16 * 1024);
|
||||||
|
foreach (IGrouping<string, KeyValuePair<MetricSeriesKey, long>> family in _prometheusCounters
|
||||||
|
.OrderBy(static item => item.Key.Name, StringComparer.Ordinal)
|
||||||
|
.ThenBy(static item => item.Key.Labels, StringComparer.Ordinal)
|
||||||
|
.GroupBy(static item => item.Key.Name, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
output.Append("# TYPE ").Append(family.Key).Append(" counter\n");
|
||||||
|
foreach (KeyValuePair<MetricSeriesKey, long> series in family)
|
||||||
|
{
|
||||||
|
AppendValue(output, series.Key.Name, series.Key.Labels, series.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (IGrouping<string, KeyValuePair<MetricSeriesKey, PrometheusHistogram>> family in
|
||||||
|
_prometheusHistograms
|
||||||
|
.OrderBy(static item => item.Key.Name, StringComparer.Ordinal)
|
||||||
|
.ThenBy(static item => item.Key.Labels, StringComparer.Ordinal)
|
||||||
|
.GroupBy(static item => item.Key.Name, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
output.Append("# TYPE ").Append(family.Key).Append(" histogram\n");
|
||||||
|
foreach (KeyValuePair<MetricSeriesKey, PrometheusHistogram> series in family)
|
||||||
|
{
|
||||||
|
series.Value.Append(output, series.Key.Name, series.Key.Labels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EphemeralStoreSnapshot store = _store.GetMetricsSnapshot();
|
||||||
|
AppendGauge(output, "rendezvous_store_active_listings", store.ActiveListings);
|
||||||
|
AppendGauge(output, "rendezvous_store_fresh_presence_bindings", store.FreshPresenceBindings);
|
||||||
|
AppendGauge(
|
||||||
|
output,
|
||||||
|
"rendezvous_store_awaiting_presence_listings",
|
||||||
|
Math.Max(0, store.ActiveListings - store.FreshPresenceBindings));
|
||||||
|
AppendGauge(output, "rendezvous_store_active_leases", store.ActiveListings);
|
||||||
|
AppendGauge(output, "rendezvous_store_active_attempts", store.ActiveJoinAttempts);
|
||||||
|
AppendGauge(output, "rendezvous_queue_depth", store.ActiveJoinAttempts);
|
||||||
|
AppendGauge(output, "rendezvous_store_replay_markers", store.ReplayMarkers);
|
||||||
|
AppendGauge(output, "rendezvous_store_available", store.IsAvailable ? 1 : 0);
|
||||||
|
AppendGauge(output, "rendezvous_store_draining", store.IsDraining ? 1 : 0);
|
||||||
|
AppendCounter(output, "rendezvous_store_expiry_churn_total", store.ExpiryChurn);
|
||||||
|
if (_sessionChanges is not null)
|
||||||
|
{
|
||||||
|
AppendGauge(output, "rendezvous_browser_sse_subscribers", _sessionChanges.SubscriberCount);
|
||||||
|
AppendGauge(output, "rendezvous_browser_sse_tenants", _sessionChanges.SubscribedTenantCount);
|
||||||
|
AppendGauge(output, "rendezvous_browser_replay_entries", _sessionChanges.ReplayCount);
|
||||||
|
}
|
||||||
|
AppendProcessMetrics(output);
|
||||||
|
AppendSigningKeyMetrics(output);
|
||||||
|
return output.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendSigningKeyMetrics(StringBuilder output)
|
||||||
|
{
|
||||||
|
if (_provisioning is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
|
SigningKeyStatus[] statuses = _provisioning.SigningKeys.GetStatuses(now).ToArray();
|
||||||
|
output.Append("# TYPE rendezvous_signing_keys gauge\n");
|
||||||
|
foreach (IGrouping<string, SigningKeyStatus> state in statuses.GroupBy(
|
||||||
|
static status => status.Status,
|
||||||
|
StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
AppendValue(
|
||||||
|
output,
|
||||||
|
"rendezvous_signing_keys",
|
||||||
|
Labels(("state", state.Key)),
|
||||||
|
state.Count());
|
||||||
|
}
|
||||||
|
double seconds = statuses
|
||||||
|
.Where(static status => status.Status == "signing")
|
||||||
|
.Select(status => Math.Max(0, (status.SignUntil - now).TotalSeconds))
|
||||||
|
.DefaultIfEmpty(0)
|
||||||
|
.Min();
|
||||||
|
AppendGauge(output, "rendezvous_signing_key_sign_seconds_remaining", seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendProcessMetrics(StringBuilder output)
|
||||||
|
{
|
||||||
|
using Process process = Process.GetCurrentProcess();
|
||||||
|
process.Refresh();
|
||||||
|
AppendCounter(output, "process_cpu_seconds_total", process.TotalProcessorTime.TotalSeconds);
|
||||||
|
AppendGauge(output, "process_resident_memory_bytes", process.WorkingSet64);
|
||||||
|
AppendGauge(output, "process_virtual_memory_bytes", process.VirtualMemorySize64);
|
||||||
|
AppendGauge(output, "process_threads", process.Threads.Count);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AppendGauge(
|
||||||
|
output,
|
||||||
|
"process_open_file_descriptors",
|
||||||
|
Directory.EnumerateFileSystemEntries("/proc/self/fd").Count());
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException
|
||||||
|
or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
AppendGauge(output, "dotnet_gc_heap_size_bytes", GC.GetTotalMemory(forceFullCollection: false));
|
||||||
|
output.Append("# TYPE dotnet_gc_collections_total counter\n");
|
||||||
|
AppendValue(output, "dotnet_gc_collections_total", Labels(("generation", "0")), GC.CollectionCount(0));
|
||||||
|
AppendValue(output, "dotnet_gc_collections_total", Labels(("generation", "1")), GC.CollectionCount(1));
|
||||||
|
AppendValue(output, "dotnet_gc_collections_total", Labels(("generation", "2")), GC.CollectionCount(2));
|
||||||
|
AppendGauge(output, "dotnet_thread_pool_threads", ThreadPool.ThreadCount);
|
||||||
|
ThreadPool.GetAvailableThreads(out int workerThreads, out int completionThreads);
|
||||||
|
AppendGauge(output, "dotnet_thread_pool_available_worker_threads", workerThreads);
|
||||||
|
AppendGauge(output, "dotnet_thread_pool_available_completion_threads", completionThreads);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void IncrementPrometheus(string name, string labels) =>
|
||||||
|
_prometheusCounters.AddOrUpdate(new(name, labels), 1, static (_, current) => current + 1);
|
||||||
|
|
||||||
|
private void AddPrometheus(string name, long value, string labels)
|
||||||
|
{
|
||||||
|
if (value <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_prometheusCounters.AddOrUpdate(new(name, labels), value, (_, current) => current + value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ObservePrometheus(string name, double value, string labels) =>
|
||||||
|
_prometheusHistograms.GetOrAdd(new(name, labels), static _ => new()).Observe(value);
|
||||||
|
|
||||||
|
private static string Labels(params (string Name, string Value)[] labels)
|
||||||
|
{
|
||||||
|
if (labels.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
return "{" + string.Join(',', labels.Select(static label =>
|
||||||
|
$"{label.Name}=\"{EscapeLabel(NormalizeLabel(label.Value))}\"")) + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeLabel(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value) || value.Length > 64)
|
||||||
|
{
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
return value.All(static character => char.IsAsciiLetterOrDigit(character)
|
||||||
|
|| character is '-' or '_' or '.')
|
||||||
|
? value
|
||||||
|
: "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeLabel(string value) => value
|
||||||
|
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||||
|
.Replace("\"", "\\\"", StringComparison.Ordinal)
|
||||||
|
.Replace("\n", "\\n", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static void AppendCounter(
|
||||||
|
StringBuilder output,
|
||||||
|
string name,
|
||||||
|
double value,
|
||||||
|
string labels = "")
|
||||||
|
{
|
||||||
|
output.Append("# TYPE ").Append(name).Append(" counter\n");
|
||||||
|
AppendValue(output, name, labels, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendGauge(
|
||||||
|
StringBuilder output,
|
||||||
|
string name,
|
||||||
|
double value,
|
||||||
|
string labels = "")
|
||||||
|
{
|
||||||
|
output.Append("# TYPE ").Append(name).Append(" gauge\n");
|
||||||
|
AppendValue(output, name, labels, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendValue(StringBuilder output, string name, string labels, double value) =>
|
||||||
|
output.Append(name)
|
||||||
|
.Append(labels)
|
||||||
|
.Append(' ')
|
||||||
|
.Append(value.ToString("R", CultureInfo.InvariantCulture))
|
||||||
|
.Append('\n');
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_activities.Dispose();
|
_activities.Dispose();
|
||||||
_meter.Dispose();
|
_meter.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly record struct MetricSeriesKey(string Name, string Labels);
|
||||||
|
|
||||||
|
private sealed class PrometheusHistogram
|
||||||
|
{
|
||||||
|
private static readonly double[] Bounds = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000];
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly long[] _buckets = new long[Bounds.Length];
|
||||||
|
private long _count;
|
||||||
|
private double _sum;
|
||||||
|
|
||||||
|
public void Observe(double value)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(value) || value < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_count++;
|
||||||
|
_sum += value;
|
||||||
|
for (int index = 0; index < Bounds.Length; index++)
|
||||||
|
{
|
||||||
|
if (value <= Bounds[index])
|
||||||
|
{
|
||||||
|
_buckets[index]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Append(StringBuilder output, string name, string labels)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
for (int index = 0; index < Bounds.Length; index++)
|
||||||
|
{
|
||||||
|
AppendValue(
|
||||||
|
output,
|
||||||
|
name + "_bucket",
|
||||||
|
AddLabel(labels, "le", Bounds[index].ToString("R", CultureInfo.InvariantCulture)),
|
||||||
|
_buckets[index]);
|
||||||
|
}
|
||||||
|
AppendValue(output, name + "_bucket", AddLabel(labels, "le", "+Inf"), _count);
|
||||||
|
AppendValue(output, name + "_sum", labels, _sum);
|
||||||
|
AppendValue(output, name + "_count", labels, _count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string AddLabel(string labels, string name, string value) =>
|
||||||
|
string.IsNullOrEmpty(labels)
|
||||||
|
? $"{{{name}=\"{value}\"}}"
|
||||||
|
: labels[..^1] + $",{name}=\"{value}\"}}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using FinalFactory.Rendezvous.Server.Abuse;
|
|||||||
using FinalFactory.Rendezvous.Server.Browser;
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||||
using FinalFactory.Rendezvous.Server.Deployment;
|
using FinalFactory.Rendezvous.Server.Deployment;
|
||||||
|
using FinalFactory.Rendezvous.Server.Diagnostics;
|
||||||
using FinalFactory.Rendezvous.Server.Http;
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
using FinalFactory.Rendezvous.Server.Observability;
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
@@ -251,10 +252,42 @@ if (!builder.Environment.IsDevelopment() && !isOpenApiGeneration)
|
|||||||
}
|
}
|
||||||
|
|
||||||
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(deploymentOptions));
|
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(deploymentOptions));
|
||||||
|
DiagnosticDashboardOptions diagnosticDashboardOptions = builder.Configuration
|
||||||
|
.GetSection(DiagnosticDashboardOptions.SectionName)
|
||||||
|
.Get<DiagnosticDashboardOptions>() ?? new DiagnosticDashboardOptions();
|
||||||
|
IReadOnlyList<string> diagnosticErrors = diagnosticDashboardOptions.Validate();
|
||||||
|
if (diagnosticErrors.Count > 0)
|
||||||
|
{
|
||||||
|
throw new DeploymentConfigurationException(diagnosticErrors);
|
||||||
|
}
|
||||||
|
builder.Services.AddSingleton(
|
||||||
|
Microsoft.Extensions.Options.Options.Create(diagnosticDashboardOptions));
|
||||||
|
PrometheusMetricsOptions metricsOptions = builder.Configuration
|
||||||
|
.GetSection(PrometheusMetricsOptions.SectionName)
|
||||||
|
.Get<PrometheusMetricsOptions>() ?? new PrometheusMetricsOptions();
|
||||||
|
IReadOnlyList<string> metricsErrors = metricsOptions.Validate();
|
||||||
|
if (metricsErrors.Count > 0)
|
||||||
|
{
|
||||||
|
throw new DeploymentConfigurationException(metricsErrors);
|
||||||
|
}
|
||||||
|
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(metricsOptions));
|
||||||
|
MetricsAccessCredential? metricsCredential = null;
|
||||||
|
if (metricsOptions.Enabled && !isOpenApiGeneration)
|
||||||
|
{
|
||||||
|
EnvironmentSecretProvider metricsSecrets = new();
|
||||||
|
if (!MetricsAccessCredential.TryCreate(metricsOptions, metricsSecrets, out metricsCredential)
|
||||||
|
|| metricsCredential is null)
|
||||||
|
{
|
||||||
|
throw new DeploymentConfigurationException(
|
||||||
|
[$"{PrometheusMetricsOptions.SectionName}:BearerTokenSecretReference did not resolve to 32-128 visible ASCII bytes."]);
|
||||||
|
}
|
||||||
|
builder.Services.AddSingleton(metricsCredential);
|
||||||
|
}
|
||||||
builder.Services.Configure<HostOptions>(options =>
|
builder.Services.Configure<HostOptions>(options =>
|
||||||
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
|
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
|
||||||
|
|
||||||
SystemRendezvousClock rendezvousClock = new();
|
SystemRendezvousClock rendezvousClock = new();
|
||||||
|
SessionChangeJournal sessionChanges = new(new SessionChangeJournalOptions());
|
||||||
EphemeralStoreOptions stateOptions = new()
|
EphemeralStoreOptions stateOptions = new()
|
||||||
{
|
{
|
||||||
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
|
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
|
||||||
@@ -262,8 +295,10 @@ EphemeralStoreOptions stateOptions = new()
|
|||||||
InMemoryEphemeralRendezvousStore stateStore = new(
|
InMemoryEphemeralRendezvousStore stateStore = new(
|
||||||
stateOptions,
|
stateOptions,
|
||||||
rendezvousClock,
|
rendezvousClock,
|
||||||
rendezvousClock);
|
rendezvousClock,
|
||||||
|
sessionChanges);
|
||||||
builder.Services.AddSingleton(stateStore);
|
builder.Services.AddSingleton(stateStore);
|
||||||
|
builder.Services.AddSingleton(sessionChanges);
|
||||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
||||||
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
|
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
|
||||||
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
|
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
|
||||||
@@ -297,7 +332,9 @@ else
|
|||||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
builder.Services.AddSingleton<SessionLeaseService>();
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
||||||
builder.Services.AddSingleton<SessionBrowserService>();
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamService>();
|
||||||
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||||
builder.Services.AddSingleton<JoinAttemptService>();
|
builder.Services.AddSingleton<JoinAttemptService>();
|
||||||
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
|
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
|
||||||
@@ -344,6 +381,8 @@ app.MapOpenApi();
|
|||||||
app.MapRendezvousContractEndpoints();
|
app.MapRendezvousContractEndpoints();
|
||||||
app.MapOperatorEndpoints();
|
app.MapOperatorEndpoints();
|
||||||
app.MapRendezvousHealthEndpoints();
|
app.MapRendezvousHealthEndpoints();
|
||||||
|
app.MapDiagnosticDashboardEndpoints();
|
||||||
|
app.MapPrometheusMetricsEndpoint(metricsOptions, metricsCredential);
|
||||||
|
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -240,7 +240,15 @@ internal sealed class SessionLeaseService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
StoredListing ownedListing = listing!;
|
StoredListing ownedListing = listing!;
|
||||||
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
|
PublisherAuthorizationResult authorized = authorization.Authorize(
|
||||||
|
principal,
|
||||||
|
ownedListing.Definition.Scope.GameId,
|
||||||
|
ownedListing.Definition.Scope.EnvironmentId,
|
||||||
|
request.RegionId ?? ownedListing.Definition.RegionId,
|
||||||
|
request.ProtocolVersion ?? ownedListing.Definition.ProtocolVersion,
|
||||||
|
request.Visibility ?? ownedListing.Definition.Visibility,
|
||||||
|
request.Metadata,
|
||||||
|
clock.UtcNow);
|
||||||
if (!authorized.IsAllowed || authorized.Context is null)
|
if (!authorized.IsAllowed || authorized.Context is null)
|
||||||
{
|
{
|
||||||
return new(MapAuthorization(authorized.Error));
|
return new(MapAuthorization(authorized.Error));
|
||||||
@@ -261,7 +269,10 @@ internal sealed class SessionLeaseService(
|
|||||||
request.Capacity.CurrentPlayers,
|
request.Capacity.CurrentPlayers,
|
||||||
request.Capacity.MaximumPlayers,
|
request.Capacity.MaximumPlayers,
|
||||||
request.Metadata,
|
request.Metadata,
|
||||||
request.DedicatedFallback), cancellationToken);
|
request.DedicatedFallback,
|
||||||
|
request.RegionId,
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.Visibility), cancellationToken);
|
||||||
return updated.Succeeded
|
return updated.Succeeded
|
||||||
? new(RendezvousErrorCode.None, true)
|
? new(RendezvousErrorCode.None, true)
|
||||||
: new(updated.Code.ToContractError());
|
: new(updated.Code.ToContractError());
|
||||||
@@ -391,6 +402,9 @@ internal sealed class SessionLeaseService(
|
|||||||
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||||
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||||
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||||
|
|| request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value)
|
||||||
|
|| request.ProtocolVersion.HasValue && request.ProtocolVersion.Value == 0
|
||||||
|
|| request.Visibility.HasValue && !Enum.IsDefined(request.Visibility.Value)
|
||||||
|| request.DedicatedFallback is not null
|
|| request.DedicatedFallback is not null
|
||||||
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
|
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
|
||||||
? RendezvousErrorCode.InvalidRequest
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
|||||||
@@ -228,7 +228,10 @@ internal sealed record UpdateListingCommand(
|
|||||||
int CurrentPlayers,
|
int CurrentPlayers,
|
||||||
int MaximumPlayers,
|
int MaximumPlayers,
|
||||||
IReadOnlyDictionary<string, string> Metadata,
|
IReadOnlyDictionary<string, string> Metadata,
|
||||||
NetworkEndpoint? DedicatedFallback);
|
NetworkEndpoint? DedicatedFallback,
|
||||||
|
RegionId? RegionId = null,
|
||||||
|
uint? ProtocolVersion = null,
|
||||||
|
ListingVisibility? Visibility = null);
|
||||||
|
|
||||||
internal sealed record DeleteListingCommand(
|
internal sealed record DeleteListingCommand(
|
||||||
SessionListingId ListingId,
|
SessionListingId ListingId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.State;
|
namespace FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly EphemeralStoreOptions _options;
|
private readonly EphemeralStoreOptions _options;
|
||||||
private readonly IMonotonicClock _monotonicClock;
|
private readonly IMonotonicClock _monotonicClock;
|
||||||
|
private readonly SessionChangeJournal? _sessionChanges;
|
||||||
private readonly DateTimeOffset _wallOrigin;
|
private readonly DateTimeOffset _wallOrigin;
|
||||||
private readonly TimeSpan _monotonicOrigin;
|
private readonly TimeSpan _monotonicOrigin;
|
||||||
private readonly Dictionary<SessionListingId, ListingEntry> _listings = [];
|
private readonly Dictionary<SessionListingId, ListingEntry> _listings = [];
|
||||||
@@ -45,7 +47,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
public InMemoryEphemeralRendezvousStore(
|
public InMemoryEphemeralRendezvousStore(
|
||||||
EphemeralStoreOptions options,
|
EphemeralStoreOptions options,
|
||||||
IWallClock wallClock,
|
IWallClock wallClock,
|
||||||
IMonotonicClock monotonicClock)
|
IMonotonicClock monotonicClock,
|
||||||
|
SessionChangeJournal? sessionChanges = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
ArgumentNullException.ThrowIfNull(wallClock);
|
ArgumentNullException.ThrowIfNull(wallClock);
|
||||||
@@ -53,6 +56,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
options.Validate();
|
options.Validate();
|
||||||
_options = options;
|
_options = options;
|
||||||
_monotonicClock = monotonicClock;
|
_monotonicClock = monotonicClock;
|
||||||
|
_sessionChanges = sessionChanges;
|
||||||
_wallOrigin = wallClock.UtcNow;
|
_wallOrigin = wallClock.UtcNow;
|
||||||
_monotonicOrigin = monotonicClock.Elapsed;
|
_monotonicOrigin = monotonicClock.Elapsed;
|
||||||
InstanceId = Guid.NewGuid();
|
InstanceId = Guid.NewGuid();
|
||||||
@@ -225,7 +229,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
now + _options.IdempotencyLifetime);
|
now + _options.IdempotencyLifetime);
|
||||||
_idempotency.Add(idempotencyKey, idempotency);
|
_idempotency.Add(idempotencyKey, idempotency);
|
||||||
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline);
|
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline);
|
||||||
return new(StoreResultCode.Success, Snapshot(entry));
|
StoredListing created = Snapshot(entry);
|
||||||
|
_sessionChanges?.Publish(null, created);
|
||||||
|
return new(StoreResultCode.Success, created);
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
public StoreResult<StoredListing> RenewLease(
|
public StoreResult<StoredListing> RenewLease(
|
||||||
@@ -277,6 +283,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|
||||||
|| command.CurrentPlayers < 0
|
|| command.CurrentPlayers < 0
|
||||||
|| command.CurrentPlayers > command.MaximumPlayers
|
|| command.CurrentPlayers > command.MaximumPlayers
|
||||||
|
|| command.RegionId.HasValue && string.IsNullOrEmpty(command.RegionId.Value.Value)
|
||||||
|
|| command.ProtocolVersion.HasValue && command.ProtocolVersion.Value == 0
|
||||||
|
|| command.Visibility.HasValue && !Enum.IsDefined(command.Visibility.Value)
|
||||||
|| !ContractValidation.IsMetadataValid(command.Metadata)
|
|| !ContractValidation.IsMetadataValid(command.Metadata)
|
||||||
|| command.DedicatedFallback is not null
|
|| command.DedicatedFallback is not null
|
||||||
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback))
|
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback))
|
||||||
@@ -302,8 +311,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return new(StoreResultCode.NotFound);
|
return new(StoreResultCode.NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StoredListing before = Snapshot(entry);
|
||||||
entry.Definition = StoredListing.Freeze(entry.Definition with
|
entry.Definition = StoredListing.Freeze(entry.Definition with
|
||||||
{
|
{
|
||||||
|
RegionId = command.RegionId ?? entry.Definition.RegionId,
|
||||||
|
ProtocolVersion = command.ProtocolVersion ?? entry.Definition.ProtocolVersion,
|
||||||
|
Visibility = command.Visibility ?? entry.Definition.Visibility,
|
||||||
BuildVersion = command.BuildVersion,
|
BuildVersion = command.BuildVersion,
|
||||||
DisplayName = command.DisplayName,
|
DisplayName = command.DisplayName,
|
||||||
CurrentPlayers = command.CurrentPlayers,
|
CurrentPlayers = command.CurrentPlayers,
|
||||||
@@ -312,7 +325,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
DedicatedFallback = command.DedicatedFallback,
|
DedicatedFallback = command.DedicatedFallback,
|
||||||
});
|
});
|
||||||
entry.Version++;
|
entry.Version++;
|
||||||
return new(StoreResultCode.Success, Snapshot(entry));
|
StoredListing after = Snapshot(entry);
|
||||||
|
_sessionChanges?.Publish(before, after);
|
||||||
|
return new(StoreResultCode.Success, after);
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
public StoreResult<bool> DeleteListing(
|
public StoreResult<bool> DeleteListing(
|
||||||
@@ -382,6 +397,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return new(StoreResultCode.CapacityExceeded);
|
return new(StoreResultCode.CapacityExceeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StoredListing before = Snapshot(entry);
|
||||||
bool isNewPresence = !_presence.ContainsKey(command.Handle);
|
bool isNewPresence = !_presence.ContainsKey(command.Handle);
|
||||||
PresenceEntry presence = new(
|
PresenceEntry presence = new(
|
||||||
command.PublicEndpoint,
|
command.PublicEndpoint,
|
||||||
@@ -396,7 +412,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
command.Handle,
|
command.Handle,
|
||||||
presence.Deadline);
|
presence.Deadline);
|
||||||
}
|
}
|
||||||
return new(StoreResultCode.Success, Snapshot(entry));
|
StoredListing after = Snapshot(entry);
|
||||||
|
_sessionChanges?.Publish(before, after);
|
||||||
|
return new(StoreResultCode.Success, after);
|
||||||
}, cancellationToken, eagerCleanup: false);
|
}, cancellationToken, eagerCleanup: false);
|
||||||
|
|
||||||
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
|
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
|
||||||
@@ -996,6 +1014,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
private void ClearActiveState()
|
private void ClearActiveState()
|
||||||
{
|
{
|
||||||
|
StoredListing[] removedListings = _listings.Values.Select(Snapshot).ToArray();
|
||||||
_listings.Clear();
|
_listings.Clear();
|
||||||
_listingCountsByOwner.Clear();
|
_listingCountsByOwner.Clear();
|
||||||
_listingExpiries.Clear();
|
_listingExpiries.Clear();
|
||||||
@@ -1017,6 +1036,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
_idempotencyExpiries.Clear();
|
_idempotencyExpiries.Clear();
|
||||||
_replay.Clear();
|
_replay.Clear();
|
||||||
_replayExpiries.Clear();
|
_replayExpiries.Clear();
|
||||||
|
foreach (StoredListing listing in removedListings)
|
||||||
|
{
|
||||||
|
_sessionChanges?.Publish(listing, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveListing(SessionListingId listingId)
|
private void RemoveListing(SessionListingId listingId)
|
||||||
@@ -1026,6 +1049,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StoredListing removed = Snapshot(listing);
|
||||||
_leases.Remove(listing.Definition.LeaseId);
|
_leases.Remove(listing.Definition.LeaseId);
|
||||||
DecrementCount(_listingCountsByOwner, listing.Definition.OwnerSubject);
|
DecrementCount(_listingCountsByOwner, listing.Definition.OwnerSubject);
|
||||||
_presenceHandles.Remove(listing.Definition.HostPresenceHandle);
|
_presenceHandles.Remove(listing.Definition.HostPresenceHandle);
|
||||||
@@ -1045,6 +1069,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
RemoveOutcome(attemptId);
|
RemoveOutcome(attemptId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_sessionChanges?.Publish(removed, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveAttempt(JoinAttemptId attemptId)
|
private void RemoveAttempt(JoinAttemptId attemptId)
|
||||||
@@ -1186,6 +1212,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
else if (_presence.Remove(candidate.Key))
|
else if (_presence.Remove(candidate.Key))
|
||||||
{
|
{
|
||||||
|
if (_presenceHandles.TryGetValue(candidate.Key, out SessionListingId listingId)
|
||||||
|
&& _listings.TryGetValue(listingId, out ListingEntry? listing))
|
||||||
|
{
|
||||||
|
StoredListing after = Snapshot(listing);
|
||||||
|
_sessionChanges?.Publish(after with { HasFreshPresence = true }, after);
|
||||||
|
}
|
||||||
removed++;
|
removed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ internal sealed class NatMediationProcessor(
|
|||||||
telemetry?.RecordUdp(
|
telemetry?.RecordUdp(
|
||||||
"frozen",
|
"frozen",
|
||||||
result.ToString(),
|
result.ToString(),
|
||||||
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
Stopwatch.GetElapsedTime(started).TotalMilliseconds,
|
||||||
|
encoded.Length,
|
||||||
|
result == NatMediationResult.Introduced ? 2 * ContractLimits.UdpDatagramMaxBytes : 0);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +159,7 @@ internal sealed class NatMediationProcessor(
|
|||||||
observedPublicEndpoint,
|
observedPublicEndpoint,
|
||||||
token,
|
token,
|
||||||
introductionSink,
|
introductionSink,
|
||||||
cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal NatMediationResult ProcessRequestAfterIngress(
|
internal NatMediationResult ProcessRequestAfterIngress(
|
||||||
@@ -165,6 +167,7 @@ internal sealed class NatMediationProcessor(
|
|||||||
IPEndPoint observedPublicEndpoint,
|
IPEndPoint observedPublicEndpoint,
|
||||||
string token,
|
string token,
|
||||||
INatIntroductionSink introductionSink,
|
INatIntroductionSink introductionSink,
|
||||||
|
int receivedBytes = 0,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
long started = Stopwatch.GetTimestamp();
|
long started = Stopwatch.GetTimestamp();
|
||||||
@@ -178,7 +181,9 @@ internal sealed class NatMediationProcessor(
|
|||||||
telemetry?.RecordUdp(
|
telemetry?.RecordUdp(
|
||||||
"litenet",
|
"litenet",
|
||||||
result.ToString(),
|
result.ToString(),
|
||||||
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
Stopwatch.GetElapsedTime(started).TotalMilliseconds,
|
||||||
|
receivedBytes,
|
||||||
|
result == NatMediationResult.Introduced ? 2 * ContractLimits.UdpDatagramMaxBytes : 0);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ internal sealed class UdpMediatorService : BackgroundService
|
|||||||
&& token is not null)
|
&& token is not null)
|
||||||
{
|
{
|
||||||
_ = processor.ProcessRequestAfterIngress(
|
_ = processor.ProcessRequestAfterIngress(
|
||||||
claimedLocalEndpoint, endPoint, token, sink);
|
claimedLocalEndpoint, endPoint, token, sink, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
|
// Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
{
|
{
|
||||||
TestClientMode.Host => RunHostAsync(options, output, cancellationToken),
|
TestClientMode.Host => RunHostAsync(options, output, cancellationToken),
|
||||||
TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken),
|
TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken),
|
||||||
|
TestClientMode.Watch => RunWatchAsync(options, output, cancellationToken),
|
||||||
TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken),
|
TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken),
|
||||||
_ => Task.FromResult(TestClientExitCode.Usage),
|
_ => Task.FromResult(TestClientExitCode.Usage),
|
||||||
};
|
};
|
||||||
@@ -114,11 +115,12 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
listingId: session.ListingId.ToString(),
|
listingId: session.ListingId.ToString(),
|
||||||
displayName: options.DisplayName);
|
displayName: options.DisplayName);
|
||||||
echo = new DirectEchoProtocol(events.GameplayEvents, host: true);
|
echo = new DirectEchoProtocol(events.GameplayEvents, host: true);
|
||||||
echo.ExchangeCompleted += _ => output.Write(
|
echo.ExchangeCompleted += peer => output.Write(
|
||||||
"host.direct-traffic",
|
"host.direct-traffic",
|
||||||
"verified",
|
"verified",
|
||||||
phase: "direct-traffic",
|
phase: "direct-traffic",
|
||||||
endpointType: "peer-to-peer");
|
endpointType: "peer-to-peer",
|
||||||
|
addressFamily: AddressFamilyName(peer.Address));
|
||||||
coordinator = new RendezvousHostCoordinator(
|
coordinator = new RendezvousHostCoordinator(
|
||||||
manager,
|
manager,
|
||||||
events,
|
events,
|
||||||
@@ -485,6 +487,7 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
"connected",
|
"connected",
|
||||||
phase: "direct-connection",
|
phase: "direct-connection",
|
||||||
endpointType: endpointType,
|
endpointType: endpointType,
|
||||||
|
addressFamily: AddressFamilyName(peer.Address),
|
||||||
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
|
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
|
||||||
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
|
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
|
||||||
echo.BeginJoin(peer);
|
echo.BeginJoin(peer);
|
||||||
@@ -507,7 +510,8 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
"join.direct-traffic",
|
"join.direct-traffic",
|
||||||
"verified",
|
"verified",
|
||||||
phase: "direct-traffic",
|
phase: "direct-traffic",
|
||||||
endpointType: endpointType);
|
endpointType: endpointType,
|
||||||
|
addressFamily: AddressFamilyName(peer.Address));
|
||||||
peer.Disconnect();
|
peer.Disconnect();
|
||||||
manager.PollEvents();
|
manager.PollEvents();
|
||||||
return TestClientExitCode.Success;
|
return TestClientExitCode.Success;
|
||||||
@@ -550,6 +554,178 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<TestClientExitCode> RunWatchAsync(
|
||||||
|
TestClientOptions options,
|
||||||
|
TestClientOutput output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource watch = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
cancellationToken);
|
||||||
|
watch.CancelAfter(options.RunDuration ?? options.OperationTimeout);
|
||||||
|
using HttpClient http = CreateHttpClient(options);
|
||||||
|
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
|
||||||
|
BrowseSessionsRequest request = BrowseRequest(options);
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> snapshot = await browser.BrowseAsync(
|
||||||
|
request,
|
||||||
|
watch.Token).ConfigureAwait(false);
|
||||||
|
if (!snapshot.IsSuccess || snapshot.Value is null)
|
||||||
|
{
|
||||||
|
WriteServiceFailure(output, "watch.snapshot", "directory", snapshot);
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
output.Write(
|
||||||
|
"watch.snapshot",
|
||||||
|
"complete",
|
||||||
|
phase: "directory",
|
||||||
|
count: snapshot.Value.Items.Count);
|
||||||
|
string cursor = options.ExerciseReset
|
||||||
|
? CorruptCursor(snapshot.Value.StreamCursor)
|
||||||
|
: snapshot.Value.StreamCursor;
|
||||||
|
output.Write("watch.stream", "started", phase: "live-directory");
|
||||||
|
SessionStreamEvent? expectedReplay = null;
|
||||||
|
bool reconnectExerciseCompleted = false;
|
||||||
|
int emptyConnections = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
string connectionCursor = cursor;
|
||||||
|
bool receivedEvent = false;
|
||||||
|
bool deliberateReconnect = false;
|
||||||
|
await foreach (RendezvousClientResult<SessionStreamEvent> result in browser
|
||||||
|
.StreamAsync(request, cursor, watch.Token)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
WriteServiceFailure(output, "watch.stream", "live-directory", result);
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
receivedEvent = true;
|
||||||
|
SessionStreamEvent item = result.Value;
|
||||||
|
if (expectedReplay is not null)
|
||||||
|
{
|
||||||
|
if (!SameStreamEvent(expectedReplay, item))
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"watch.reconnect",
|
||||||
|
"failed",
|
||||||
|
"The reconnect did not replay the expected ordered event.",
|
||||||
|
phase: "live-directory");
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
output.Write("watch.reconnect", "verified", phase: "live-directory");
|
||||||
|
expectedReplay = null;
|
||||||
|
reconnectExerciseCompleted = true;
|
||||||
|
cursor = item.Cursor;
|
||||||
|
if (options.Script)
|
||||||
|
{
|
||||||
|
return TestClientExitCode.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cursor = item.Cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (item.Kind)
|
||||||
|
{
|
||||||
|
case SessionStreamEventKind.SessionUpsert when item.Session is not null:
|
||||||
|
output.Write(
|
||||||
|
"watch.session-upsert",
|
||||||
|
"available",
|
||||||
|
phase: "live-directory",
|
||||||
|
listingId: item.Session.ListingId.ToString(),
|
||||||
|
displayName: item.Session.DisplayName);
|
||||||
|
break;
|
||||||
|
case SessionStreamEventKind.SessionRemove when item.ListingId.HasValue:
|
||||||
|
output.Write(
|
||||||
|
"watch.session-remove",
|
||||||
|
"removed",
|
||||||
|
phase: "live-directory",
|
||||||
|
listingId: item.ListingId.Value.ToString());
|
||||||
|
break;
|
||||||
|
case SessionStreamEventKind.Reset:
|
||||||
|
output.Write("watch.reset", "required", phase: "live-directory");
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> refreshed = await browser.BrowseAsync(
|
||||||
|
request,
|
||||||
|
watch.Token).ConfigureAwait(false);
|
||||||
|
if (!refreshed.IsSuccess || refreshed.Value is null)
|
||||||
|
{
|
||||||
|
WriteServiceFailure(output, "watch.snapshot", "directory", refreshed);
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
output.Write(
|
||||||
|
"watch.snapshot",
|
||||||
|
"refreshed",
|
||||||
|
phase: "directory",
|
||||||
|
count: refreshed.Value.Items.Count);
|
||||||
|
return TestClientExitCode.Success;
|
||||||
|
case SessionStreamEventKind.Keepalive:
|
||||||
|
output.Write("watch.keepalive", "alive", phase: "live-directory");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool listingDelta = item.Kind is SessionStreamEventKind.SessionUpsert
|
||||||
|
or SessionStreamEventKind.SessionRemove;
|
||||||
|
if (options.ExerciseReconnect
|
||||||
|
&& !reconnectExerciseCompleted
|
||||||
|
&& listingDelta
|
||||||
|
&& expectedReplay is null)
|
||||||
|
{
|
||||||
|
expectedReplay = item;
|
||||||
|
cursor = connectionCursor;
|
||||||
|
deliberateReconnect = true;
|
||||||
|
output.Write("watch.reconnect", "started", phase: "live-directory");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (options.Script && listingDelta)
|
||||||
|
{
|
||||||
|
return TestClientExitCode.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliberateReconnect)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emptyConnections = receivedEvent ? 0 : emptyConnections + 1;
|
||||||
|
if (emptyConnections >= 3)
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"watch.reconnect",
|
||||||
|
"failed",
|
||||||
|
"The stream closed repeatedly without an event; use bounded polling fallback.",
|
||||||
|
phase: "live-directory");
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
output.Write("watch.reconnect", "required", phase: "live-directory");
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(250), watch.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
output.Write("watch.complete", "complete", phase: "lifecycle");
|
||||||
|
return TestClientExitCode.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameStreamEvent(SessionStreamEvent expected, SessionStreamEvent actual) =>
|
||||||
|
expected.Kind == actual.Kind
|
||||||
|
&& string.Equals(expected.Cursor, actual.Cursor, StringComparison.Ordinal)
|
||||||
|
&& expected.ListingId == actual.ListingId
|
||||||
|
&& expected.Session?.ListingId == actual.Session?.ListingId;
|
||||||
|
|
||||||
|
private static string CorruptCursor(string cursor)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(cursor))
|
||||||
|
{
|
||||||
|
return "invalid-stream-cursor";
|
||||||
|
}
|
||||||
|
char replacement = cursor[^1] == 'a' ? 'b' : 'a';
|
||||||
|
return cursor[..^1] + replacement;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<SessionSelection> SelectListingAsync(
|
private static async Task<SessionSelection> SelectListingAsync(
|
||||||
TestClientOptions options,
|
TestClientOptions options,
|
||||||
TestClientOutput output,
|
TestClientOutput output,
|
||||||
@@ -765,6 +941,9 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
|||||||
return privateAddress ? "private" : "public";
|
return privateAddress ? "private" : "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string AddressFamilyName(IPAddress address) =>
|
||||||
|
address.AddressFamily == AddressFamily.InterNetworkV6 ? "ipv6" : "ipv4";
|
||||||
|
|
||||||
private static long ToMilliseconds(TimeSpan elapsed) =>
|
private static long ToMilliseconds(TimeSpan elapsed) =>
|
||||||
(long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds));
|
(long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds));
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ internal enum TestClientMode
|
|||||||
{
|
{
|
||||||
Host,
|
Host,
|
||||||
Browse,
|
Browse,
|
||||||
|
Watch,
|
||||||
Join,
|
Join,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ internal sealed class TestClientOptions
|
|||||||
internal bool Script { get; init; }
|
internal bool Script { get; init; }
|
||||||
internal bool Json { get; init; }
|
internal bool Json { get; init; }
|
||||||
internal bool ExitAfterEcho { get; init; }
|
internal bool ExitAfterEcho { get; init; }
|
||||||
|
internal bool ExerciseReconnect { get; init; }
|
||||||
|
internal bool ExerciseReset { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class TestClientParseResult
|
internal sealed class TestClientParseResult
|
||||||
@@ -63,6 +66,7 @@ internal static class TestClientOptionParser
|
|||||||
Usage:
|
Usage:
|
||||||
rendezvous-test-client host [options]
|
rendezvous-test-client host [options]
|
||||||
rendezvous-test-client browse [options]
|
rendezvous-test-client browse [options]
|
||||||
|
rendezvous-test-client watch [options]
|
||||||
rendezvous-test-client join [options]
|
rendezvous-test-client join [options]
|
||||||
|
|
||||||
Common options:
|
Common options:
|
||||||
@@ -88,6 +92,11 @@ internal static class TestClientOptionParser
|
|||||||
--run-seconds NUMBER Stop after 1-86400 seconds
|
--run-seconds NUMBER Stop after 1-86400 seconds
|
||||||
--exit-after-echo Stop after an authenticated ping/echo/ack exchange
|
--exit-after-echo Stop after an authenticated ping/echo/ack exchange
|
||||||
|
|
||||||
|
Watch options:
|
||||||
|
--run-seconds NUMBER Stop after 1-86400 seconds
|
||||||
|
--exercise-reconnect Disconnect after an update and verify ordered replay
|
||||||
|
--exercise-reset Corrupt the snapshot cursor and verify reset/refresh
|
||||||
|
|
||||||
Join options:
|
Join options:
|
||||||
--listing UUID Join an exact listing; otherwise browse/select
|
--listing UUID Join an exact listing; otherwise browse/select
|
||||||
|
|
||||||
@@ -129,6 +138,8 @@ internal static class TestClientOptionParser
|
|||||||
bool script = false;
|
bool script = false;
|
||||||
bool json = false;
|
bool json = false;
|
||||||
bool exitAfterEcho = false;
|
bool exitAfterEcho = false;
|
||||||
|
bool exerciseReconnect = false;
|
||||||
|
bool exerciseReset = false;
|
||||||
HashSet<string> seen = new(StringComparer.Ordinal);
|
HashSet<string> seen = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
for (int index = 1; index < args.Length; index++)
|
for (int index = 1; index < args.Length; index++)
|
||||||
@@ -138,7 +149,8 @@ internal static class TestClientOptionParser
|
|||||||
{
|
{
|
||||||
return TestClientParseResult.Help();
|
return TestClientParseResult.Help();
|
||||||
}
|
}
|
||||||
if (option is "--script" or "--json" or "--exit-after-echo")
|
if (option is "--script" or "--json" or "--exit-after-echo"
|
||||||
|
or "--exercise-reconnect" or "--exercise-reset")
|
||||||
{
|
{
|
||||||
if (!seen.Add(option))
|
if (!seen.Add(option))
|
||||||
{
|
{
|
||||||
@@ -147,6 +159,8 @@ internal static class TestClientOptionParser
|
|||||||
script |= option == "--script";
|
script |= option == "--script";
|
||||||
json |= option == "--json";
|
json |= option == "--json";
|
||||||
exitAfterEcho |= option == "--exit-after-echo";
|
exitAfterEcho |= option == "--exit-after-echo";
|
||||||
|
exerciseReconnect |= option == "--exercise-reconnect";
|
||||||
|
exerciseReset |= option == "--exercise-reset";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!option.StartsWith("--", StringComparison.Ordinal)
|
if (!option.StartsWith("--", StringComparison.Ordinal)
|
||||||
@@ -279,8 +293,11 @@ internal static class TestClientOptionParser
|
|||||||
return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
|
return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
|
||||||
}
|
}
|
||||||
if (listingId.HasValue && mode != TestClientMode.Join
|
if (listingId.HasValue && mode != TestClientMode.Join
|
||||||
|| runSeconds.HasValue && mode != TestClientMode.Host
|
|| runSeconds.HasValue && mode is not (TestClientMode.Host or TestClientMode.Watch)
|
||||||
|| exitAfterEcho && mode != TestClientMode.Host
|
|| exitAfterEcho && mode != TestClientMode.Host
|
||||||
|
|| exerciseReconnect && mode != TestClientMode.Watch
|
||||||
|
|| exerciseReset && mode != TestClientMode.Watch
|
||||||
|
|| exerciseReconnect && exerciseReset
|
||||||
|| metadata.Count > 0 && mode != TestClientMode.Host
|
|| metadata.Count > 0 && mode != TestClientMode.Host
|
||||||
|| dedicatedFallback is not null && mode != TestClientMode.Host
|
|| dedicatedFallback is not null && mode != TestClientMode.Host
|
||||||
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
|
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
|
||||||
@@ -316,6 +333,8 @@ internal static class TestClientOptionParser
|
|||||||
Script = script,
|
Script = script,
|
||||||
Json = json,
|
Json = json,
|
||||||
ExitAfterEcho = exitAfterEcho,
|
ExitAfterEcho = exitAfterEcho,
|
||||||
|
ExerciseReconnect = exerciseReconnect,
|
||||||
|
ExerciseReset = exerciseReset,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
|||||||
string? displayName = null,
|
string? displayName = null,
|
||||||
string? outcome = null,
|
string? outcome = null,
|
||||||
string? endpointType = null,
|
string? endpointType = null,
|
||||||
|
string? addressFamily = null,
|
||||||
int? count = null,
|
int? count = null,
|
||||||
long? elapsedMilliseconds = null,
|
long? elapsedMilliseconds = null,
|
||||||
string? message = null) => WriteCore(
|
string? message = null) => WriteCore(
|
||||||
@@ -37,6 +38,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
|||||||
DisplayName = SafeText(displayName),
|
DisplayName = SafeText(displayName),
|
||||||
Outcome = SafeToken(outcome),
|
Outcome = SafeToken(outcome),
|
||||||
EndpointType = SafeToken(endpointType),
|
EndpointType = SafeToken(endpointType),
|
||||||
|
AddressFamily = SafeToken(addressFamily),
|
||||||
Count = count,
|
Count = count,
|
||||||
ElapsedMilliseconds = elapsedMilliseconds,
|
ElapsedMilliseconds = elapsedMilliseconds,
|
||||||
Message = SafeText(message),
|
Message = SafeText(message),
|
||||||
@@ -92,6 +94,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
|||||||
Append(line, "name", item.DisplayName, quote: true);
|
Append(line, "name", item.DisplayName, quote: true);
|
||||||
Append(line, "outcome", item.Outcome);
|
Append(line, "outcome", item.Outcome);
|
||||||
Append(line, "endpoint", item.EndpointType);
|
Append(line, "endpoint", item.EndpointType);
|
||||||
|
Append(line, "addressFamily", item.AddressFamily);
|
||||||
if (item.Count.HasValue)
|
if (item.Count.HasValue)
|
||||||
{
|
{
|
||||||
Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
@@ -174,6 +177,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
|||||||
public string? DisplayName { get; init; }
|
public string? DisplayName { get; init; }
|
||||||
public string? Outcome { get; init; }
|
public string? Outcome { get; init; }
|
||||||
public string? EndpointType { get; init; }
|
public string? EndpointType { get; init; }
|
||||||
|
public string? AddressFamily { get; init; }
|
||||||
public int? Count { get; init; }
|
public int? Count { get; init; }
|
||||||
public long? ElapsedMilliseconds { get; init; }
|
public long? ElapsedMilliseconds { get; init; }
|
||||||
public string? Message { get; init; }
|
public string? Message { get; init; }
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
SessionProjection,
|
||||||
|
buildBrowseUrl,
|
||||||
|
buildStreamUrl,
|
||||||
|
chooseSnapshotTransport,
|
||||||
|
safeText,
|
||||||
|
} from "../../src/FinalFactory.Rendezvous.Server/Diagnostics/Assets/app.mjs";
|
||||||
|
|
||||||
|
const cursor = "rvs1.test-cursor";
|
||||||
|
|
||||||
|
function session(id, name = `Host ${id}`) {
|
||||||
|
return {
|
||||||
|
contractVersion: 1,
|
||||||
|
listingId: `00000000-0000-0000-0000-${String(id).padStart(12, "0")}`,
|
||||||
|
gameId: "space-game",
|
||||||
|
environmentId: "smoke",
|
||||||
|
regionId: "local",
|
||||||
|
protocolVersion: 1,
|
||||||
|
buildVersion: "1.0.0",
|
||||||
|
displayName: name,
|
||||||
|
visibility: "public",
|
||||||
|
publisherTrustMode: "managedDedicated",
|
||||||
|
capacity: { currentPlayers: 1, maximumPlayers: 8 },
|
||||||
|
metadata: { mode: "online-coop" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function event(kind, values = {}) {
|
||||||
|
return {
|
||||||
|
contractVersion: 1,
|
||||||
|
kind,
|
||||||
|
cursor: values.cursor ?? cursor,
|
||||||
|
session: values.session ?? null,
|
||||||
|
listingId: values.listingId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("snapshot and ordered deltas match the final public projection", () => {
|
||||||
|
const projection = new SessionProjection(10);
|
||||||
|
assert.equal(projection.replace([session(1)], cursor), "applied");
|
||||||
|
assert.equal(projection.apply(event("sessionUpsert", {
|
||||||
|
cursor: `${cursor}-2`,
|
||||||
|
session: session(2),
|
||||||
|
})), "applied");
|
||||||
|
assert.equal(projection.apply(event("sessionUpsert", {
|
||||||
|
cursor: `${cursor}-3`,
|
||||||
|
session: session(1, "Updated host"),
|
||||||
|
})), "applied");
|
||||||
|
assert.equal(projection.apply(event("sessionRemove", {
|
||||||
|
cursor: `${cursor}-4`,
|
||||||
|
listingId: session(2).listingId,
|
||||||
|
})), "applied");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
projection.sessions().map((entry) => entry.session.displayName),
|
||||||
|
["Updated host"],
|
||||||
|
);
|
||||||
|
assert.equal(projection.cursor, `${cursor}-4`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reset, malformed events, and a partial snapshot fail closed", () => {
|
||||||
|
const projection = new SessionProjection(2);
|
||||||
|
assert.equal(projection.replace([session(1)], cursor, true), "applied");
|
||||||
|
assert.equal(projection.apply(event("sessionRemove", {
|
||||||
|
listingId: session(1).listingId,
|
||||||
|
})), "refresh");
|
||||||
|
assert.equal(projection.apply(event("reset")), "reset");
|
||||||
|
assert.equal(projection.apply({ kind: "sessionUpsert" }), "invalid");
|
||||||
|
assert.equal(projection.replace([session(1), session(2), session(3)], cursor), "overflow");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bursts coalesce by listing identity and memory stays bounded", () => {
|
||||||
|
const projection = new SessionProjection(2);
|
||||||
|
assert.equal(projection.replace([], cursor), "applied");
|
||||||
|
for (let index = 0; index < 1_000; index += 1) {
|
||||||
|
assert.equal(projection.apply(event("sessionUpsert", {
|
||||||
|
cursor: `${cursor}-${index}`,
|
||||||
|
session: session(1, `Host ${index}`),
|
||||||
|
})), "applied");
|
||||||
|
}
|
||||||
|
assert.equal(projection.sessions().length, 1);
|
||||||
|
assert.equal(projection.sessions()[0].session.displayName, "Host 999");
|
||||||
|
assert.equal(projection.apply(event("sessionUpsert", { session: session(2) })), "applied");
|
||||||
|
assert.equal(projection.apply(event("sessionUpsert", { session: session(3) })), "overflow");
|
||||||
|
assert.equal(projection.sessions().length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requests are fixed same-origin paths with an exact configured filter", () => {
|
||||||
|
const filter = {
|
||||||
|
gameId: "space-game",
|
||||||
|
environmentId: "smoke",
|
||||||
|
protocolVersion: 1,
|
||||||
|
regionId: "local",
|
||||||
|
excludeFull: true,
|
||||||
|
};
|
||||||
|
const browse = buildBrowseUrl(filter);
|
||||||
|
const stream = buildStreamUrl(filter, cursor);
|
||||||
|
assert.match(browse, /^\/v1\/sessions\?/u);
|
||||||
|
assert.match(stream, /^\/v1\/sessions\/stream\?/u);
|
||||||
|
assert.match(browse, /gameId=space-game/u);
|
||||||
|
assert.match(browse, /environmentId=smoke/u);
|
||||||
|
assert.match(stream, /streamCursor=rvs1.test-cursor/u);
|
||||||
|
assert.doesNotMatch(browse, /https?:/u);
|
||||||
|
assert.doesNotMatch(stream, /https?:/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("snapshot transport preserves deliberate polling and bounds partial snapshots", () => {
|
||||||
|
assert.equal(chooseSnapshotTransport(false, false), "stream");
|
||||||
|
assert.equal(chooseSnapshotTransport(true, false), "boundedPolling");
|
||||||
|
assert.equal(chooseSnapshotTransport(false, true), "pollingOnly");
|
||||||
|
assert.equal(chooseSnapshotTransport(true, true), "pollingOnly");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hostile display data remains literal text and no unsafe DOM sink exists", async () => {
|
||||||
|
const payload = `<img src=x onerror=alert(1)><style>body{display:none}</style><a href=//evil>go</a>`;
|
||||||
|
assert.equal(safeText(payload, 200), payload);
|
||||||
|
const source = await readFile(new URL(
|
||||||
|
"../../src/FinalFactory.Rendezvous.Server/Diagnostics/Assets/app.mjs",
|
||||||
|
import.meta.url,
|
||||||
|
), "utf8");
|
||||||
|
for (const forbidden of [
|
||||||
|
"innerHTML",
|
||||||
|
"outerHTML",
|
||||||
|
"insertAdjacentHTML",
|
||||||
|
"document.write",
|
||||||
|
"eval(",
|
||||||
|
"new Function",
|
||||||
|
"window.location",
|
||||||
|
]) {
|
||||||
|
assert.equal(source.includes(forbidden), false, `unsafe browser sink: ${forbidden}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -7,18 +7,25 @@ namespace FinalFactory.Rendezvous.Tests.Browser;
|
|||||||
|
|
||||||
internal sealed class SessionBrowserFixture : IDisposable
|
internal sealed class SessionBrowserFixture : IDisposable
|
||||||
{
|
{
|
||||||
private readonly EphemeralStateFixture _state = new();
|
private readonly EphemeralStateFixture _state;
|
||||||
|
|
||||||
public SessionBrowserFixture()
|
public SessionBrowserFixture()
|
||||||
{
|
{
|
||||||
|
Changes = new(new SessionChangeJournalOptions());
|
||||||
|
_state = new(changes: Changes);
|
||||||
Cursors = new();
|
Cursors = new();
|
||||||
Browser = new(_state.Store, Cursors, _state.Clock);
|
StreamCursors = new();
|
||||||
|
Browser = new(_state.Store, Cursors, StreamCursors, Changes, _state.Clock);
|
||||||
|
Streams = new(Changes, StreamCursors, _state.Clock);
|
||||||
}
|
}
|
||||||
|
|
||||||
public InMemoryEphemeralRendezvousStore Store => _state.Store;
|
public InMemoryEphemeralRendezvousStore Store => _state.Store;
|
||||||
public ManualRendezvousClock Clock => _state.Clock;
|
public ManualRendezvousClock Clock => _state.Clock;
|
||||||
public SessionBrowserCursorCodec Cursors { get; }
|
public SessionBrowserCursorCodec Cursors { get; }
|
||||||
|
public SessionStreamCursorCodec StreamCursors { get; }
|
||||||
|
public SessionChangeJournal Changes { get; }
|
||||||
public SessionBrowserService Browser { get; }
|
public SessionBrowserService Browser { get; }
|
||||||
|
public SessionStreamService Streams { get; }
|
||||||
public TenantScope Scope => _state.Scope;
|
public TenantScope Scope => _state.Scope;
|
||||||
|
|
||||||
public StoredListing Add(
|
public StoredListing Add(
|
||||||
@@ -67,5 +74,9 @@ internal sealed class SessionBrowserFixture : IDisposable
|
|||||||
PageSize = pageSize,
|
PageSize = pageSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
public void Dispose() => Cursors.Dispose();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Cursors.Dispose();
|
||||||
|
StreamCursors.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||||
|
|
||||||
|
public sealed class SessionStreamServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SnapshotPlusUpdateMatchesFreshProjection()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.Add();
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(
|
||||||
|
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||||
|
|
||||||
|
StoreResult<StoredListing> updated = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
displayName: "Updated host",
|
||||||
|
currentPlayers: 4));
|
||||||
|
Assert.True(updated.Succeeded);
|
||||||
|
SessionStreamEvent delta = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, delta.Kind);
|
||||||
|
Assert.Equal("Updated host", delta.Session!.DisplayName);
|
||||||
|
Assert.Equal(4, delta.Session.Capacity.CurrentPlayers);
|
||||||
|
|
||||||
|
BrowseSessionsResponse fresh = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
SessionListing expected = Assert.Single(fresh.Items);
|
||||||
|
Assert.Equal(expected.DisplayName, delta.Session.DisplayName);
|
||||||
|
Assert.Equal(expected.Capacity.CurrentPlayers, delta.Session.Capacity.CurrentPlayers);
|
||||||
|
Assert.DoesNotContain("lease", System.Text.Json.JsonSerializer.Serialize(delta, ContractJson.Options), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresenceStalenessRecoveryAndRevocationProduceRemoveUpsertRemove()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.Add();
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(
|
||||||
|
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||||
|
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(21));
|
||||||
|
AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
SessionStreamEvent stale = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, stale.Kind);
|
||||||
|
Assert.Equal(listing.Definition.ListingId, stale.ListingId);
|
||||||
|
|
||||||
|
StoreResult<StoredListing> rebound = fixture.Store.BindHostPresence(new(
|
||||||
|
listing.Definition.HostPresenceHandle,
|
||||||
|
listing.Definition.HostPresenceFingerprint,
|
||||||
|
new ObservedEndpoint(AddressFamilyKind.Ipv4, "203.0.113.20", 40_020),
|
||||||
|
null));
|
||||||
|
Assert.True(rebound.Succeeded);
|
||||||
|
Assert.Equal(
|
||||||
|
SessionStreamEventKind.SessionUpsert,
|
||||||
|
Assert.Single(fixture.Streams.Read(subscription).Events).Kind);
|
||||||
|
|
||||||
|
Assert.True(fixture.Store.RevokeListing(listing.Definition.ListingId).Succeeded);
|
||||||
|
Assert.Equal(
|
||||||
|
SessionStreamEventKind.SessionRemove,
|
||||||
|
Assert.Single(fixture.Streams.Read(subscription).Events).Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreationAndLeaseExpiryProduceUpsertThenRemove()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(
|
||||||
|
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||||
|
|
||||||
|
StoredListing listing = fixture.Add();
|
||||||
|
SessionStreamEvent created = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, created.Kind);
|
||||||
|
Assert.Equal(listing.Definition.ListingId, created.Session!.ListingId);
|
||||||
|
|
||||||
|
for (int refresh = 0; refresh < 3; refresh++)
|
||||||
|
{
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(19));
|
||||||
|
Assert.True(fixture.Store.BindHostPresence(new(
|
||||||
|
listing.Definition.HostPresenceHandle,
|
||||||
|
listing.Definition.HostPresenceFingerprint,
|
||||||
|
new ObservedEndpoint(AddressFamilyKind.Ipv4, "203.0.113.20", 40_020),
|
||||||
|
null)).Succeeded);
|
||||||
|
}
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(4));
|
||||||
|
AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
|
||||||
|
SessionStreamEvent expired = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, expired.Kind);
|
||||||
|
Assert.Equal(listing.Definition.ListingId, expired.ListingId);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.NotFound,
|
||||||
|
fixture.Store.GetListing(listing.Definition.ListingId, requireFreshPresence: false).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScopeProtocolRegionAndFullFiltersNeverLeak()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
request.ExcludeFull = true;
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(
|
||||||
|
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||||
|
|
||||||
|
fixture.Add(scope: new(new("other-game"), fixture.Scope.EnvironmentId));
|
||||||
|
fixture.Add(protocolVersion: 99);
|
||||||
|
fixture.Add(regionId: new("other-region"));
|
||||||
|
fixture.Add(currentPlayers: 8, maximumPlayers: 8);
|
||||||
|
Assert.Empty(fixture.Streams.Read(subscription).Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VisibilityCompatibilityAndRegionChangesEnterAndLeaveTheFilter()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.Add();
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(
|
||||||
|
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||||
|
|
||||||
|
listing = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
listing.Definition.DisplayName,
|
||||||
|
1,
|
||||||
|
visibility: ListingVisibility.Unlisted)).Value!;
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
|
||||||
|
listing = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
listing.Definition.DisplayName,
|
||||||
|
1,
|
||||||
|
visibility: ListingVisibility.Public)).Value!;
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, SingleKind(fixture, subscription));
|
||||||
|
listing = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
listing.Definition.DisplayName,
|
||||||
|
1,
|
||||||
|
protocolVersion: 99)).Value!;
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
|
||||||
|
listing = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
listing.Definition.DisplayName,
|
||||||
|
1,
|
||||||
|
protocolVersion: 7)).Value!;
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, SingleKind(fixture, subscription));
|
||||||
|
listing = fixture.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
listing.Definition.DisplayName,
|
||||||
|
1,
|
||||||
|
regionId: new RegionId("other-region"))).Value!;
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReplayGapAndForeignCursorForceResetAndSubscriberLimitFailsClosed()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new();
|
||||||
|
SessionChangeJournal changes = new(new SessionChangeJournalOptions
|
||||||
|
{
|
||||||
|
ReplayCapacity = 64,
|
||||||
|
MaximumSubscribers = 1,
|
||||||
|
MaximumSubscribersPerTenant = 1,
|
||||||
|
});
|
||||||
|
using SessionStreamCursorCodec cursors = new();
|
||||||
|
SessionStreamService streams = new(changes, cursors, clock);
|
||||||
|
EphemeralStateFixture state = new(changes: changes);
|
||||||
|
StoredListing listing = state.CreateVisibleListing(out _);
|
||||||
|
BrowseSessionsRequest request = new()
|
||||||
|
{
|
||||||
|
GameId = state.Scope.GameId,
|
||||||
|
EnvironmentId = state.Scope.EnvironmentId,
|
||||||
|
ProtocolVersion = listing.Definition.ProtocolVersion,
|
||||||
|
};
|
||||||
|
VisibleListingQuery query = new(state.Scope, listing.Definition.ProtocolVersion, null);
|
||||||
|
string initial = cursors.Encode(query, changes.CurrentRevision, clock.UtcNow);
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(streams.Subscribe(request, initial));
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
streams.Subscribe(request, initial).Error);
|
||||||
|
|
||||||
|
for (int index = 0; index < 65; index++)
|
||||||
|
{
|
||||||
|
listing = state.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
displayName: $"Host {index}",
|
||||||
|
currentPlayers: index % 8)).Value!;
|
||||||
|
}
|
||||||
|
Assert.True(streams.Read(subscription).RequiresReset);
|
||||||
|
subscription.Dispose();
|
||||||
|
|
||||||
|
using SessionStreamSubscription foreign = AssertSuccess(streams.Subscribe(
|
||||||
|
request,
|
||||||
|
"not-a-valid-cursor"));
|
||||||
|
Assert.True(streams.Read(foreign).RequiresReset);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BurstIsBoundedAndCoalescedWithoutLosingFinalState()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new();
|
||||||
|
SessionChangeJournal changes = new(new SessionChangeJournalOptions
|
||||||
|
{
|
||||||
|
ReplayCapacity = 1024,
|
||||||
|
MaximumBatchSize = 128,
|
||||||
|
});
|
||||||
|
using SessionStreamCursorCodec cursors = new();
|
||||||
|
SessionStreamService streams = new(changes, cursors, clock);
|
||||||
|
EphemeralStateFixture state = new(changes: changes);
|
||||||
|
StoredListing listing = state.CreateVisibleListing(out _);
|
||||||
|
BrowseSessionsRequest request = new()
|
||||||
|
{
|
||||||
|
GameId = state.Scope.GameId,
|
||||||
|
EnvironmentId = state.Scope.EnvironmentId,
|
||||||
|
ProtocolVersion = listing.Definition.ProtocolVersion,
|
||||||
|
};
|
||||||
|
VisibleListingQuery query = new(state.Scope, listing.Definition.ProtocolVersion, null);
|
||||||
|
using SessionStreamSubscription subscription = AssertSuccess(streams.Subscribe(
|
||||||
|
request,
|
||||||
|
cursors.Encode(query, changes.CurrentRevision, clock.UtcNow)));
|
||||||
|
|
||||||
|
for (int index = 0; index < 1000; index++)
|
||||||
|
{
|
||||||
|
listing = state.Store.UpdateListing(Update(
|
||||||
|
listing,
|
||||||
|
displayName: $"Host {index}",
|
||||||
|
currentPlayers: index % 8)).Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SessionStreamEvent> emitted = [];
|
||||||
|
while (subscription.Revision < changes.CurrentRevision)
|
||||||
|
{
|
||||||
|
SessionStreamReadResult read = streams.Read(subscription);
|
||||||
|
Assert.False(read.RequiresReset);
|
||||||
|
emitted.AddRange(read.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(8, emitted.Count);
|
||||||
|
SessionStreamEvent final = emitted[^1];
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, final.Kind);
|
||||||
|
Assert.Equal("Host 999", final.Session!.DisplayName);
|
||||||
|
Assert.Equal(7, final.Session.Capacity.CurrentPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubscriberLimitIsEnforcedPerTenantAndReleasedOnDispose()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new();
|
||||||
|
SessionChangeJournal changes = new(new SessionChangeJournalOptions
|
||||||
|
{
|
||||||
|
MaximumSubscribers = 2,
|
||||||
|
MaximumSubscribersPerTenant = 1,
|
||||||
|
});
|
||||||
|
using SessionStreamCursorCodec cursors = new();
|
||||||
|
SessionStreamService streams = new(changes, cursors, clock);
|
||||||
|
TenantScope firstScope = new(new("first-game"), new("production"));
|
||||||
|
TenantScope secondScope = new(new("second-game"), new("production"));
|
||||||
|
BrowseSessionsRequest firstRequest = Request(firstScope);
|
||||||
|
BrowseSessionsRequest secondRequest = Request(secondScope);
|
||||||
|
string firstCursor = cursors.Encode(
|
||||||
|
new VisibleListingQuery(firstScope, 7, null),
|
||||||
|
changes.CurrentRevision,
|
||||||
|
clock.UtcNow);
|
||||||
|
string secondCursor = cursors.Encode(
|
||||||
|
new VisibleListingQuery(secondScope, 7, null),
|
||||||
|
changes.CurrentRevision,
|
||||||
|
clock.UtcNow);
|
||||||
|
|
||||||
|
SessionStreamSubscription first = AssertSuccess(streams.Subscribe(firstRequest, firstCursor));
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
streams.Subscribe(firstRequest, firstCursor).Error);
|
||||||
|
using SessionStreamSubscription second = AssertSuccess(
|
||||||
|
streams.Subscribe(secondRequest, secondCursor));
|
||||||
|
first.Dispose();
|
||||||
|
using SessionStreamSubscription replacement = AssertSuccess(
|
||||||
|
streams.Subscribe(firstRequest, firstCursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BrowseSessionsRequest Request(TenantScope scope) => new()
|
||||||
|
{
|
||||||
|
GameId = scope.GameId,
|
||||||
|
EnvironmentId = scope.EnvironmentId,
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static UpdateListingCommand Update(
|
||||||
|
StoredListing listing,
|
||||||
|
string displayName,
|
||||||
|
int currentPlayers,
|
||||||
|
RegionId? regionId = null,
|
||||||
|
uint? protocolVersion = null,
|
||||||
|
ListingVisibility? visibility = null) => new(
|
||||||
|
listing.Definition.ListingId,
|
||||||
|
listing.Definition.LeaseId,
|
||||||
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
|
listing.Definition.BuildVersion,
|
||||||
|
displayName,
|
||||||
|
currentPlayers,
|
||||||
|
listing.Definition.MaximumPlayers,
|
||||||
|
listing.Definition.Metadata,
|
||||||
|
listing.Definition.DedicatedFallback,
|
||||||
|
regionId,
|
||||||
|
protocolVersion,
|
||||||
|
visibility);
|
||||||
|
|
||||||
|
private static SessionStreamEventKind SingleKind(
|
||||||
|
SessionBrowserFixture fixture,
|
||||||
|
SessionStreamSubscription subscription) =>
|
||||||
|
Assert.Single(fixture.Streams.Read(subscription).Events).Kind;
|
||||||
|
|
||||||
|
private static T AssertSuccess<T>(BrowserServiceResult<T> result)
|
||||||
|
{
|
||||||
|
Assert.True(result.Succeeded, result.Error.ToString());
|
||||||
|
return Assert.IsType<T>(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,6 +167,88 @@ public sealed class RendezvousClientBehaviorTests
|
|||||||
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamRejectsMalformedAndOversizedEventEnvelopes()
|
||||||
|
{
|
||||||
|
string[] bodies =
|
||||||
|
[
|
||||||
|
"event: session_upsert\nid: valid-cursor\ndata: {}\n\n",
|
||||||
|
"data: " + new string('x', ContractLimits.SessionStreamEventMaxBytes + 1) + "\n\n",
|
||||||
|
];
|
||||||
|
foreach (string body in bodies)
|
||||||
|
{
|
||||||
|
StringContent content = new(body, Encoding.UTF8, "text/event-stream");
|
||||||
|
ScriptedHandler handler = new(Response(HttpStatusCode.OK, content));
|
||||||
|
using HttpClient httpClient = new(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new("http://rendezvous.test/"),
|
||||||
|
};
|
||||||
|
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||||
|
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||||
|
.StreamAsync(new BrowseSessionsRequest
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
}, "valid-stream-cursor")
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
Assert.True(await events.MoveNextAsync());
|
||||||
|
Assert.False(events.Current.IsSuccess);
|
||||||
|
Assert.Equal(RendezvousErrorCode.InternalError, events.Current.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamRequiresANonEmptySnapshotCursor()
|
||||||
|
{
|
||||||
|
using HttpClient httpClient = new(new ScriptedHandler())
|
||||||
|
{
|
||||||
|
BaseAddress = new("http://rendezvous.test/"),
|
||||||
|
};
|
||||||
|
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||||
|
{
|
||||||
|
await foreach (RendezvousClientResult<SessionStreamEvent> _ in browser.StreamAsync(
|
||||||
|
new BrowseSessionsRequest
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
},
|
||||||
|
string.Empty))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamOpeningIsBoundedByTheConfiguredRequestTimeout()
|
||||||
|
{
|
||||||
|
using HttpClient httpClient = new(new SilentHandler())
|
||||||
|
{
|
||||||
|
BaseAddress = new("http://rendezvous.test/"),
|
||||||
|
};
|
||||||
|
RendezvousSessionBrowserClient browser = new(
|
||||||
|
httpClient,
|
||||||
|
new RendezvousClientOptions
|
||||||
|
{
|
||||||
|
MaximumSafeRetries = 0,
|
||||||
|
RequestTimeout = TimeSpan.FromMilliseconds(20),
|
||||||
|
});
|
||||||
|
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||||
|
.StreamAsync(new BrowseSessionsRequest
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
}, "valid-stream-cursor")
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
Assert.True(await events.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)));
|
||||||
|
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, events.Current.Error);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LeaseMaintainerReportsLeaseLoss()
|
public async Task LeaseMaintainerReportsLeaseLoss()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -142,6 +142,82 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BrowserStreamResetsInvalidCursorReplaysReconnectAndReleasesConnections()
|
||||||
|
{
|
||||||
|
await using ClientTestHost host = await ClientTestHost.StartAsync();
|
||||||
|
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||||
|
RendezvousSessionBrowserClient browser = new(
|
||||||
|
host.HttpClient,
|
||||||
|
new RendezvousClientOptions
|
||||||
|
{
|
||||||
|
RequestTimeout = TimeSpan.FromSeconds(15),
|
||||||
|
});
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(200),
|
||||||
|
host.PublisherCredential));
|
||||||
|
BindPresence(host, session, 41_200);
|
||||||
|
BrowseSessionsRequest request = BrowseRequest();
|
||||||
|
BrowseSessionsResponse snapshot = AssertSuccess(await browser.BrowseAsync(request));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(snapshot.StreamCursor));
|
||||||
|
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30));
|
||||||
|
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> invalid = browser
|
||||||
|
.StreamAsync(request, CorruptCursor(snapshot.StreamCursor), timeout.Token)
|
||||||
|
.GetAsyncEnumerator(timeout.Token))
|
||||||
|
{
|
||||||
|
Assert.True(await invalid.MoveNextAsync());
|
||||||
|
Assert.Equal(SessionStreamEventKind.Reset, AssertSuccess(invalid.Current).Kind);
|
||||||
|
Assert.False(await invalid.MoveNextAsync());
|
||||||
|
}
|
||||||
|
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||||
|
.StreamAsync(request, snapshot.StreamCursor, timeout.Token)
|
||||||
|
.GetAsyncEnumerator(timeout.Token);
|
||||||
|
Task<bool> upsertPending = events.MoveNextAsync().AsTask();
|
||||||
|
Assert.True((await publisher.UpdateAsync(
|
||||||
|
session,
|
||||||
|
new UpdateSessionRequest
|
||||||
|
{
|
||||||
|
BuildVersion = "2.0.0",
|
||||||
|
DisplayName = "Live update",
|
||||||
|
Capacity = new() { CurrentPlayers = 3, MaximumPlayers = 8 },
|
||||||
|
Metadata = new() { ["mode"] = "online-coop" },
|
||||||
|
},
|
||||||
|
host.PublisherCredential,
|
||||||
|
timeout.Token)).IsSuccess);
|
||||||
|
Assert.True(await upsertPending);
|
||||||
|
SessionStreamEvent upsert = AssertSuccess(events.Current);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, upsert.Kind);
|
||||||
|
Assert.Equal("Live update", upsert.Session!.DisplayName);
|
||||||
|
|
||||||
|
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> replay = browser
|
||||||
|
.StreamAsync(request, snapshot.StreamCursor, timeout.Token)
|
||||||
|
.GetAsyncEnumerator(timeout.Token))
|
||||||
|
{
|
||||||
|
Assert.True(await replay.MoveNextAsync());
|
||||||
|
SessionStreamEvent replayed = AssertSuccess(replay.Current);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionUpsert, replayed.Kind);
|
||||||
|
Assert.Equal(upsert.Cursor, replayed.Cursor);
|
||||||
|
Assert.Equal("Live update", replayed.Session!.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task<bool> removePending = events.MoveNextAsync().AsTask();
|
||||||
|
Assert.True((await publisher.DeregisterAsync(
|
||||||
|
session,
|
||||||
|
host.PublisherCredential,
|
||||||
|
timeout.Token)).IsSuccess);
|
||||||
|
Assert.True(await removePending);
|
||||||
|
SessionStreamEvent remove = AssertSuccess(events.Current);
|
||||||
|
Assert.Equal(SessionStreamEventKind.SessionRemove, remove.Kind);
|
||||||
|
Assert.Equal(session.ListingId, remove.ListingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CorruptCursor(string cursor)
|
||||||
|
{
|
||||||
|
char replacement = cursor[^1] == 'a' ? 'b' : 'a';
|
||||||
|
return cursor[..^1] + replacement;
|
||||||
|
}
|
||||||
|
|
||||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||||
{
|
{
|
||||||
Assert.True(result.IsSuccess, result.Message);
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
@@ -210,7 +286,8 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
{
|
{
|
||||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
EphemeralStoreOptions stateOptions = new();
|
EphemeralStoreOptions stateOptions = new();
|
||||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
|
||||||
EphemeralCapabilityIssuer capabilities = new();
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
ProvisioningTestData.CreateOptions(),
|
ProvisioningTestData.CreateOptions(),
|
||||||
@@ -239,7 +316,10 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
builder.Services.AddSingleton<SessionLeaseService>();
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
||||||
|
builder.Services.AddSingleton(changes);
|
||||||
builder.Services.AddSingleton<SessionBrowserService>();
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamService>();
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
app.UseExceptionHandler();
|
app.UseExceptionHandler();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
"/v1/operator/principals/revoke",
|
"/v1/operator/principals/revoke",
|
||||||
"/v1/operator/status",
|
"/v1/operator/status",
|
||||||
"/v1/sessions",
|
"/v1/sessions",
|
||||||
|
"/v1/sessions/stream",
|
||||||
"/v1/sessions/{listingId}",
|
"/v1/sessions/{listingId}",
|
||||||
"/v1/sessions/{listingId}/join-attempts",
|
"/v1/sessions/{listingId}/join-attempts",
|
||||||
"/v1/sessions/{listingId}/renew",
|
"/v1/sessions/{listingId}/renew",
|
||||||
@@ -68,6 +69,25 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
Assert.DoesNotContain(listingProperties, static property =>
|
Assert.DoesNotContain(listingProperties, static property =>
|
||||||
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|
||||||
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
|
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
|
||||||
|
JsonElement streamProperties = schemas.GetProperty("SessionStreamEvent")
|
||||||
|
.GetProperty("properties");
|
||||||
|
Assert.True(streamProperties.TryGetProperty("contractVersion", out _));
|
||||||
|
Assert.True(streamProperties.TryGetProperty("kind", out _));
|
||||||
|
Assert.True(streamProperties.TryGetProperty("cursor", out _));
|
||||||
|
Assert.True(streamProperties.TryGetProperty("session", out _));
|
||||||
|
Assert.True(streamProperties.TryGetProperty("listingId", out _));
|
||||||
|
Assert.DoesNotContain(streamProperties.EnumerateObject(), static property =>
|
||||||
|
property.Name.Contains("token", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| property.Name.Contains("capability", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| property.Name.Contains("ticket", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| property.Name.Contains("endpoint", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.True(root.GetProperty("paths")
|
||||||
|
.GetProperty("/v1/sessions/stream")
|
||||||
|
.GetProperty("get")
|
||||||
|
.GetProperty("responses")
|
||||||
|
.GetProperty("200")
|
||||||
|
.GetProperty("content")
|
||||||
|
.TryGetProperty("text/event-stream", out _));
|
||||||
JsonElement dedicatedFallback = schemas.GetProperty("SessionListing")
|
JsonElement dedicatedFallback = schemas.GetProperty("SessionListing")
|
||||||
.GetProperty("properties")
|
.GetProperty("properties")
|
||||||
.GetProperty("dedicatedFallback");
|
.GetProperty("dedicatedFallback");
|
||||||
@@ -205,7 +225,7 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.Equal(17, overloadContracts);
|
Assert.Equal(18, overloadContracts);
|
||||||
(string Path, string Method)[] bodyOperations =
|
(string Path, string Method)[] bodyOperations =
|
||||||
[
|
[
|
||||||
("/v1/sessions", "post"),
|
("/v1/sessions", "post"),
|
||||||
|
|||||||
@@ -238,6 +238,10 @@ public sealed class ProductionProcessTests
|
|||||||
"--Rendezvous:Provisioning:SigningKeys:0:NotBefore", now.AddHours(-1).ToString("O"),
|
"--Rendezvous:Provisioning:SigningKeys:0:NotBefore", now.AddHours(-1).ToString("O"),
|
||||||
"--Rendezvous:Provisioning:SigningKeys:0:SignUntil", now.AddHours(1).ToString("O"),
|
"--Rendezvous:Provisioning:SigningKeys:0:SignUntil", now.AddHours(1).ToString("O"),
|
||||||
"--Rendezvous:Provisioning:SigningKeys:0:VerifyUntil", now.AddHours(2).ToString("O"),
|
"--Rendezvous:Provisioning:SigningKeys:0:VerifyUntil", now.AddHours(2).ToString("O"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:SecretReference", $"file:{secretPath}",
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:NotBefore", now.AddHours(-1).ToString("O"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:SignUntil", now.AddHours(1).ToString("O"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:VerifyUntil", now.AddHours(2).ToString("O"),
|
||||||
"--Rendezvous:Udp:Port", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
"--Rendezvous:Udp:Port", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
"--Rendezvous:Deployment:PublicUdpPort", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
"--Rendezvous:Deployment:PublicUdpPort", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Server.Diagnostics;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Diagnostics;
|
||||||
|
|
||||||
|
public sealed class DiagnosticDashboardTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ConfigurationRequiresBoundedUniqueAllowListedScopes()
|
||||||
|
{
|
||||||
|
Assert.Empty(ValidOptions().Validate());
|
||||||
|
Assert.Empty(new DiagnosticDashboardOptions().Validate());
|
||||||
|
|
||||||
|
DiagnosticDashboardOptions invalid = ValidOptions() with
|
||||||
|
{
|
||||||
|
PollIntervalSeconds = 1,
|
||||||
|
MaximumRenderedSessions = 501,
|
||||||
|
Scopes =
|
||||||
|
[
|
||||||
|
new DiagnosticDashboardScope
|
||||||
|
{
|
||||||
|
GameId = "INVALID",
|
||||||
|
EnvironmentId = "smoke",
|
||||||
|
ProtocolVersions = [0, 0],
|
||||||
|
Regions = ["INVALID", "INVALID"],
|
||||||
|
},
|
||||||
|
new DiagnosticDashboardScope
|
||||||
|
{
|
||||||
|
GameId = "INVALID",
|
||||||
|
EnvironmentId = "smoke",
|
||||||
|
ProtocolVersions = [1],
|
||||||
|
Regions = ["local"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<string> errors = invalid.Validate();
|
||||||
|
Assert.Contains(errors, error => error.Contains("PollIntervalSeconds", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("MaximumRenderedSessions", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("invalid game", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("protocol versions", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("regions", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("duplicate", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
DiagnosticDashboardOptions nullBound = ValidOptions() with
|
||||||
|
{
|
||||||
|
Scopes = null!,
|
||||||
|
};
|
||||||
|
Assert.Contains(
|
||||||
|
nullBound.Validate(),
|
||||||
|
error => error.Contains("Scopes must contain", StringComparison.Ordinal));
|
||||||
|
DiagnosticDashboardOptions nullScope = ValidOptions() with
|
||||||
|
{
|
||||||
|
Scopes = [null!],
|
||||||
|
};
|
||||||
|
Assert.Contains(
|
||||||
|
nullScope.Validate(),
|
||||||
|
error => error.Contains("null entries", StringComparison.Ordinal));
|
||||||
|
DiagnosticDashboardOptions nullCollections = ValidOptions() with
|
||||||
|
{
|
||||||
|
Scopes =
|
||||||
|
[
|
||||||
|
new DiagnosticDashboardScope
|
||||||
|
{
|
||||||
|
GameId = null!,
|
||||||
|
EnvironmentId = null!,
|
||||||
|
ProtocolVersions = null!,
|
||||||
|
Regions = null!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
Assert.True(nullCollections.Validate().Count >= 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DisabledDashboardHasNoPublicAssetOrConfigurationSurface()
|
||||||
|
{
|
||||||
|
await using DashboardHost host = await DashboardHost.StartAsync(new());
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await host.Client.GetAsync("diagnostics/")).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await host.Client.GetAsync("diagnostics/app.mjs")).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await host.Client.GetAsync("diagnostics/config.json")).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnabledDashboardServesOnlyHardenedSameOriginReadOnlyAssets()
|
||||||
|
{
|
||||||
|
await using DashboardHost host = await DashboardHost.StartAsync(ValidOptions());
|
||||||
|
using HttpRequestMessage request = new(HttpMethod.Get, "diagnostics/");
|
||||||
|
request.Headers.Add("Origin", "https://hostile.example");
|
||||||
|
using HttpResponseMessage response = await host.Client.SendAsync(request);
|
||||||
|
string html = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.True(response.IsSuccessStatusCode, html);
|
||||||
|
Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType);
|
||||||
|
Assert.Equal("DENY", Header(response, "X-Frame-Options"));
|
||||||
|
Assert.Equal("nosniff", Header(response, "X-Content-Type-Options"));
|
||||||
|
Assert.Equal("no-referrer", Header(response, "Referrer-Policy"));
|
||||||
|
Assert.Contains("default-src 'none'", Header(response, "Content-Security-Policy"), StringComparison.Ordinal);
|
||||||
|
Assert.Contains("connect-src 'self'", Header(response, "Content-Security-Policy"), StringComparison.Ordinal);
|
||||||
|
Assert.Contains("frame-ancestors 'none'", Header(response, "Content-Security-Policy"), StringComparison.Ordinal);
|
||||||
|
Assert.False(response.Headers.Contains("Access-Control-Allow-Origin"));
|
||||||
|
Assert.Contains("<html lang=\"en\">", html, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("href=\"#main-content\"", html, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("aria-live=\"polite\"", html, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("type=\"module\" src=\"/diagnostics/app.mjs\"", html, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("<script src=\"http", html, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("/v1/operator", html, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
using HttpResponseMessage scriptResponse = await host.Client.GetAsync("diagnostics/app.mjs");
|
||||||
|
string script = await scriptResponse.Content.ReadAsStringAsync();
|
||||||
|
Assert.Equal("text/javascript", scriptResponse.Content.Headers.ContentType?.MediaType);
|
||||||
|
Assert.DoesNotContain("/v1/operator", script, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("Authorization", script, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("innerHTML", script, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BrowserConfigurationContainsOnlyTheExplicitPublicAllowList()
|
||||||
|
{
|
||||||
|
await using DashboardHost host = await DashboardHost.StartAsync(ValidOptions());
|
||||||
|
using HttpResponseMessage response = await host.Client.GetAsync("diagnostics/config.json");
|
||||||
|
using JsonDocument document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
JsonElement root = document.RootElement;
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal(1, root.GetProperty("contractVersion").GetInt32());
|
||||||
|
Assert.Equal(10, root.GetProperty("pollIntervalSeconds").GetInt32());
|
||||||
|
JsonElement scope = Assert.Single(root.GetProperty("scopes").EnumerateArray());
|
||||||
|
Assert.Equal("space-game", scope.GetProperty("gameId").GetString());
|
||||||
|
Assert.Equal("smoke", scope.GetProperty("environmentId").GetString());
|
||||||
|
Assert.Equal("public", scope.GetProperty("visibility").GetString());
|
||||||
|
string json = root.GetRawText();
|
||||||
|
Assert.DoesNotContain("http", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("credential", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("token", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Header(HttpResponseMessage response, string name) =>
|
||||||
|
Assert.Single(response.Headers.GetValues(name));
|
||||||
|
|
||||||
|
private static DiagnosticDashboardOptions ValidOptions() => new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
PollIntervalSeconds = 10,
|
||||||
|
MaximumRenderedSessions = 100,
|
||||||
|
Scopes =
|
||||||
|
[
|
||||||
|
new DiagnosticDashboardScope
|
||||||
|
{
|
||||||
|
GameId = "space-game",
|
||||||
|
EnvironmentId = "smoke",
|
||||||
|
ProtocolVersions = [1, 2],
|
||||||
|
Regions = ["local"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class DashboardHost : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplication _application;
|
||||||
|
|
||||||
|
private DashboardHost(WebApplication application, HttpClient client)
|
||||||
|
{
|
||||||
|
_application = application;
|
||||||
|
Client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpClient Client { get; }
|
||||||
|
|
||||||
|
public static async Task<DashboardHost> StartAsync(DiagnosticDashboardOptions options)
|
||||||
|
{
|
||||||
|
int port = ReserveTcpPort();
|
||||||
|
string address = $"http://127.0.0.1:{port}";
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseUrls(address);
|
||||||
|
builder.Services.AddSingleton(Options.Create(options));
|
||||||
|
WebApplication application = builder.Build();
|
||||||
|
application.MapDiagnosticDashboardEndpoints();
|
||||||
|
await application.StartAsync();
|
||||||
|
return new(application, new HttpClient { BaseAddress = new Uri(address) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Client.Dispose();
|
||||||
|
await _application.StopAsync();
|
||||||
|
await _application.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReserveTcpPort()
|
||||||
|
{
|
||||||
|
using TcpListener listener = new(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,26 +40,61 @@ public sealed partial class DocumentationContractTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LocalCredentialHelperIsFixedScopeAndSmokeDelegatesToIt()
|
public void LocalCredentialHelperWhitelistsProvisionedGameScopesAndSmokeDelegatesToIt()
|
||||||
{
|
{
|
||||||
string root = FindRepositoryRoot();
|
string root = FindRepositoryRoot();
|
||||||
string helper = File.ReadAllText(Path.Combine(root, "scripts", "mint-local-publisher-credential.sh"));
|
string helper = File.ReadAllText(Path.Combine(root, "scripts", "mint-local-publisher-credential.sh"));
|
||||||
string smoke = File.ReadAllText(Path.Combine(root, "scripts", "smoke-deployment.sh"));
|
string smoke = File.ReadAllText(Path.Combine(root, "scripts", "smoke-deployment.sh"));
|
||||||
|
|
||||||
Assert.Contains("if (( $# != 0 ));", helper, StringComparison.Ordinal);
|
Assert.Contains("if (( $# != 0 ));", helper, StringComparison.Ordinal);
|
||||||
Assert.Contains("\"gameId\": \"space-game\"", 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("\"environmentId\": \"smoke\"", helper, StringComparison.Ordinal);
|
||||||
Assert.Contains("\"regions\": [\"local\"]", helper, StringComparison.Ordinal);
|
Assert.Contains("\"regions\": [\"local\"]", helper, StringComparison.Ordinal);
|
||||||
Assert.Contains("now + 600", helper, StringComparison.Ordinal);
|
Assert.Contains("now + 600", helper, StringComparison.Ordinal);
|
||||||
Assert.Contains("stat.S_ISLNK", helper, StringComparison.Ordinal);
|
Assert.Contains("stat.S_ISLNK", helper, StringComparison.Ordinal);
|
||||||
Assert.Contains("parent.st_mode & 0o077", helper, StringComparison.Ordinal);
|
Assert.Contains("parent.st_mode & 0o077", helper, StringComparison.Ordinal);
|
||||||
Assert.Contains("metadata.st_mode & 0o022", helper, StringComparison.Ordinal);
|
Assert.Contains("metadata.st_mode & 0o077", helper, StringComparison.Ordinal);
|
||||||
Assert.Contains("metadata.st_nlink != 1", helper, StringComparison.Ordinal);
|
Assert.Contains("metadata.st_nlink != 1", helper, StringComparison.Ordinal);
|
||||||
Assert.Contains("mint-local-publisher-credential.sh", smoke, StringComparison.Ordinal);
|
Assert.Contains("mint-local-publisher-credential.sh", smoke, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("select(.event == \"host.ready\" and .status == \"ready\")", smoke, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("hasListingId", smoke, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("jq -s -e", smoke, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("jq -c .", smoke, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("hexkey:", smoke, StringComparison.Ordinal);
|
Assert.DoesNotContain("hexkey:", smoke, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("openssl dgst", 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]
|
[Fact]
|
||||||
public void EveryIncidentRunbookHasDetectContainRecoverAndVerifyGates()
|
public void EveryIncidentRunbookHasDetectContainRecoverAndVerifyGates()
|
||||||
{
|
{
|
||||||
@@ -184,6 +219,79 @@ public sealed partial class DocumentationContractTests
|
|||||||
Assert.Equal(httpVersion, udpVersion);
|
Assert.Equal(httpVersion, udpVersion);
|
||||||
Assert.Equal(httpVersion, ticketVersion);
|
Assert.Equal(httpVersion, ticketVersion);
|
||||||
Assert.Contains($"contract version `{httpVersion}`", guide, StringComparison.Ordinal);
|
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)]
|
[GeneratedRegex(@"rv1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", RegexOptions.CultureInvariant)]
|
||||||
|
|||||||
@@ -288,7 +288,8 @@ public sealed class JoinAttemptHttpEndpointTests
|
|||||||
{
|
{
|
||||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
EphemeralStoreOptions stateOptions = new();
|
EphemeralStoreOptions stateOptions = new();
|
||||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
|
||||||
EphemeralCapabilityIssuer capabilities = new();
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
ProvisioningTestData.CreateOptions(),
|
ProvisioningTestData.CreateOptions(),
|
||||||
@@ -318,7 +319,10 @@ public sealed class JoinAttemptHttpEndpointTests
|
|||||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
builder.Services.AddSingleton<SessionLeaseService>();
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
||||||
|
builder.Services.AddSingleton(changes);
|
||||||
builder.Services.AddSingleton<SessionBrowserService>();
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamService>();
|
||||||
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||||
builder.Services.AddSingleton<JoinAttemptService>();
|
builder.Services.AddSingleton<JoinAttemptService>();
|
||||||
ConnectionOutcomeMetrics outcomeMetrics = new();
|
ConnectionOutcomeMetrics outcomeMetrics = new();
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Observability;
|
||||||
|
|
||||||
|
[Collection(RendezvousTelemetryIsolation.Name)]
|
||||||
|
public sealed class PrometheusMetricsTests
|
||||||
|
{
|
||||||
|
private const string Token = "0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnabledExporterRequiresAnExternalBoundedSecretReference()
|
||||||
|
{
|
||||||
|
Assert.Empty(new PrometheusMetricsOptions().Validate());
|
||||||
|
Assert.Empty(new PrometheusMetricsOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
BearerTokenSecretReference = "file:/run/secrets/rendezvous-metrics-token",
|
||||||
|
}.Validate());
|
||||||
|
Assert.NotEmpty(new PrometheusMetricsOptions { Enabled = true }.Validate());
|
||||||
|
Assert.NotEmpty(new PrometheusMetricsOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
BearerTokenSecretReference = "literal-secret",
|
||||||
|
}.Validate());
|
||||||
|
Assert.NotEmpty(new PrometheusMetricsOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
BearerTokenSecretReference = "file:relative-token",
|
||||||
|
}.Validate());
|
||||||
|
Assert.NotEmpty(new PrometheusMetricsOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
BearerTokenSecretReference = null!,
|
||||||
|
}.Validate());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExporterIsDisabledByDefaultAndConcealsRejectedAuthentication()
|
||||||
|
{
|
||||||
|
await using MetricsHost disabled = await MetricsHost.StartAsync(
|
||||||
|
new PrometheusMetricsOptions(),
|
||||||
|
credential: null);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await disabled.Client.GetAsync("metrics")).StatusCode);
|
||||||
|
|
||||||
|
PrometheusMetricsOptions options = EnabledOptions();
|
||||||
|
using MetricsAccessCredential credential = Credential(options);
|
||||||
|
await using MetricsHost enabled = await MetricsHost.StartAsync(options, credential);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await enabled.Client.GetAsync("metrics")).StatusCode);
|
||||||
|
using HttpRequestMessage wrong = new(HttpMethod.Get, "metrics");
|
||||||
|
wrong.Headers.Authorization = new("Bearer", new string('x', 32));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await enabled.Client.SendAsync(wrong)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticatedExporterReturnsBoundedPrivacySafePrometheusFamilies()
|
||||||
|
{
|
||||||
|
PrometheusMetricsOptions options = EnabledOptions();
|
||||||
|
using MetricsAccessCredential credential = Credential(options);
|
||||||
|
await using MetricsHost host = await MetricsHost.StartAsync(options, credential);
|
||||||
|
host.Telemetry.RecordHttp("BrowseSessions", 200, 3.5);
|
||||||
|
host.Telemetry.RecordHttp("listing-id/canary", 500, 4.5);
|
||||||
|
host.Telemetry.RecordUdp("frozen", "Introduced", 1.25, 128, 2400);
|
||||||
|
host.Telemetry.RecordLimiterDrop("udp", "rate-or-concurrency");
|
||||||
|
host.Telemetry.RecordConnectionOutcome("Connected", "UnderOneSecond");
|
||||||
|
host.Telemetry.RecordPairingLatency(12.5);
|
||||||
|
using HttpRequestMessage request = new(HttpMethod.Get, "metrics");
|
||||||
|
request.Headers.Authorization = new("Bearer", Token);
|
||||||
|
using HttpResponseMessage response = await host.Client.SendAsync(request);
|
||||||
|
string body = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType);
|
||||||
|
Assert.Equal("no-store", response.Headers.CacheControl?.ToString());
|
||||||
|
Assert.Contains("rendezvous_http_requests_total{operation=\"BrowseSessions\",status_code=\"200\"} 1", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("rendezvous_http_duration_milliseconds_bucket", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("rendezvous_udp_received_bytes_total{operation=\"frozen\"} 128", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("rendezvous_udp_response_budget_bytes_total{operation=\"frozen\"} 2400", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("rendezvous_store_active_listings", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("rendezvous_browser_sse_subscribers", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("process_resident_memory_bytes", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("dotnet_gc_heap_size_bytes", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("operation=\"other\"", body, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("listing-id", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain(Token, body, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PrometheusMetricsOptions EnabledOptions() => new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
BearerTokenSecretReference = "file:/metrics-token",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static MetricsAccessCredential Credential(PrometheusMetricsOptions options)
|
||||||
|
{
|
||||||
|
using DictionarySecretProvider secrets = new(new Dictionary<string, byte[]>
|
||||||
|
{
|
||||||
|
[options.BearerTokenSecretReference] = Encoding.ASCII.GetBytes(Token + "\n"),
|
||||||
|
});
|
||||||
|
Assert.True(MetricsAccessCredential.TryCreate(options, secrets, out MetricsAccessCredential? credential));
|
||||||
|
return Assert.IsType<MetricsAccessCredential>(credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MetricsHost : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplication _application;
|
||||||
|
|
||||||
|
private MetricsHost(
|
||||||
|
WebApplication application,
|
||||||
|
HttpClient client,
|
||||||
|
RendezvousTelemetry telemetry)
|
||||||
|
{
|
||||||
|
_application = application;
|
||||||
|
Client = client;
|
||||||
|
Telemetry = telemetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpClient Client { get; }
|
||||||
|
public RendezvousTelemetry Telemetry { get; }
|
||||||
|
|
||||||
|
public static async Task<MetricsHost> StartAsync(
|
||||||
|
PrometheusMetricsOptions options,
|
||||||
|
MetricsAccessCredential? credential)
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new();
|
||||||
|
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(new(), clock, clock, changes);
|
||||||
|
RendezvousTelemetry telemetry = new(store, changes);
|
||||||
|
int port = ReserveTcpPort();
|
||||||
|
string address = $"http://127.0.0.1:{port}";
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseUrls(address);
|
||||||
|
builder.Services.AddSingleton(telemetry);
|
||||||
|
WebApplication application = builder.Build();
|
||||||
|
application.MapPrometheusMetricsEndpoint(options, credential);
|
||||||
|
await application.StartAsync();
|
||||||
|
return new(application, new HttpClient { BaseAddress = new Uri(address) }, telemetry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Client.Dispose();
|
||||||
|
await _application.StopAsync();
|
||||||
|
await _application.DisposeAsync();
|
||||||
|
// This test supplies an existing singleton instance, so the host does not own it.
|
||||||
|
Telemetry.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReserveTcpPort()
|
||||||
|
{
|
||||||
|
using TcpListener listener = new(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,72 @@ public sealed class ReleaseCompatibilityTests
|
|||||||
pinnedConsumers.Select(static item => item.GetProperty("name").GetString()!).ToArray());
|
pinnedConsumers.Select(static item => item.GetProperty("name").GetString()!).ToArray());
|
||||||
Assert.All(pinnedConsumers, static item =>
|
Assert.All(pinnedConsumers, static item =>
|
||||||
Assert.Matches("^[0-9a-f]{40}$", item.GetProperty("revision").GetString()));
|
Assert.Matches("^[0-9a-f]{40}$", item.GetProperty("revision").GetString()));
|
||||||
|
|
||||||
|
string realConsumerGate = File.ReadAllText(Path.Combine(root, "scripts", "verify-real-consumers.sh"));
|
||||||
|
Assert.Contains("<PackageReference Remove=\"FinalFactory.Rendezvous.Client\" />", realConsumerGate, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("<PackageReference Remove=\"FinalFactory.Rendezvous.Contracts\" />", realConsumerGate, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutomationProvidesNet8RuntimeForProcessVerification()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string qualityWorkflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows/ci.yml"));
|
||||||
|
Assert.Equal(2, qualityWorkflow.Split("8.0.128", StringSplitOptions.None).Length - 1);
|
||||||
|
|
||||||
|
string releaseWorkflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows/release.yml"));
|
||||||
|
Assert.Contains("8.0.128", releaseWorkflow, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
string releaseBuilder = File.ReadAllText(Path.Combine(root, "eng/release-builder.Dockerfile"));
|
||||||
|
Assert.Contains("runtime:8.0.28-noble@sha256:", releaseBuilder, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Microsoft.NETCore.App/8.0.28", releaseBuilder, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutomationUsesPerRunContainerPorts()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string compose = File.ReadAllText(Path.Combine(root, "deploy/compose/compose.yaml"));
|
||||||
|
Assert.Contains("${RENDEZVOUS_HTTP_HOST_PORT:-8080}:8080/tcp", compose, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("${RENDEZVOUS_UDP_HOST_PORT:-9050}:9050/udp", compose, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("${RENDEZVOUS_CONFIG_SOURCE:-./appsettings.Production.json}", compose, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("${RENDEZVOUS_SECRET_SOURCE:-./secrets/signing-key}", compose, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
foreach (string workflowName in new[] { "ci.yml", "release.yml" })
|
||||||
|
{
|
||||||
|
string workflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows", workflowName));
|
||||||
|
Assert.Contains("GITHUB_RUN_ID", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("--network \"container:${container_id}\"", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("publisher_credential", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("RENDEZVOUS_PUBLISHER_CREDENTIAL", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("for attempt in {1..180}", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("sleep 1", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("http://127.0.0.1:8080/", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("127.0.0.1:9050", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("RENDEZVOUS_HTTP_HOST_PORT", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("RENDEZVOUS_UDP_HOST_PORT", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("RENDEZVOUS_SMOKE_HTTP_URL", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("RENDEZVOUS_SMOKE_UDP_ENDPOINT", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("runner_workspace_source", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("RENDEZVOUS_CONFIG_SOURCE", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("RENDEZVOUS_SECRET_SOURCE", workflow, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ContainerGateUsesValidInspectTemplates()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string workflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows/ci.yml"));
|
||||||
|
Assert.Contains(
|
||||||
|
"eq .Destination \"/app/appsettings.Production.json\"",
|
||||||
|
workflow,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"eq .Destination \"/run/secrets/rendezvous-signing-key\"",
|
||||||
|
workflow,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("eq .Destination \\\"", workflow, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -164,6 +230,52 @@ public sealed class ReleaseCompatibilityTests
|
|||||||
Assert.Equal(actual, declared);
|
Assert.Equal(actual, declared);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductionReadinessRecordIsFailClosedAndCanaryEvidenceIsRedacted()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
using JsonDocument readiness = JsonDocument.Parse(File.ReadAllText(Path.Combine(
|
||||||
|
root,
|
||||||
|
"docs/evidence/production-readiness-v1.json")));
|
||||||
|
JsonElement document = readiness.RootElement;
|
||||||
|
Assert.Equal(1, document.GetProperty("schemaVersion").GetInt32());
|
||||||
|
Assert.Equal("rendezvous-production-readiness", document.GetProperty("kind").GetString());
|
||||||
|
Assert.Matches("^[0-9a-f]{40}$", document.GetProperty("evaluatedCommit").GetString());
|
||||||
|
|
||||||
|
JsonElement[] local = document.GetProperty("localGates").EnumerateArray().ToArray();
|
||||||
|
JsonElement[] external = document.GetProperty("externalGates").EnumerateArray().ToArray();
|
||||||
|
Assert.Equal(6, local.Length);
|
||||||
|
Assert.Equal(13, external.Length);
|
||||||
|
JsonElement[] gates = local.Concat(external).ToArray();
|
||||||
|
Assert.Equal(gates.Length, gates.Select(static gate => gate.GetProperty("id").GetString()).Distinct().Count());
|
||||||
|
Assert.All(gates, static gate =>
|
||||||
|
{
|
||||||
|
Assert.True(gate.GetProperty("status").GetString() is "pass" or "pending" or "fail");
|
||||||
|
string evidence = Assert.IsType<string>(gate.GetProperty("evidenceRef").GetString());
|
||||||
|
Assert.False(Path.IsPathRooted(evidence));
|
||||||
|
Assert.DoesNotContain("..", evidence, StringComparison.Ordinal);
|
||||||
|
Assert.True(gate.GetProperty("note").GetString()!.Length <= 240);
|
||||||
|
});
|
||||||
|
bool allPass = gates.All(static gate => gate.GetProperty("status").GetString() == "pass");
|
||||||
|
Assert.Equal(allPass ? "ready" : "not-ready", document.GetProperty("decision").GetString());
|
||||||
|
|
||||||
|
string canary = File.ReadAllText(Path.Combine(root, "scripts/run-real-network-canary.sh"));
|
||||||
|
Assert.Contains("umask 077", canary, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("client-expected-failure", canary, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("exit_code\" -eq 12", canary, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(".addressFamily == $family", canary, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("identifiers:\"not-in-summary\"", canary, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("jq -c . \"$raw_log\"", canary, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
string checker = File.ReadAllText(Path.Combine(root, "eng/check_production_readiness.py"));
|
||||||
|
Assert.Contains("return 3", checker, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("FORBIDDEN_KEY_PARTS", checker, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("decision must be", checker, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("candidate capacity evidence", checker, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("rendezvous-external-gate-attestation", checker, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("does not resolve to a repository evidence file", checker, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
private static string Property(XDocument document, string name) =>
|
private static string Property(XDocument document, string name) =>
|
||||||
document.Descendants(name).Single().Value;
|
document.Descendants(name).Single().Value;
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ public sealed class SessionHttpEndpointTests
|
|||||||
{
|
{
|
||||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
EphemeralStoreOptions stateOptions = new();
|
EphemeralStoreOptions stateOptions = new();
|
||||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
|
||||||
EphemeralCapabilityIssuer capabilities = new();
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
ProvisioningTestData.CreateOptions(),
|
ProvisioningTestData.CreateOptions(),
|
||||||
@@ -57,7 +58,10 @@ public sealed class SessionHttpEndpointTests
|
|||||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
builder.Services.AddSingleton<SessionLeaseService>();
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
||||||
|
builder.Services.AddSingleton(changes);
|
||||||
builder.Services.AddSingleton<SessionBrowserService>();
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<SessionStreamService>();
|
||||||
await using WebApplication app = builder.Build();
|
await using WebApplication app = builder.Build();
|
||||||
app.UseExceptionHandler();
|
app.UseExceptionHandler();
|
||||||
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
using FinalFactory.Rendezvous.Server.State;
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Tests.State;
|
namespace FinalFactory.Rendezvous.Tests.State;
|
||||||
@@ -24,10 +25,12 @@ internal sealed class EphemeralStateFixture
|
|||||||
{
|
{
|
||||||
private int _sequence;
|
private int _sequence;
|
||||||
|
|
||||||
public EphemeralStateFixture(EphemeralStoreOptions? options = null)
|
public EphemeralStateFixture(
|
||||||
|
EphemeralStoreOptions? options = null,
|
||||||
|
SessionChangeJournal? changes = null)
|
||||||
{
|
{
|
||||||
Clock = new();
|
Clock = new();
|
||||||
Store = new(options ?? new EphemeralStoreOptions(), Clock, Clock);
|
Store = new(options ?? new EphemeralStoreOptions(), Clock, Clock, changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ManualRendezvousClock Clock { get; }
|
public ManualRendezvousClock Clock { get; }
|
||||||
|
|||||||
@@ -59,6 +59,29 @@ public sealed class TestClientCommandTests
|
|||||||
Assert.Equal(130, (int)TestClientExitCode.Cancelled);
|
Assert.Equal(130, (int)TestClientExitCode.Cancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WatchModeSupportsBoundedRuntimeAndDeliberateRecoveryExercisesOnly()
|
||||||
|
{
|
||||||
|
TestClientParseResult reset = TestClientOptionParser.Parse(
|
||||||
|
["watch", "--run-seconds", "30", "--exercise-reset", "--script", "--json"]);
|
||||||
|
Assert.True(reset.Succeeded, reset.Error);
|
||||||
|
TestClientOptions resetOptions = Assert.IsType<TestClientOptions>(reset.Options);
|
||||||
|
Assert.Equal(TestClientMode.Watch, resetOptions.Mode);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(30), resetOptions.RunDuration);
|
||||||
|
Assert.True(resetOptions.ExerciseReset);
|
||||||
|
|
||||||
|
TestClientParseResult reconnect = TestClientOptionParser.Parse(
|
||||||
|
["watch", "--exercise-reconnect", "--script"]);
|
||||||
|
Assert.True(reconnect.Succeeded, reconnect.Error);
|
||||||
|
Assert.True(Assert.IsType<TestClientOptions>(reconnect.Options).ExerciseReconnect);
|
||||||
|
|
||||||
|
Assert.False(TestClientOptionParser.Parse(["browse", "--exercise-reconnect"]).Succeeded);
|
||||||
|
Assert.False(TestClientOptionParser.Parse(["browse", "--exercise-reset"]).Succeeded);
|
||||||
|
Assert.False(TestClientOptionParser.Parse(
|
||||||
|
["watch", "--exercise-reconnect", "--exercise-reset"]).Succeeded);
|
||||||
|
Assert.False(TestClientOptionParser.Parse(["join", "--run-seconds", "30"]).Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HostFailureBudgetStopsAuthorityLossAndBoundsTransientRetries()
|
public void HostFailureBudgetStopsAuthorityLossAndBoundsTransientRetries()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -178,12 +178,17 @@ public sealed class TestClientProcessIntegrationTests
|
|||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
join.JsonEvents(),
|
join.JsonEvents(),
|
||||||
item => item.GetProperty("event").GetString() == "join.connected"
|
item => item.GetProperty("event").GetString() == "join.connected"
|
||||||
&& item.GetProperty("endpointType").GetString() is "loopback" or "private");
|
&& item.GetProperty("endpointType").GetString() is "loopback" or "private"
|
||||||
|
&& item.GetProperty("addressFamily").GetString() == "ipv4");
|
||||||
Assert.True(join.HasEvent("join.punch", "started"), join.DiagnosticText());
|
Assert.True(join.HasEvent("join.punch", "started"), join.DiagnosticText());
|
||||||
Assert.True(join.HasEvent("join.direct-connect", "started"), join.DiagnosticText());
|
Assert.True(join.HasEvent("join.direct-connect", "started"), join.DiagnosticText());
|
||||||
Assert.True(join.HasEvent("join.direct-traffic", "verified"), join.DiagnosticText());
|
Assert.True(join.HasEvent("join.direct-traffic", "verified"), join.DiagnosticText());
|
||||||
Assert.True(join.HasEvent("join.outcome-report", "accepted"), join.DiagnosticText());
|
Assert.True(join.HasEvent("join.outcome-report", "accepted"), join.DiagnosticText());
|
||||||
Assert.True(host.HasEvent("host.direct-traffic", "verified"), host.DiagnosticText());
|
Assert.True(host.HasEvent("host.direct-traffic", "verified"), host.DiagnosticText());
|
||||||
|
Assert.Contains(
|
||||||
|
host.JsonEvents(),
|
||||||
|
item => item.GetProperty("event").GetString() == "host.direct-traffic"
|
||||||
|
&& item.GetProperty("addressFamily").GetString() == "ipv4");
|
||||||
Assert.True(host.HasEvent("host.punch", "started"), host.DiagnosticText());
|
Assert.True(host.HasEvent("host.punch", "started"), host.DiagnosticText());
|
||||||
Assert.True(host.HasEvent("host.direct-connect", "connected"), host.DiagnosticText());
|
Assert.True(host.HasEvent("host.direct-connect", "connected"), host.DiagnosticText());
|
||||||
Assert.True(host.HasEvent("host.deregistered", "complete"), host.DiagnosticText());
|
Assert.True(host.HasEvent("host.deregistered", "complete"), host.DiagnosticText());
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"contractVersion":1,"items":[{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","publisherTrustMode":"managedDedicated","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}],"nextCursor":"cursor-002"}
|
{"contractVersion":1,"items":[{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","publisherTrustMode":"managedDedicated","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}],"nextCursor":"cursor-002","streamCursor":"stream-cursor-002"}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ TYPE FinalFactory.Rendezvous.Client.IRendezvousSessionBrowserClient
|
|||||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Collections.Generic.IAsyncEnumerable<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.SessionStreamEvent>> StreamAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.String streamCursor, System.Threading.CancellationToken cancellationToken)
|
||||||
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceResult
|
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceResult
|
||||||
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
|
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
|
||||||
PROP FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason Reason {get;}
|
PROP FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason Reason {get;}
|
||||||
@@ -212,6 +213,7 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousSessionBrowserClient
|
|||||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Collections.Generic.IAsyncEnumerable<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.SessionStreamEvent>> StreamAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.String streamCursor, System.Threading.CancellationToken cancellationToken)
|
||||||
TYPE FinalFactory.Rendezvous.Client.SessionLeaseMaintainer
|
TYPE FinalFactory.Rendezvous.Client.SessionLeaseMaintainer
|
||||||
EVENT System.EventHandler LeaseLost
|
EVENT System.EventHandler LeaseLost
|
||||||
METHOD System.Threading.Tasks.ValueTask DisposeAsync()
|
METHOD System.Threading.Tasks.ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse
|
|||||||
PROP System.Int32 ContractVersion {get;set;}
|
PROP System.Int32 ContractVersion {get;set;}
|
||||||
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;}
|
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;}
|
||||||
PROP System.String NextCursor {get;set;}
|
PROP System.String NextCursor {get;set;}
|
||||||
|
PROP System.String StreamCursor {get;set;}
|
||||||
TYPE FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket
|
TYPE FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket
|
||||||
ENUM UnderOneSecond=1
|
ENUM UnderOneSecond=1
|
||||||
ENUM OneToFiveSeconds=2
|
ENUM OneToFiveSeconds=2
|
||||||
@@ -84,6 +85,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
|
|||||||
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
|
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
|
||||||
FIELD System.Int32 RegionIdMaxCharacters=32
|
FIELD System.Int32 RegionIdMaxCharacters=32
|
||||||
FIELD System.Int32 SessionCapacityMaxPlayers=10000
|
FIELD System.Int32 SessionCapacityMaxPlayers=10000
|
||||||
|
FIELD System.Int32 SessionStreamEventMaxBytes=32768
|
||||||
FIELD System.Int32 UdpCapabilityMaxCharacters=192
|
FIELD System.Int32 UdpCapabilityMaxCharacters=192
|
||||||
FIELD System.Int32 UdpDatagramMaxBytes=1200
|
FIELD System.Int32 UdpDatagramMaxBytes=1200
|
||||||
TYPE FinalFactory.Rendezvous.Contracts.ContractValidation
|
TYPE FinalFactory.Rendezvous.Contracts.ContractValidation
|
||||||
@@ -345,6 +347,18 @@ TYPE FinalFactory.Rendezvous.Contracts.SessionListingId
|
|||||||
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.SessionListingId& id)
|
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.SessionListingId& id)
|
||||||
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
|
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
|
||||||
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
|
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
|
||||||
|
TYPE FinalFactory.Rendezvous.Contracts.SessionStreamEvent
|
||||||
|
CTOR ()
|
||||||
|
PROP System.Int32 ContractVersion {get;set;}
|
||||||
|
PROP System.String Cursor {get;set;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.SessionStreamEventKind Kind {get;set;}
|
||||||
|
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.SessionListingId> ListingId {get;set;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.SessionListing Session {get;set;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Contracts.SessionStreamEventKind
|
||||||
|
ENUM SessionUpsert=1
|
||||||
|
ENUM SessionRemove=2
|
||||||
|
ENUM Reset=3
|
||||||
|
ENUM Keepalive=4
|
||||||
TYPE FinalFactory.Rendezvous.Contracts.UdpDecodeError
|
TYPE FinalFactory.Rendezvous.Contracts.UdpDecodeError
|
||||||
ENUM None=0
|
ENUM None=0
|
||||||
ENUM DatagramTooLarge=1
|
ENUM DatagramTooLarge=1
|
||||||
@@ -371,3 +385,6 @@ TYPE FinalFactory.Rendezvous.Contracts.UpdateSessionRequest
|
|||||||
PROP System.String DisplayName {get;set;}
|
PROP System.String DisplayName {get;set;}
|
||||||
PROP System.String LeaseToken {get;set;}
|
PROP System.String LeaseToken {get;set;}
|
||||||
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
|
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
|
||||||
|
PROP System.Nullable<System.UInt32> ProtocolVersion {get;set;}
|
||||||
|
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.RegionId> RegionId {get;set;}
|
||||||
|
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.ListingVisibility> Visibility {get;set;}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const root = new URL("../../", import.meta.url);
|
||||||
|
const dashboardPath = new URL(
|
||||||
|
"deploy/observability/grafana/dashboards/rendezvous-overview.json",
|
||||||
|
root,
|
||||||
|
);
|
||||||
|
|
||||||
|
test("dashboard is provisionable, broad, and uses privacy-safe bounded series", async () => {
|
||||||
|
const dashboard = JSON.parse(await readFile(dashboardPath, "utf8"));
|
||||||
|
assert.equal(dashboard.uid, "rendezvous-overview");
|
||||||
|
assert.equal(dashboard.editable, false);
|
||||||
|
assert.ok(dashboard.panels.length >= 20);
|
||||||
|
assert.equal(new Set(dashboard.panels.map((panel) => panel.id)).size, dashboard.panels.length);
|
||||||
|
assert.equal(new Set(dashboard.panels.map((panel) => panel.title)).size, dashboard.panels.length);
|
||||||
|
|
||||||
|
const expressions = dashboard.panels
|
||||||
|
.flatMap((panel) => panel.targets ?? [])
|
||||||
|
.map((target) => target.expr ?? "")
|
||||||
|
.join("\n");
|
||||||
|
for (const metric of [
|
||||||
|
"rendezvous_http_requests_total",
|
||||||
|
"rendezvous_http_duration_milliseconds_bucket",
|
||||||
|
"rendezvous_udp_results_total",
|
||||||
|
"rendezvous_udp_received_bytes_total",
|
||||||
|
"rendezvous_limiter_drops_total",
|
||||||
|
"rendezvous_connection_outcomes_total",
|
||||||
|
"rendezvous_browser_sse_subscribers",
|
||||||
|
"rendezvous_store_active_listings",
|
||||||
|
"rendezvous_store_active_attempts",
|
||||||
|
"rendezvous_signing_keys",
|
||||||
|
"process_resident_memory_bytes",
|
||||||
|
"process_open_file_descriptors",
|
||||||
|
"dotnet_gc_heap_size_bytes",
|
||||||
|
]) {
|
||||||
|
assert.match(expressions, new RegExp(`\\b${metric}\\b`));
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(
|
||||||
|
expressions,
|
||||||
|
/listing_?id|session_?id|player|subject|token|capability|endpoint|address|metadata|credential/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const panel of dashboard.panels) {
|
||||||
|
assert.ok(panel.title?.trim());
|
||||||
|
assert.ok(panel.gridPos?.w > 0 && panel.gridPos?.h > 0);
|
||||||
|
for (const target of panel.targets ?? []) {
|
||||||
|
assert.equal(target.editorMode, "code");
|
||||||
|
assert.ok(target.expr?.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Compose overlay pins hardened services and keeps telemetry private", async () => {
|
||||||
|
const compose = await readFile(new URL("deploy/observability/compose.yaml", root), "utf8");
|
||||||
|
assert.match(compose, /prom\/prometheus:v3\.13\.1@sha256:[a-f0-9]{64}/);
|
||||||
|
assert.match(compose, /grafana\/grafana:13\.1\.0@sha256:[a-f0-9]{64}/);
|
||||||
|
assert.match(compose, /Rendezvous__Metrics__Enabled: "true"/);
|
||||||
|
assert.match(compose, /rendezvous-metrics-token:\/run\/secrets\/rendezvous-metrics-token:ro/);
|
||||||
|
assert.match(compose, /GF_AUTH_ANONYMOUS_ENABLED: "false"/);
|
||||||
|
assert.match(compose, /GF_PLUGINS_PREINSTALL_DISABLED: "true"/);
|
||||||
|
assert.match(compose, /"127\.0\.0\.1:3000:3000\/tcp"/);
|
||||||
|
assert.doesNotMatch(compose, /9090:9090/);
|
||||||
|
assert.ok((compose.match(/read_only: true/g) ?? []).length >= 2);
|
||||||
|
assert.ok((compose.match(/no-new-privileges:true/g) ?? []).length >= 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Prometheus and Grafana provisioning use server-side authenticated access", async () => {
|
||||||
|
const prometheus = await readFile(
|
||||||
|
new URL("deploy/observability/prometheus/prometheus.yml", root),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const datasource = await readFile(
|
||||||
|
new URL("deploy/observability/grafana/provisioning/datasources/prometheus.yaml", root),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const provider = await readFile(
|
||||||
|
new URL("deploy/observability/grafana/provisioning/dashboards/rendezvous.yaml", root),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(prometheus, /bearer_token_file: \/run\/secrets\/rendezvous-metrics-token/);
|
||||||
|
assert.match(prometheus, /- rendezvous:8080/);
|
||||||
|
assert.match(datasource, /uid: rendezvous-prometheus/);
|
||||||
|
assert.match(datasource, /access: proxy/);
|
||||||
|
assert.match(datasource, /url: http:\/\/prometheus:9090/);
|
||||||
|
assert.match(provider, /allowUiUpdates: false/);
|
||||||
|
assert.match(provider, /path: \/var\/lib\/grafana\/dashboards/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user