feat(tooling): add standalone rendezvous test client (#25)
quality-gate / quality (push) Failing after 1m3s
quality-gate / quality (push) Failing after 1m3s
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.TestClient;
|
||||
|
||||
internal enum TestClientMode
|
||||
{
|
||||
Host,
|
||||
Browse,
|
||||
Join,
|
||||
}
|
||||
|
||||
internal sealed class TestClientOptions
|
||||
{
|
||||
internal TestClientMode Mode { get; init; }
|
||||
internal Uri ServiceUri { get; init; } = new("http://127.0.0.1:5000/");
|
||||
internal IPEndPoint Mediator { get; init; } = new(IPAddress.Loopback, 9050);
|
||||
internal GameId GameId { get; init; } = new("space-game");
|
||||
internal EnvironmentId EnvironmentId { get; init; } = new("development");
|
||||
internal RegionId RegionId { get; init; } = new("local");
|
||||
internal uint ProtocolVersion { get; init; } = 1;
|
||||
internal string BuildVersion { get; init; } = "test-client";
|
||||
internal string DisplayName { get; init; } = "Rendezvous diagnostic host";
|
||||
internal string PublisherCredentialEnvironmentVariable { get; init; } =
|
||||
"RENDEZVOUS_PUBLISHER_CREDENTIAL";
|
||||
internal Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
|
||||
internal NetworkEndpoint? DedicatedFallback { get; init; }
|
||||
internal SessionListingId? ListingId { get; init; }
|
||||
internal int LocalPort { get; init; }
|
||||
internal int PageSize { get; init; } = 20;
|
||||
internal TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(20);
|
||||
internal TimeSpan? RunDuration { get; init; }
|
||||
internal bool Script { get; init; }
|
||||
internal bool Json { get; init; }
|
||||
internal bool ExitAfterEcho { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class TestClientParseResult
|
||||
{
|
||||
private TestClientParseResult(TestClientOptions? options, string? error, bool showHelp)
|
||||
{
|
||||
Options = options;
|
||||
Error = error;
|
||||
ShowHelp = showHelp;
|
||||
}
|
||||
|
||||
internal TestClientOptions? Options { get; }
|
||||
internal string? Error { get; }
|
||||
internal bool ShowHelp { get; }
|
||||
internal bool Succeeded => Options is not null;
|
||||
|
||||
internal static TestClientParseResult Success(TestClientOptions options) => new(options, null, false);
|
||||
internal static TestClientParseResult Failure(string error) => new(null, error, false);
|
||||
internal static TestClientParseResult Help() => new(null, null, true);
|
||||
}
|
||||
|
||||
internal static class TestClientOptionParser
|
||||
{
|
||||
internal const string Usage = """
|
||||
Rendezvous diagnostic client
|
||||
|
||||
Usage:
|
||||
rendezvous-test-client host [options]
|
||||
rendezvous-test-client browse [options]
|
||||
rendezvous-test-client join [options]
|
||||
|
||||
Common options:
|
||||
--service URL HTTP(S) Rendezvous base URL
|
||||
--mediator IP:PORT UDP mediator endpoint
|
||||
--game ID Game scope (default: space-game)
|
||||
--environment ID Environment scope (default: development)
|
||||
--protocol NUMBER Exact gameplay protocol (default: 1)
|
||||
--region ID Region filter/publication (default: local)
|
||||
--timeout-seconds NUMBER Bounded startup/traversal stage, 1-300 (default: 20)
|
||||
--port NUMBER Caller-owned gameplay UDP port; 0 chooses one
|
||||
--page-size NUMBER Bounded browser page size, 1-100 (default: 20)
|
||||
--script Never prompt; select the first compatible listing
|
||||
--json Emit one versioned JSON event per line
|
||||
--help Show this help
|
||||
|
||||
Host options:
|
||||
--publisher-credential-env NAME Environment variable containing the credential
|
||||
--display-name TEXT Public listing name
|
||||
--build-version TEXT Public build version
|
||||
--metadata KEY=VALUE Bounded public metadata; may be repeated
|
||||
--fallback IP:PORT Optional policy-authorized dedicated fallback
|
||||
--run-seconds NUMBER Stop after 1-86400 seconds
|
||||
--exit-after-echo Stop after an authenticated ping/echo/ack exchange
|
||||
|
||||
Join options:
|
||||
--listing UUID Join an exact listing; otherwise browse/select
|
||||
|
||||
Credentials are accepted only through the named environment variable. They are never
|
||||
accepted on the command line and are never written to human or JSON output.
|
||||
""";
|
||||
|
||||
internal static TestClientParseResult Parse(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args.Length == 1 && IsHelp(args[0]))
|
||||
{
|
||||
return TestClientParseResult.Help();
|
||||
}
|
||||
if (args.Length > 64)
|
||||
{
|
||||
return TestClientParseResult.Failure("Too many command-line arguments.");
|
||||
}
|
||||
if (!TryMode(args[0], out TestClientMode mode))
|
||||
{
|
||||
return TestClientParseResult.Failure("The first argument must be host, browse, or join.");
|
||||
}
|
||||
|
||||
Uri serviceUri = new("http://127.0.0.1:5000/");
|
||||
IPEndPoint mediator = new(IPAddress.Loopback, 9050);
|
||||
string game = "space-game";
|
||||
string environment = "development";
|
||||
string region = "local";
|
||||
uint protocol = 1;
|
||||
string buildVersion = "test-client";
|
||||
string displayName = "Rendezvous diagnostic host";
|
||||
string credentialEnvironmentVariable = "RENDEZVOUS_PUBLISHER_CREDENTIAL";
|
||||
Dictionary<string, string> metadata = new(StringComparer.Ordinal);
|
||||
NetworkEndpoint? dedicatedFallback = null;
|
||||
SessionListingId? listingId = null;
|
||||
int localPort = 0;
|
||||
int pageSize = 20;
|
||||
int timeoutSeconds = 20;
|
||||
int? runSeconds = null;
|
||||
bool script = false;
|
||||
bool json = false;
|
||||
bool exitAfterEcho = false;
|
||||
HashSet<string> seen = new(StringComparer.Ordinal);
|
||||
|
||||
for (int index = 1; index < args.Length; index++)
|
||||
{
|
||||
string option = args[index];
|
||||
if (IsHelp(option))
|
||||
{
|
||||
return TestClientParseResult.Help();
|
||||
}
|
||||
if (option is "--script" or "--json" or "--exit-after-echo")
|
||||
{
|
||||
if (!seen.Add(option))
|
||||
{
|
||||
return TestClientParseResult.Failure($"Option {option} was specified more than once.");
|
||||
}
|
||||
script |= option == "--script";
|
||||
json |= option == "--json";
|
||||
exitAfterEcho |= option == "--exit-after-echo";
|
||||
continue;
|
||||
}
|
||||
if (!option.StartsWith("--", StringComparison.Ordinal)
|
||||
|| index + 1 >= args.Length)
|
||||
{
|
||||
return TestClientParseResult.Failure("Every option must use the form --name value.");
|
||||
}
|
||||
|
||||
string value = args[++index];
|
||||
if (value.Length is 0 or > 512)
|
||||
{
|
||||
return TestClientParseResult.Failure($"Option {option} has an invalid value length.");
|
||||
}
|
||||
if (option != "--metadata" && !seen.Add(option))
|
||||
{
|
||||
return TestClientParseResult.Failure($"Option {option} was specified more than once.");
|
||||
}
|
||||
|
||||
switch (option)
|
||||
{
|
||||
case "--service":
|
||||
if (!TryServiceUri(value, out serviceUri))
|
||||
{
|
||||
return TestClientParseResult.Failure("The service URL must be absolute HTTP(S), credential-free, and query-free.");
|
||||
}
|
||||
break;
|
||||
case "--mediator":
|
||||
if (!IPEndPoint.TryParse(value, out IPEndPoint? parsedMediator)
|
||||
|| parsedMediator.Port == 0)
|
||||
{
|
||||
return TestClientParseResult.Failure("The mediator must be an IP endpoint with a non-zero port.");
|
||||
}
|
||||
mediator = parsedMediator;
|
||||
break;
|
||||
case "--game":
|
||||
game = value;
|
||||
break;
|
||||
case "--environment":
|
||||
environment = value;
|
||||
break;
|
||||
case "--region":
|
||||
region = value;
|
||||
break;
|
||||
case "--protocol":
|
||||
if (!uint.TryParse(value, out protocol) || protocol == 0)
|
||||
{
|
||||
return TestClientParseResult.Failure("The protocol must be a positive integer.");
|
||||
}
|
||||
break;
|
||||
case "--build-version":
|
||||
buildVersion = value;
|
||||
break;
|
||||
case "--display-name":
|
||||
displayName = value;
|
||||
break;
|
||||
case "--publisher-credential-env":
|
||||
if (!IsEnvironmentVariableName(value))
|
||||
{
|
||||
return TestClientParseResult.Failure("The credential environment-variable name is invalid.");
|
||||
}
|
||||
credentialEnvironmentVariable = value;
|
||||
break;
|
||||
case "--metadata":
|
||||
if (!TryMetadata(value, metadata))
|
||||
{
|
||||
return TestClientParseResult.Failure("Metadata must be a unique KEY=VALUE pair with a non-empty key.");
|
||||
}
|
||||
break;
|
||||
case "--fallback":
|
||||
if (!IPEndPoint.TryParse(value, out IPEndPoint? fallbackEndpoint)
|
||||
|| fallbackEndpoint.Port == 0)
|
||||
{
|
||||
return TestClientParseResult.Failure("The fallback must be an IP endpoint with a non-zero port.");
|
||||
}
|
||||
dedicatedFallback = new NetworkEndpoint
|
||||
{
|
||||
AddressFamily = fallbackEndpoint.AddressFamily == AddressFamily.InterNetwork
|
||||
? AddressFamilyKind.Ipv4
|
||||
: AddressFamilyKind.Ipv6,
|
||||
Address = fallbackEndpoint.Address.ToString(),
|
||||
Port = fallbackEndpoint.Port,
|
||||
};
|
||||
break;
|
||||
case "--listing":
|
||||
if (!Guid.TryParse(value, out Guid parsedListing) || parsedListing == Guid.Empty)
|
||||
{
|
||||
return TestClientParseResult.Failure("The listing must be a non-empty UUID.");
|
||||
}
|
||||
listingId = new SessionListingId(parsedListing);
|
||||
break;
|
||||
case "--port":
|
||||
if (!int.TryParse(value, out localPort) || localPort is < 0 or > 65_535)
|
||||
{
|
||||
return TestClientParseResult.Failure("The local UDP port must be between 0 and 65535.");
|
||||
}
|
||||
break;
|
||||
case "--page-size":
|
||||
if (!int.TryParse(value, out pageSize)
|
||||
|| pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
|
||||
{
|
||||
return TestClientParseResult.Failure("The page size is outside the contract limit.");
|
||||
}
|
||||
break;
|
||||
case "--timeout-seconds":
|
||||
if (!int.TryParse(value, out timeoutSeconds) || timeoutSeconds is < 1 or > 300)
|
||||
{
|
||||
return TestClientParseResult.Failure("The timeout must be between 1 and 300 seconds.");
|
||||
}
|
||||
break;
|
||||
case "--run-seconds":
|
||||
if (!int.TryParse(value, out int parsedRunSeconds)
|
||||
|| parsedRunSeconds is < 1 or > 86_400)
|
||||
{
|
||||
return TestClientParseResult.Failure("The host run duration must be between 1 and 86400 seconds.");
|
||||
}
|
||||
runSeconds = parsedRunSeconds;
|
||||
break;
|
||||
default:
|
||||
return TestClientParseResult.Failure($"Unknown option {option}.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!IsSlug(game, ContractLimits.GameIdMaxCharacters)
|
||||
|| !IsSlug(environment, ContractLimits.EnvironmentIdMaxCharacters)
|
||||
|| !IsSlug(region, ContractLimits.RegionIdMaxCharacters)
|
||||
|| !ContractValidation.IsBuildVersionValid(buildVersion)
|
||||
|| !ContractValidation.IsDisplayNameValid(displayName)
|
||||
|| !ContractValidation.IsMetadataValid(metadata))
|
||||
{
|
||||
return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
|
||||
}
|
||||
if (listingId.HasValue && mode != TestClientMode.Join
|
||||
|| runSeconds.HasValue && mode != TestClientMode.Host
|
||||
|| exitAfterEcho && mode != TestClientMode.Host
|
||||
|| metadata.Count > 0 && mode != TestClientMode.Host
|
||||
|| dedicatedFallback is not null && mode != TestClientMode.Host
|
||||
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
|
||||
|| seen.Contains("--display-name") && mode != TestClientMode.Host
|
||||
|| seen.Contains("--build-version") && mode != TestClientMode.Host)
|
||||
{
|
||||
return TestClientParseResult.Failure("One or more options do not apply to the selected mode.");
|
||||
}
|
||||
|
||||
return TestClientParseResult.Success(new TestClientOptions
|
||||
{
|
||||
Mode = mode,
|
||||
ServiceUri = serviceUri,
|
||||
Mediator = mediator,
|
||||
GameId = new(game),
|
||||
EnvironmentId = new(environment),
|
||||
RegionId = new(region),
|
||||
ProtocolVersion = protocol,
|
||||
BuildVersion = buildVersion,
|
||||
DisplayName = displayName,
|
||||
PublisherCredentialEnvironmentVariable = credentialEnvironmentVariable,
|
||||
Metadata = metadata,
|
||||
DedicatedFallback = dedicatedFallback,
|
||||
ListingId = listingId,
|
||||
LocalPort = localPort,
|
||||
PageSize = pageSize,
|
||||
OperationTimeout = TimeSpan.FromSeconds(timeoutSeconds),
|
||||
RunDuration = runSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(runSeconds.Value)
|
||||
: mode == TestClientMode.Host && script
|
||||
? TimeSpan.FromSeconds(timeoutSeconds)
|
||||
: null,
|
||||
Script = script,
|
||||
Json = json,
|
||||
ExitAfterEcho = exitAfterEcho,
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryMode(string value, out TestClientMode mode) =>
|
||||
Enum.TryParse(value, true, out mode) && Enum.IsDefined(mode);
|
||||
|
||||
private static bool IsHelp(string value) => value is "--help" or "-h" or "help";
|
||||
|
||||
private static bool TryServiceUri(string value, out Uri uri)
|
||||
{
|
||||
uri = null!;
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed)
|
||||
|| parsed.Scheme is not ("http" or "https")
|
||||
|| !string.IsNullOrEmpty(parsed.UserInfo)
|
||||
|| !string.IsNullOrEmpty(parsed.Query)
|
||||
|| !string.IsNullOrEmpty(parsed.Fragment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
UriBuilder builder = new(parsed) { Path = parsed.AbsolutePath.TrimEnd('/') + "/" };
|
||||
uri = builder.Uri;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsEnvironmentVariableName(string value)
|
||||
{
|
||||
if (value.Length is 0 or > 64 || !(char.IsLetter(value[0]) || value[0] == '_'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return value.All(static character =>
|
||||
char.IsAsciiLetterOrDigit(character) || character == '_');
|
||||
}
|
||||
|
||||
private static bool TryMetadata(string value, Dictionary<string, string> metadata)
|
||||
{
|
||||
int separator = value.IndexOf('=');
|
||||
if (separator is < 1 or > ContractLimits.MetadataKeyMaxBytes
|
||||
|| metadata.Count >= ContractLimits.MetadataMaxKeys)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string key = value[..separator];
|
||||
string metadataValue = value[(separator + 1)..];
|
||||
return !string.IsNullOrWhiteSpace(key)
|
||||
&& ContractValidation.IsUtf8LengthWithin(key, ContractLimits.MetadataKeyMaxBytes)
|
||||
&& ContractValidation.IsUtf8LengthWithin(metadataValue, ContractLimits.MetadataValueMaxBytes)
|
||||
&& metadata.TryAdd(key, metadataValue);
|
||||
}
|
||||
|
||||
private static bool IsSlug(string value, int maximumCharacters) =>
|
||||
value.Length is > 0
|
||||
&& value.Length <= maximumCharacters
|
||||
&& value[0] is >= 'a' and <= 'z'
|
||||
&& value.All(static character => character is >= 'a' and <= 'z'
|
||||
or >= '0' and <= '9'
|
||||
or '-');
|
||||
}
|
||||
Reference in New Issue
Block a user