소스 검색

Interoperability tests CLI (#433)

* Update client connection to take hostname override

* Add interoperability tests CLI

* Fix typos and documentation
George Barnett 6 년 전
부모
커밋
bf4bb07d7d

+ 4 - 0
Package.swift

@@ -95,6 +95,10 @@ let package = Package(
             path: "Sources/Examples/EchoNIO"),
     .target(name: "SwiftGRPCNIOInteroperabilityTests",
             dependencies: ["SwiftGRPCNIO"]),
+    .target(name: "SwiftGRPCNIOInteroperabilityTestsCLI",
+            dependencies: [
+              "SwiftGRPCNIOInteroperabilityTests",
+              "Commander"]),
     .target(name: "Simple",
             dependencies: ["SwiftGRPC", "Commander"],
             path: "Sources/Examples/Simple"),

+ 13 - 2
Sources/SwiftGRPCNIO/GRPCClientConnection.swift

@@ -22,11 +22,22 @@ import NIOSSL
 ///
 /// Different service clients implementing `GRPCClient` may share an instance of this class.
 open class GRPCClientConnection {
+  /// Starts a connection to the given host and port.
+  ///
+  /// - Parameters:
+  ///   - host: Host to connect to.
+  ///   - port: Port on the host to connect to.
+  ///   - eventLoopGroup: Event loop group to run the connection on.
+  ///   - tlsMode: How TLS should be configured for this connection.
+  ///   - hostOverride: Value to use for TLS SNI extension; this must not be an IP address. Ignored
+  ///       if `tlsMode` is `.none`.
+  /// - Returns: A future which will be fulfilled with a connection to the remote peer.
   public static func start(
     host: String,
     port: Int,
     eventLoopGroup: EventLoopGroup,
-    tls tlsMode: TLSMode = .none
+    tls tlsMode: TLSMode = .none,
+    hostOverride: String? = nil
   ) throws -> EventLoopFuture<GRPCClientConnection> {
     // We need to capture the multiplexer from the channel initializer to store it after connection.
     let multiplexerPromise: EventLoopPromise<HTTP2StreamMultiplexer> = eventLoopGroup.next().makePromise()
@@ -35,7 +46,7 @@ open class GRPCClientConnection {
       // Enable SO_REUSEADDR.
       .channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
       .channelInitializer { channel in
-        let multiplexer = configureTLS(mode: tlsMode, channel: channel, host: host).flatMap {
+        let multiplexer = configureTLS(mode: tlsMode, channel: channel, host: hostOverride ?? host).flatMap {
           channel.configureHTTP2Pipeline(mode: .client)
         }
 

+ 1 - 1
Sources/SwiftGRPCNIO/GRPCServer.swift

@@ -135,7 +135,7 @@ public final class GRPCServer {
     return handlerAddedPromise.futureResult
   }
 
-  private let channel: Channel
+  public let channel: Channel
   private var errorDelegate: ServerErrorDelegate?
 
   private init(channel: Channel, errorDelegate: ServerErrorDelegate?) {

+ 77 - 0
Sources/SwiftGRPCNIOInteroperabilityTests/InteroperabilityTestCase.swift

@@ -28,3 +28,80 @@ public protocol InteroperabilityTest {
   /// - Throws: Any exception may be thrown to indicate an unsuccessful test.
   func run(using connection: GRPCClientConnection) throws
 }
+
+/// Test cases as listed by the [gRPC interoperability test description
+/// specification](https://github.com/grpc/grpc/blob/master/doc/interop-test-descriptions.md).
+///
+/// This is not a complete list, the following tests have not been implemented:
+/// - client_compressed_unary
+/// - server_compressed_unary
+/// - client_compressed_streaming
+/// - server_compressed_streaming
+/// - compute_engine_creds
+/// - jwt_token_creds
+/// - oauth2_auth_token
+/// - per_rpc_creds
+/// - google_default_credentials
+/// - compute_engine_channel_credentials
+///
+/// Note: a description from the specification is included inline for each test as documentation for
+/// its associated `InteroperabilityTest` class.
+public enum InteroperabilityTestCase: String, CaseIterable {
+  case emptyUnary = "empty_unary"
+  case cacheableUnary = "cacheable_unary"
+  case largeUnary = "large_unary"
+  case clientStreaming = "client_streaming"
+  case serverStreaming = "server_streaming"
+  case pingPong = "ping_pong"
+  case emptyStream = "empty_stream"
+  case customMetadata = "custom_metadata"
+  case statusCodeAndMessage = "status_code_and_message"
+  case specialStatusMessage = "special_status_message"
+  case unimplementedMethod = "unimplemented_method"
+  case unimplementedService = "unimplemented_service"
+  case cancelAfterBegin = "cancel_after_begin"
+  case cancelAfterFirstResponse = "cancel_after_first_response"
+  case timeoutOnSleepingServer = "timeout_on_sleeping_server"
+
+  public var name: String {
+    return self.rawValue
+  }
+}
+
+extension InteroperabilityTestCase {
+  /// Return a new instance of the test case.
+  public func makeTest() -> InteroperabilityTest {
+    switch self {
+    case .emptyUnary:
+      return EmptyUnary()
+    case .cacheableUnary:
+      return CacheableUnary()
+    case .largeUnary:
+      return LargeUnary()
+    case .clientStreaming:
+      return ClientStreaming()
+    case .serverStreaming:
+      return ServerStreaming()
+    case .pingPong:
+      return PingPong()
+    case .emptyStream:
+      return EmptyStream()
+    case .customMetadata:
+      return CustomMetadata()
+    case .statusCodeAndMessage:
+      return StatusCodeAndMessage()
+    case .specialStatusMessage:
+      return SpecialStatusMessage()
+    case .unimplementedMethod:
+      return UnimplementedMethod()
+    case .unimplementedService:
+      return UnimplementedService()
+    case .cancelAfterBegin:
+      return CancelAfterBegin()
+    case .cancelAfterFirstResponse:
+      return CancelAfterFirstResponse()
+    case .timeoutOnSleepingServer:
+      return TimeoutOnSleepingServer()
+    }
+  }
+}

+ 0 - 12
Sources/SwiftGRPCNIOInteroperabilityTests/InteroperabilityTestCases.swift

@@ -17,18 +17,6 @@ import Foundation
 import SwiftGRPCNIO
 import NIOHTTP1
 
-/// Missing tests:
-/// - client_compressed_unary
-/// - server_compressed_unary
-/// - client_compressed_streaming
-/// - server_compressed_streaming
-/// - compute_engine_creds
-/// - jwt_token_creds
-/// - oauth2_auth_token
-/// - per_rpc_creds
-/// - google_default_credentials
-/// - compute_engine_channel_credentials
-
 /// This test verifies that implementations support zero-size messages. Ideally, client
 /// implementations would verify that the request and response were zero bytes serialized, but
 /// this is generally prohibitive to perform, so is not required.

+ 59 - 0
Sources/SwiftGRPCNIOInteroperabilityTests/InteroperabilityTestClientConnection.swift

@@ -0,0 +1,59 @@
+/*
+ * Copyright 2019, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import Foundation
+import SwiftGRPCNIO
+import NIO
+import NIOSSL
+
+/// Makes a client connections for gRPC interoperability testing.
+///
+/// - Parameters:
+///   - host: The host to connect to.
+///   - port: The port to connect to.
+///   - eventLoopGroup: Event loop group to run client connection on.
+///   - useTLS: Whether to use TLS or not.
+/// - Returns: A future of a `GRPCClientConnection`.
+public func makeInteroperabilityTestClientConnection(
+  host: String,
+  port: Int,
+  eventLoopGroup: EventLoopGroup,
+  useTLS: Bool
+) throws -> EventLoopFuture<GRPCClientConnection> {
+  let tlsMode: GRPCClientConnection.TLSMode
+  let hostOverride: String?
+
+  if useTLS {
+    // The CA certificate has a common name of "*.test.google.fr", use the following host override
+    // so we can do full certificate verification.
+    hostOverride = "foo.test.google.fr"
+    let tlsConfiguration = TLSConfiguration.forClient(
+      trustRoots: .certificates([InteroperabilityTestCredentials.caCertificate]),
+      applicationProtocols: ["h2"])
+
+    tlsMode = .custom(try NIOSSLContext(configuration: tlsConfiguration))
+  } else {
+    hostOverride = nil
+    tlsMode = .none
+  }
+
+  return try GRPCClientConnection.start(
+    host: host,
+    port: port,
+    eventLoopGroup: eventLoopGroup,
+    tls: tlsMode,
+    hostOverride: hostOverride
+  )
+}

+ 105 - 0
Sources/SwiftGRPCNIOInteroperabilityTests/InteroperabilityTestCredentials.swift

@@ -0,0 +1,105 @@
+/*
+ * Copyright 2019, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import Foundation
+import NIOSSL
+
+/// Contains credentials used for the gRPC interoperability tests.
+///
+/// Tests are described in [interop-test-descriptions.md][1], certificates and private keys can be
+/// found in the [gRPC repository][2].
+///
+/// [1]: https://github.com/grpc/grpc/blob/master/doc/interop-test-descriptions.md
+/// [2]: https://github.com/grpc/grpc/tree/master/src/core/tsi/test_creds
+public struct InteroperabilityTestCredentials {
+  private init() { }
+
+  /// Self signed gRPC interoperability test CA certificate.
+  public static let caCertificate = try! NIOSSLCertificate(
+    buffer: Array(caCertificatePem.utf8CString),
+    format: .pem)
+
+  /// gRPC interoperability test server certificate.
+  ///
+  /// Note: the specification refers to the certificate and key as "server1", this name is carried
+  /// across here.
+  public static let server1Certificate = try! NIOSSLCertificate(
+    buffer: Array(server1CertificatePem.utf8CString),
+    format: .pem)
+
+  /// gRPC interoperability test server private key.
+  ///
+  /// Note: the specification refers to the certificate and key as "server1", this name is carried
+  /// across here.
+  public static let server1Key = try! NIOSSLPrivateKey(
+    buffer: Array(server1KeyPem.utf8CString),
+    format: .pem)
+
+  private static let caCertificatePem = """
+    -----BEGIN CERTIFICATE-----
+    MIICSjCCAbOgAwIBAgIJAJHGGR4dGioHMA0GCSqGSIb3DQEBCwUAMFYxCzAJBgNV
+    BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
+    aWRnaXRzIFB0eSBMdGQxDzANBgNVBAMTBnRlc3RjYTAeFw0xNDExMTEyMjMxMjla
+    Fw0yNDExMDgyMjMxMjlaMFYxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0
+    YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxDzANBgNVBAMT
+    BnRlc3RjYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAwEDfBV5MYdlHVHJ7
+    +L4nxrZy7mBfAVXpOc5vMYztssUI7mL2/iYujiIXM+weZYNTEpLdjyJdu7R5gGUu
+    g1jSVK/EPHfc74O7AyZU34PNIP4Sh33N+/A5YexrNgJlPY+E3GdVYi4ldWJjgkAd
+    Qah2PH5ACLrIIC6tRka9hcaBlIECAwEAAaMgMB4wDAYDVR0TBAUwAwEB/zAOBgNV
+    HQ8BAf8EBAMCAgQwDQYJKoZIhvcNAQELBQADgYEAHzC7jdYlzAVmddi/gdAeKPau
+    sPBG/C2HCWqHzpCUHcKuvMzDVkY/MP2o6JIW2DBbY64bO/FceExhjcykgaYtCH/m
+    oIU63+CFOTtR7otyQAWHqXa7q4SbCDlG7DyRFxqG0txPtGvy12lgldA2+RgcigQG
+    Dfcog5wrJytaQ6UA0wE=
+    -----END CERTIFICATE-----
+    """
+
+  private static let server1CertificatePem = """
+    -----BEGIN CERTIFICATE-----
+    MIICnDCCAgWgAwIBAgIBBzANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJBVTET
+    MBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQ
+    dHkgTHRkMQ8wDQYDVQQDEwZ0ZXN0Y2EwHhcNMTUxMTA0MDIyMDI0WhcNMjUxMTAx
+    MDIyMDI0WjBlMQswCQYDVQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNV
+    BAcTB0NoaWNhZ28xFTATBgNVBAoTDEV4YW1wbGUsIENvLjEaMBgGA1UEAxQRKi50
+    ZXN0Lmdvb2dsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOHDFSco
+    LCVJpYDDM4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1Bg
+    zkWF+slf3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd
+    9N8YwbBYAckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAGjazBpMAkGA1UdEwQCMAAw
+    CwYDVR0PBAQDAgXgME8GA1UdEQRIMEaCECoudGVzdC5nb29nbGUuZnKCGHdhdGVy
+    em9vaS50ZXN0Lmdvb2dsZS5iZYISKi50ZXN0LnlvdXR1YmUuY29thwTAqAEDMA0G
+    CSqGSIb3DQEBCwUAA4GBAJFXVifQNub1LUP4JlnX5lXNlo8FxZ2a12AFQs+bzoJ6
+    hM044EDjqyxUqSbVePK0ni3w1fHQB5rY9yYC5f8G7aqqTY1QOhoUk8ZTSTRpnkTh
+    y4jjdvTZeLDVBlueZUTDRmy2feY5aZIU18vFDK08dTG0A87pppuv1LNIR3loveU8
+    -----END CERTIFICATE-----
+    """
+
+  private static let server1KeyPem = """
+    -----BEGIN PRIVATE KEY-----
+    MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAOHDFScoLCVJpYDD
+    M4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1BgzkWF+slf
+    3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd9N8YwbBY
+    AckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAECgYAn7qGnM2vbjJNBm0VZCkOkTIWm
+    V10okw7EPJrdL2mkre9NasghNXbE1y5zDshx5Nt3KsazKOxTT8d0Jwh/3KbaN+YY
+    tTCbKGW0pXDRBhwUHRcuRzScjli8Rih5UOCiZkhefUTcRb6xIhZJuQy71tjaSy0p
+    dHZRmYyBYO2YEQ8xoQJBAPrJPhMBkzmEYFtyIEqAxQ/o/A6E+E4w8i+KM7nQCK7q
+    K4JXzyXVAjLfyBZWHGM2uro/fjqPggGD6QH1qXCkI4MCQQDmdKeb2TrKRh5BY1LR
+    81aJGKcJ2XbcDu6wMZK4oqWbTX2KiYn9GB0woM6nSr/Y6iy1u145YzYxEV/iMwff
+    DJULAkB8B2MnyzOg0pNFJqBJuH29bKCcHa8gHJzqXhNO5lAlEbMK95p/P2Wi+4Hd
+    aiEIAF1BF326QJcvYKmwSmrORp85AkAlSNxRJ50OWrfMZnBgzVjDx3xG6KsFQVk2
+    ol6VhqL6dFgKUORFUWBvnKSyhjJxurlPEahV6oo6+A+mPhFY8eUvAkAZQyTdupP3
+    XEFQKctGz+9+gKkemDp7LBBMEMBXrGTLPhpEfcjv/7KPdnFHYmhYeBTBnuVmTVWe
+    F98XJ7tIFfJq
+    -----END PRIVATE KEY-----
+    """
+}

+ 68 - 0
Sources/SwiftGRPCNIOInteroperabilityTests/InteroperabilityTestServer.swift

@@ -0,0 +1,68 @@
+/*
+ * Copyright 2019, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import Foundation
+import SwiftGRPCNIO
+import NIO
+import NIOSSL
+
+/// Makes a server for gRPC interoperability testing.
+///
+/// - Parameters:
+///   - host: The host to bind the server socket to, defaults to "localhost".
+///   - port: The port to bind the server socket to.
+///   - eventLoopGroup: Event loop group to run the server on.
+///   - serviceProviders: Service providers to handle requests with, defaults to provider for the
+///     "Test" service.
+///   - useTLS: Whether to use TLS or not. If `true` then the server will use the "server1"
+///     certificate and CA as set out in the interoperability test specification. The common name
+///     is "*.test.google.fr"; clients should set their hostname override accordingly.
+/// - Returns: A future `GRPCServer` configured to serve the test service.
+public func makeInteroperabilityTestServer(
+  host: String = "localhost",
+  port: Int,
+  eventLoopGroup: EventLoopGroup,
+  serviceProviders: [CallHandlerProvider] = [TestServiceProvider_NIO()],
+  useTLS: Bool
+) throws -> EventLoopFuture<GRPCServer> {
+  let tlsMode: GRPCServer.TLSMode
+
+  if useTLS {
+    print("Using the gRPC interop testing CA for TLS; clients should expect the host to be '*.test.google.fr'")
+
+    let caCert = InteroperabilityTestCredentials.caCertificate
+    let serverCert = InteroperabilityTestCredentials.server1Certificate
+    let serverKey = InteroperabilityTestCredentials.server1Key
+
+    let tlsConfiguration = TLSConfiguration.forServer(
+      certificateChain: [.certificate(serverCert)],
+      privateKey: .privateKey(serverKey),
+      trustRoots: .certificates([caCert]),
+      applicationProtocols: ["h2"]
+    )
+
+    tlsMode = .custom(try NIOSSLContext(configuration: tlsConfiguration))
+  } else {
+    tlsMode = .none
+  }
+
+  return try GRPCServer.start(
+    hostname: host,
+    port: port,
+    eventLoopGroup: eventLoopGroup,
+    serviceProviders: serviceProviders,
+    tls: tlsMode
+  )
+}

+ 205 - 0
Sources/SwiftGRPCNIOInteroperabilityTestsCLI/main.swift

@@ -0,0 +1,205 @@
+/*
+ * Copyright 2019, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import Foundation
+import SwiftGRPCNIO
+import NIO
+import NIOSSL
+import SwiftGRPCNIOInteroperabilityTests
+import Commander
+
+enum InteroperabilityTestError: LocalizedError {
+  case testNotFound(String)
+  case testFailed(Error)
+
+  var errorDescription: String? {
+    switch self {
+    case .testNotFound(let name):
+      return "No test named '\(name)' was found"
+
+    case .testFailed(let error):
+      return "Test failed with error: \(error)"
+    }
+  }
+}
+
+/// Runs the test instance using the given connection.
+///
+/// Success or failure is indicated by the lack or presence of thrown errors, respectively.
+///
+/// - Parameters:
+///   - instance: `InteroperabilityTest` instance to run.
+///   - name: the name of the test, use for logging only.
+///   - connection: client connection to use for running the test.
+/// - Throws: `InteroperabilityTestError` if the test fails.
+func runTest(_ instance: InteroperabilityTest, name: String, connection: GRPCClientConnection) throws {
+  do {
+    print("Running '\(name)' ... ", terminator: "")
+    try instance.run(using: connection)
+    print("PASSED")
+  } catch {
+    print("FAILED")
+    throw InteroperabilityTestError.testFailed(error)
+  }
+}
+
+/// Creates a new `InteroperabilityTest` instance with the given name, or throws an
+/// `InteroperabilityTestError` if no test matches the given name. Implemented test names can be
+/// found by running the `list_tests` target.
+func makeRunnableTest(name: String) throws -> InteroperabilityTest {
+  guard let testCase = InteroperabilityTestCase(rawValue: name) else {
+    throw InteroperabilityTestError.testNotFound(name)
+  }
+
+  return testCase.makeTest()
+}
+
+/// Runs the given block and exits with code 1 if the block throws an error.
+///
+/// The "Commander" CLI elides thrown errors in favour of its own. This function is intended purely
+/// to work around this limitation by printing any errors before exiting.
+func exitOnThrow<T>(block: () throws -> T) -> T {
+  do {
+    return try block()
+  } catch {
+    print(error)
+    exit(1)
+  }
+}
+
+// MARK: - Optional extensions for Commander
+
+// "Commander" doesn't allow us to have no value for an `Option` and using a sentinel value to
+// indicate a lack of value isn't very Swift-y when we have `Optional`.
+
+extension Optional: CustomStringConvertible where Wrapped: ArgumentConvertible {
+  public var description: String {
+    guard let value = self else {
+      return "None"
+    }
+    return "Some(\(value))"
+  }
+}
+
+extension Optional: ArgumentConvertible where Wrapped: ArgumentConvertible {
+  public init(parser: ArgumentParser) throws {
+    if let wrapped = parser.shift() as? Wrapped {
+      self = wrapped
+    } else {
+      self = .none
+    }
+  }
+}
+
+// MARK: - Command line options and "main".
+
+let serverHostOption = Option(
+  "server_host",
+  default: "localhost",
+  description: "The server host to connect to.")
+
+let serverPortOption = Option(
+  "server_port",
+  default: 8080,
+  description: "The server port to connect to.")
+
+let testCaseOption = Option(
+  "test_case",
+  default: InteroperabilityTestCase.emptyUnary.name,
+  description: "The name of the test case to execute.")
+
+/// The spec requires a string (as opposed to having a flag) to indicate whether TLS is enabled or
+/// disabled.
+let useTLSOption = Option(
+  "use_tls",
+  default: "false",
+  description: "Whether to use an encrypted or plaintext connection (true|false).") { value in
+  let lowercased = value.lowercased()
+  switch lowercased {
+  case "true", "false":
+    return lowercased
+  default:
+    throw ArgumentError.invalidType(value: value, type: "boolean", argument: "use_tls")
+  }
+}
+
+let portOption = Option(
+  "port",
+  default: 8080,
+  description: "The port to listen on.")
+
+let group = Group { group in
+  group.command(
+    "run_test",
+    serverHostOption,
+    serverPortOption,
+    useTLSOption,
+    testCaseOption,
+    description: "Run a single test. See 'list_tests' for available test names."
+  ) { host, port, useTLS, testCaseName in
+    let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
+    defer {
+      try? eventLoopGroup.syncShutdownGracefully()
+    }
+
+    exitOnThrow {
+      let instance = try makeRunnableTest(name: testCaseName)
+      let connection = try makeInteroperabilityTestClientConnection(
+        host: host,
+        port: port,
+        eventLoopGroup: eventLoopGroup,
+        useTLS: useTLS == "true").wait()
+      try runTest(instance, name: testCaseName, connection: connection)
+    }
+  }
+
+  group.command(
+    "start_server",
+    portOption,
+    useTLSOption,
+    description: "Starts the test server."
+  ) { port, useTls in
+    let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
+    defer {
+      try? eventLoopGroup.syncShutdownGracefully()
+    }
+
+    let server = exitOnThrow {
+      return try makeInteroperabilityTestServer(
+        host: "localhost",
+        port: port,
+        eventLoopGroup: eventLoopGroup,
+        useTLS: useTls == "true")
+    }
+
+    server.map { $0.channel.localAddress?.port }.whenSuccess {
+      print("Server started on port \($0!)")
+    }
+
+    // We never call close; run until we get killed.
+    try server.flatMap { $0.onClose }.wait()
+  }
+
+  group.command(
+    "list_tests",
+    description: "List available test case names."
+  ) {
+    InteroperabilityTestCase.allCases.forEach {
+      print($0.name)
+    }
+  }
+}
+
+group.run()