You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by ra...@apache.org on 2022/01/24 15:54:05 UTC

[trafficcontrol] branch master updated: TR Ultimate Test Harness - HTTP Load Tests (#6520)

This is an automated email from the ASF dual-hosted git repository.

rawlin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new cf08c90  TR Ultimate Test Harness - HTTP Load Tests (#6520)
cf08c90 is described below

commit cf08c901a36d5225e1d4cf8f1897dc005a8cc875
Author: Zach Hoffman <zr...@apache.org>
AuthorDate: Mon Jan 24 07:53:46 2022 -0800

    TR Ultimate Test Harness - HTTP Load Tests (#6520)
    
    * TR Ultimate Test Harness - HTTP Load Tests
    
    * CDN in a Box configuration for the TRU Test Harness
    
    * Fix whitespace and remove semicolon line endings
    
    * Rename BenchmarkTime to BenchmarkSeconds
    
    * parameters -> parameter
    
    * Only check for HTTP status code 302
    
    * Remove unused function
    
    * Handle errors before asserting string type
    
    * Time out requests after 10 seconds
    
    * Use lib/go-log only instead of golang log library directly
    
    * Match test name with direectory name
    
    * Import CDN in a Box CA certificate
    
    * Allow insecure connection to Traffic Ops if TO_INSECURE is set
    
    * Use Traffic Ops timeout from TO_TIMEOUT
    
    * Initialize logging
    
    * Warn the user when a Traffic Router's Server Interface is skipped
    
    * ServerInterfaceInfo.GetDefaultAddressOrCIDR(): Accept IP addresses too, not just CIDRs
    
    * ServerInterfaceInfo.GetDefaultAddress() is now guaranteed to not include subnets
---
 .../cdn-in-a-box/docker-compose.tr-load-tests.yml  |  51 +++
 .../cdn-in-a-box/traffic_ops/to-access.sh          |   5 +-
 .../traffic_router_load_test/Dockerfile            |  65 +++
 .../cdn-in-a-box/traffic_router_load_test/run.sh   |  45 ++
 lib/go-tc/servers.go                               |  30 +-
 lib/go-tc/traffic_router.go                        |  35 +-
 traffic_router/core/src/test/resources/czmap.json  |   8 +-
 traffic_router/ultimate-test-harness/README.md     |  20 +
 traffic_router/ultimate-test-harness/http_test.go  | 495 +++++++++++++++++++++
 9 files changed, 744 insertions(+), 10 deletions(-)

diff --git a/infrastructure/cdn-in-a-box/docker-compose.tr-load-tests.yml b/infrastructure/cdn-in-a-box/docker-compose.tr-load-tests.yml
new file mode 100644
index 0000000..aeff4a8
--- /dev/null
+++ b/infrastructure/cdn-in-a-box/docker-compose.tr-load-tests.yml
@@ -0,0 +1,51 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+# This compose file runs the traffic ops integration tests assuming
+# there is already a trafficops docker instance. When using docker,
+# make sure any container rpms you need are updated. Below is an
+# example of how to run the main compose with this file:
+#
+# docker-compose -f docker-compose.yml -f docker-compose.traffic-ops-test.yml up -d edge enroller dns db smtp trafficops trafficvault integration
+# docker-compose -f docker-compose.traffic-ops-test.yml logs -f integration
+
+---
+version: '3.8'
+
+services:
+  load-tests:
+    build:
+      context: ../..
+      dockerfile: infrastructure/cdn-in-a-box/traffic_router_load_test/Dockerfile
+    hostname: load-tests
+    env_file:
+    - variables.env
+    volumes:
+      - shared:/shared
+    domainname: infra.ciab.test
+
+volumes:
+  schemas:
+    external: false
+  shared:
+    external: false
+  traffic_ops_data:
+    external: false
+  content:
+    external: false
+  ca:
+    external: false
diff --git a/infrastructure/cdn-in-a-box/traffic_ops/to-access.sh b/infrastructure/cdn-in-a-box/traffic_ops/to-access.sh
index 0eab594..cfd539d 100755
--- a/infrastructure/cdn-in-a-box/traffic_ops/to-access.sh
+++ b/infrastructure/cdn-in-a-box/traffic_ops/to-access.sh
@@ -183,11 +183,14 @@ to-enroll() {
 	export MY_NET_INTERFACE='eth0'
 	export MY_DOMAINNAME="$(dnsdomainname)"
 	MY_IP="$(ifconfig $MY_NET_INTERFACE | grep 'inet ' | tr -s ' ' | cut -d ' ' -f 3)"
-	export MY_IP=${MY_IP#"addr:"}
+	export MY_IP="${MY_IP#"addr:"}/24"
 	export MY_GATEWAY="$(route -n | grep $MY_NET_INTERFACE | grep -E '^0\.0\.0\.0' | tr -s ' ' | cut -d ' ' -f2)"
 	MY_NETMASK="$(ifconfig $MY_NET_INTERFACE | grep 'inet ' | tr -s ' ' | cut -d ' ' -f 5)"
 	export MY_NETMASK=${MY_NETMASK#"Mask:"}
 	export MY_IP6_ADDRESS="$(ifconfig $MY_NET_INTERFACE | grep inet6 | grep -i global | sed 's/addr://' | awk '{ print $2 }')"
+	if [[ "$MY_IP6_ADDRESS" != */64 ]]; then
+		MY_IP6_ADDRESS="${MY_IP6_ADDRESS}/64"
+	fi
 	export MY_IP6_GATEWAY="$(route -n6 | grep UG | awk '{print $2}')"
 
 	case "$serverType" in
diff --git a/infrastructure/cdn-in-a-box/traffic_router_load_test/Dockerfile b/infrastructure/cdn-in-a-box/traffic_router_load_test/Dockerfile
new file mode 100644
index 0000000..e6e418c
--- /dev/null
+++ b/infrastructure/cdn-in-a-box/traffic_router_load_test/Dockerfile
@@ -0,0 +1,65 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+
+FROM alpine:3.14 AS load-test-builder
+
+COPY GO_VERSION /
+RUN set -o errexit; \
+    go_version=$(cat /GO_VERSION); \
+    wget -O go.tar.gz https://dl.google.com/go/go${go_version}.linux-amd64.tar.gz; \
+    tar -C /usr/local -xvzf go.tar.gz; \
+    ln -s /usr/local/go/bin/go /usr/bin/go; \
+    rm go.tar.gz; \
+    architecture=$(uname -m); \
+    mkdir lib64; \
+    # Use musl libc where the go binary expects glibc
+    # Less-generalized: ln -s /lib/ld-musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2
+    ln -s /lib/ld-musl-${architecture}.so.[0-9] /lib64/ld-linux-${architecture//_/-}.so.2; \
+    # Test the go binary
+    go version
+ENV GOPATH=/go CGO_ENABLED=0
+ENV PATH="${PATH}:${GOPATH}/bin"
+ARG TC_DIR=${GOPATH}/src/github.com/apache/trafficcontrol
+
+COPY ./lib/ ${TC_DIR}/lib/
+COPY ./traffic_ops/toclientlib/ ${TC_DIR}/traffic_ops/toclientlib/
+COPY ./traffic_ops/v4-client/ ${TC_DIR}/traffic_ops/v4-client/
+COPY ./go.mod ./go.sum ${TC_DIR}/
+COPY ./traffic_router/ultimate-test-harness ${TC_DIR}/traffic_router/ultimate-test-harness
+
+RUN cd ${TC_DIR}/traffic_router/ultimate-test-harness && \
+    go test -c
+
+FROM alpine:3.14 AS load-test
+
+RUN apk add --no-cache \
+    # for to-access.sh
+    bash \
+    # contains dig, required by to-access.sh
+    bind-tools \
+    # to recognize the CDN in a Box CA certificate
+    ca-certificates
+
+WORKDIR /opt/load-test/app
+COPY --from=load-test-builder /go/src/github.com/apache/trafficcontrol/traffic_router/ultimate-test-harness .
+
+COPY ./infrastructure/cdn-in-a-box/traffic_ops/to-access.sh /
+COPY infrastructure/cdn-in-a-box/dns/set-dns.sh \
+     infrastructure/cdn-in-a-box/dns/insert-self-into-dns.sh \
+     infrastructure/cdn-in-a-box/traffic_router_load_test/run.sh /usr/local/bin/
+
+CMD run.sh
diff --git a/infrastructure/cdn-in-a-box/traffic_router_load_test/run.sh b/infrastructure/cdn-in-a-box/traffic_router_load_test/run.sh
new file mode 100755
index 0000000..0d297ef
--- /dev/null
+++ b/infrastructure/cdn-in-a-box/traffic_router_load_test/run.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+
+# Check that env vars are set
+envvars=( DB_SERVER DB_PORT DB_ROOT_PASS DB_USER DB_USER_PASS ADMIN_USER ADMIN_PASS)
+set -ex
+for v in $envvars
+do
+	if [[ -z "${!v}" ]]; then echo "$v is unset"; exit 1; fi
+done
+
+set-dns.sh
+insert-self-into-dns.sh
+source /to-access.sh
+
+# Source the CIAB-CA shared SSL environment
+until [[ -v 'X509_GENERATION_COMPLETE' ]]; do
+	echo 'Waiting on X509 vars to be defined'
+	sleep 1
+	if [[ ! -e "$X509_CA_ENV_FILE" ]]; then
+		continue
+	fi
+	source "$X509_CA_ENV_FILE"
+done
+
+# Copy the CIAB-CA certificate to the ca-certificates directory so it can be added to the trust store
+cp "$X509_CA_CERT_FULL_CHAIN_FILE" /usr/local/share/ca-certificates/
+update-ca-certificates
+
+./ultimate-test-harness.test
diff --git a/lib/go-tc/servers.go b/lib/go-tc/servers.go
index 557ca89..edd2d5d 100644
--- a/lib/go-tc/servers.go
+++ b/lib/go-tc/servers.go
@@ -132,15 +132,33 @@ type ServerInterfaceInfoV40 struct {
 	RouterPortName string `json:"routerPortName" db:"router_port_name"`
 }
 
-// GetDefaultAddress returns the IPv4 and IPv6 service addresses of the interface.
+// GetDefaultAddressOrCIDR returns the IPv4 and IPv6 service addresses of the interface.
 func (i *ServerInterfaceInfo) GetDefaultAddress() (string, string) {
-	var ipv4 string
-	var ipv6 string
+	ipv4, ipv6 := i.GetDefaultAddressOrCIDR()
+	address, _, err := net.ParseCIDR(ipv4)
+	if address != nil && err == nil {
+		ipv4 = address.String()
+	}
+	address, _, err = net.ParseCIDR(ipv6)
+	if address != nil && err == nil {
+		ipv6 = address.String()
+	}
+	return ipv4, ipv6
+}
+
+// GetDefaultAddressOrCIDR returns the IPv4 and IPv6 service addresses of the interface,
+// including a subnet, if one exists.
+func (i *ServerInterfaceInfo) GetDefaultAddressOrCIDR() (string, string) {
+	var ipv4, ipv6 string
+	var err error
 	for _, ip := range i.IPAddresses {
 		if ip.ServiceAddress {
-			address, _, err := net.ParseCIDR(ip.Address)
-			if err != nil || address == nil {
-				continue
+			address := net.ParseIP(ip.Address)
+			if address == nil {
+				address, _, err = net.ParseCIDR(ip.Address)
+				if err != nil || address == nil {
+					continue
+				}
 			}
 			if address.To4() != nil {
 				ipv4 = ip.Address
diff --git a/lib/go-tc/traffic_router.go b/lib/go-tc/traffic_router.go
index d0667cb..5421644 100644
--- a/lib/go-tc/traffic_router.go
+++ b/lib/go-tc/traffic_router.go
@@ -23,6 +23,39 @@ import (
 	"time"
 )
 
+// CoverageZonePollingPrefix is the prefix of all Names of Parameters used to define
+// coverage zone polling parameters.
+const CoverageZonePollingPrefix = "coveragezone.polling."
+
+const CoverageZonePollingURL = CoverageZonePollingPrefix + "url"
+
+type CoverageZoneLocation struct {
+	Network  []string `json:"network,omitempty"`
+	Network6 []string `json:"network6,omitempty"`
+}
+
+func (c *CoverageZoneLocation) GetFirstIPAddressOfType(isIPv4 bool) string {
+	var network []string
+	if isIPv4 {
+		network = c.Network
+	} else {
+		network = c.Network6
+	}
+	if len(network) < 1 {
+		return ""
+	}
+	return network[0]
+}
+
+// CoverageZoneFile is used for unmarshalling a Coverage Zone File.
+type CoverageZoneFile struct {
+	CoverageZones map[string]CoverageZoneLocation `json:"coverageZones,omitempty"`
+}
+
+// X_MM_CLIENT_IP is an optional HTTP header that causes Traffic Router to use its value
+// as the client IP address.
+const X_MM_CLIENT_IP = "X-MM-Client-IP"
+
 // SOA (Start of Authority record) defines the SOA record for the CDN's
 // top-level domain.
 type SOA struct {
@@ -244,7 +277,7 @@ func GetVIPInterface(ts TrafficServer) ServerInterfaceInfo {
 // Deprecated: LegacyTrafficServer is deprecated.
 func (ts *TrafficServer) ToLegacyServer() LegacyTrafficServer {
 	vipInterface := GetVIPInterface(*ts)
-	ipv4, ipv6 := vipInterface.GetDefaultAddress()
+	ipv4, ipv6 := vipInterface.GetDefaultAddressOrCIDR()
 
 	return LegacyTrafficServer{
 		Profile:          ts.Profile,
diff --git a/traffic_router/core/src/test/resources/czmap.json b/traffic_router/core/src/test/resources/czmap.json
index 840511a..e550c4e 100644
--- a/traffic_router/core/src/test/resources/czmap.json
+++ b/traffic_router/core/src/test/resources/czmap.json
@@ -1,7 +1,7 @@
 {
 	"coverageZones":
 	{
-		"cache-group-01":
+		"CDN_in_a_Box_Edge":
 		{
 			"network6":
 			[
@@ -12,7 +12,11 @@
 			[
 				"192.168.8.0/24",
 				"192.168.9.0/24"
-			]
+			],
+			"coordinates": {
+				"latitude": 38.897663,
+				"longitude": -77.036574
+			}
 		}
 	}
 }
diff --git a/traffic_router/ultimate-test-harness/README.md b/traffic_router/ultimate-test-harness/README.md
new file mode 100644
index 0000000..b751c67
--- /dev/null
+++ b/traffic_router/ultimate-test-harness/README.md
@@ -0,0 +1,20 @@
+<!--
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you 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.
+-->
+
+This is the Traffic Router Ultimate Test Harness, a test to verify Traffic Router performance.
diff --git a/traffic_router/ultimate-test-harness/http_test.go b/traffic_router/ultimate-test-harness/http_test.go
new file mode 100644
index 0000000..820473e
--- /dev/null
+++ b/traffic_router/ultimate-test-harness/http_test.go
@@ -0,0 +1,495 @@
+package main
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 (
+	"crypto/tls"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"math/rand"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"strconv"
+	"strings"
+	"testing"
+	"text/tabwriter"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-log"
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+
+	"github.com/kelseyhightower/envconfig"
+)
+
+const (
+	UserAgent = "Traffic Router Load Tests"
+)
+
+type TOConfig struct {
+	TOURL      string `required:"true" envconfig:"TO_URL"`
+	TOUser     string `required:"true" envconfig:"TO_USER"`
+	TOPassword string `required:"true" envconfig:"TO_PASSWORD"`
+	TOInsecure bool   `default:"true"  envconfig:"TO_INSECURE"`
+	TOTimeout  int    `default:"30"    envconfig:"TO_TIMEOUT"`
+}
+
+type TRDetails struct {
+	Hostname           string
+	IPAddresses        []string
+	ClientIP           string
+	ClientIPAddressMap IPAddressMap
+	Port               int
+	DSHost             string
+	CDNName            tc.CDNName
+}
+
+type IPAddressMap struct {
+	Zones []string
+	Map   map[string]tc.CoverageZoneLocation
+}
+
+type Benchmark struct {
+	RequestsPerSecondThreshold int
+	BenchmarkSeconds           int
+	ThreadCount                int
+	ClientIP                   *string
+	PathCount                  int
+	MaxPathLength              int
+	DSType                     tc.Type
+	TrafficRouters             []TRDetails
+	CoverageZoneLocation       string
+}
+
+var (
+	toConfig  TOConfig
+	toSession *client.Session
+	count     int
+)
+
+func getTOConfig(t *testing.T) {
+	err := envconfig.Process("", &toConfig)
+	if err != nil {
+		t.Fatalf("reading configuration from the environment: %s", err.Error())
+	}
+}
+
+var ipv4Only *bool
+var ipv6Only *bool
+var cdnName *string
+var deliveryServiceName *string
+var trafficRouterName *string
+var clientIPAddress *string
+var useCoverageZone *bool
+var coverageZoneLocation *string
+var requestsPerSecondThreshold *int
+var benchmarkTime *int
+var threadCount *int
+var pathCount *int
+var maxPathLength *int
+
+func init() {
+	rand.Seed(time.Now().UnixNano())
+	ipv4Only = flag.Bool("4", false, "test IPv4 addresses only")
+	ipv6Only = flag.Bool("6", false, "test IPv4 addresses only")
+	cdnName = flag.String("cdn", "", "the name of a CDN to search for Delivery Services")
+	deliveryServiceName = flag.String("ds", "", "the name (XMLID) of a Delivery Service to use for tests")
+	trafficRouterName = flag.String("hostname", "", "the hostname of a Traffic Router to use")
+	clientIPAddress = flag.String("ip_address", "", "spoof your client IP address to Traffic Router's geolocation")
+	useCoverageZone = flag.Bool("coverage_zone", false, "whether to use an IP address from the Traffic Router's Coverage Zone File")
+	coverageZoneLocation = flag.String("coverage_zone_location", "", "the Coverage Zone location to use (implies coverage_zone=true)")
+	requestsPerSecondThreshold = flag.Int("requests_threshold", 8000, "the minimum number of requests per second a Traffic Router must successfully respond to")
+	benchmarkTime = flag.Int("benchmark_time", 10, "the duration of each load test in seconds")
+	threadCount = flag.Int("thread_count", 12, "the number of threads to use for each test")
+	pathCount = flag.Int("path_count", 10000, "the number of paths to generate for use in requests to Delivery Services")
+	maxPathLength = flag.Int("max_path_length", 100, "the maximum length for each generated path")
+
+	log.Init(os.Stderr, os.Stderr, os.Stderr, os.Stderr, os.Stderr)
+}
+
+func getCoverageZoneURL(cdnName tc.CDNName) (string, error) {
+	snapshot, _, err := toSession.GetCRConfig(string(cdnName), client.RequestOptions{})
+	if err != nil {
+		return "", fmt.Errorf("getting the Snapshot of CDN '%s': %s", cdnName, err.Error())
+	}
+	czPollingURLInterface, ok := snapshot.Response.Config[tc.CoverageZonePollingURL]
+	if !ok {
+		return "", fmt.Errorf("parameter %s was not found in the Snapshot of CDN '%s'", tc.CoverageZonePollingURL, cdnName)
+	}
+	czPollingURL := czPollingURLInterface.(string)
+	return czPollingURL, nil
+}
+
+func getCoverageZoneFile(czPollingURL string) (tc.CoverageZoneFile, error) {
+	czMap := tc.CoverageZoneFile{}
+	czMapRequest, err := http.NewRequest("GET", czPollingURL, nil)
+	if err != nil {
+		return czMap, fmt.Errorf("creating HTTP request for URL %s: %s", czPollingURL, err.Error())
+	}
+	czMapRequest.Header.Set("User-Agent", UserAgent)
+	httpClient := http.Client{Timeout: time.Duration(toConfig.TOTimeout) * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: toConfig.TOInsecure}}}
+	czMapResponse, err := httpClient.Do(czMapRequest)
+	if err != nil {
+		return czMap, fmt.Errorf("getting Coverage Zone File from URL %s: %s", czPollingURL, err.Error())
+	}
+	defer log.Close(czMapResponse.Body, "closing the Coverage Zone File response")
+	czMapBytes, err := ioutil.ReadAll(czMapResponse.Body)
+	if err != nil {
+		return czMap, fmt.Errorf("reading Coverage Zone File bytes: %s", err.Error())
+	}
+	if err = json.Unmarshal(czMapBytes, &czMap); err != nil {
+		return czMap, fmt.Errorf("unmarshalling Coverage Zone Map bytes: %s", err.Error())
+	}
+	return czMap, nil
+}
+
+func (i *IPAddressMap) buildFromCoverageZoneMap(czMap tc.CoverageZoneFile) error {
+	i.Zones = make([]string, len(czMap.CoverageZones))
+	i.Map = map[string]tc.CoverageZoneLocation{}
+	zoneIndex := 0
+	for location, networks := range czMap.CoverageZones {
+		coverageZoneLocation := tc.CoverageZoneLocation{
+			Network:  make([]string, 2*len(networks.Network)),
+			Network6: make([]string, 2*len(networks.Network6)),
+		}
+		for index, ipAddress := range networks.Network {
+			_, ipNet, err := net.ParseCIDR(ipAddress)
+			if err != nil {
+				return fmt.Errorf("parsing IP address %s in CIDR notation: %s", ipAddress, err.Error())
+			}
+			coverageZoneLocation.Network[index*2] = util.FirstIP(ipNet).To4().String()
+			coverageZoneLocation.Network[index*2+1] = util.LastIP(ipNet).To4().String()
+		}
+		for index, ipAddress6 := range networks.Network6 {
+			_, ipNet, err := net.ParseCIDR(ipAddress6)
+			if err != nil {
+				return fmt.Errorf("parsing IP address %s in CIDR notation: %s", ipAddress6, err.Error())
+			}
+			coverageZoneLocation.Network6[index*2] = util.FirstIP(ipNet).To16().String()
+			coverageZoneLocation.Network6[index*2+1] = util.LastIP(ipNet).To16().String()
+		}
+		i.Map[location] = coverageZoneLocation
+		i.Zones[zoneIndex] = location
+		zoneIndex++
+	}
+	return nil
+}
+
+func buildIPAddressMap(cdnName tc.CDNName) (IPAddressMap, error) {
+	ipAddressMap := IPAddressMap{}
+	czPollingURL, err := getCoverageZoneURL(cdnName)
+	if err != nil {
+		return ipAddressMap, fmt.Errorf("getting Coverage Zone Polling URL from the Snapshot of CDN '%s': %s", cdnName, err.Error())
+	}
+	czMap, err := getCoverageZoneFile(czPollingURL)
+	if err != nil {
+		return ipAddressMap, fmt.Errorf("getting Coverage Zone File: %s", err.Error())
+	}
+	if err = ipAddressMap.buildFromCoverageZoneMap(czMap); err != nil {
+		return ipAddressMap, fmt.Errorf("building IP Address Map from Coverage Zone File: %s", err.Error())
+	}
+
+	return ipAddressMap, nil
+}
+
+func TestLoad(t *testing.T) {
+	var err error
+	if err = flag.Set("test.v", "true"); err != nil {
+		t.Errorf("settings flags 'test.v': %s", err.Error())
+	}
+	flag.Parse()
+	getTOConfig(t)
+
+	if *coverageZoneLocation != "" {
+		*useCoverageZone = true
+	}
+	ipAddressMaps := map[tc.CDNName]IPAddressMap{}
+
+	toSession, _, err = client.LoginWithAgent(toConfig.TOURL, toConfig.TOUser, toConfig.TOPassword, toConfig.TOInsecure, UserAgent, true, time.Second*time.Duration(toConfig.TOTimeout))
+	if err != nil {
+		t.Fatalf("logging into Traffic Ops server %s: %s", toConfig.TOURL, err.Error())
+	}
+
+	trafficRouters, err := getTrafficRouters(*trafficRouterName, tc.CDNName(*cdnName))
+	if err != nil {
+		t.Fatalf("could not get Traffic Routers: %s", err.Error())
+	}
+
+	var trafficRouterDetailsList []TRDetails
+	for _, trafficRouter := range trafficRouters {
+		var ipAddresses []string
+		for _, serverInterface := range trafficRouter.Interfaces {
+			if !serverInterface.Monitor {
+				log.Warnf("skipping server interface %s of Traffic Router %s because it is unmonitored", serverInterface.Name, *trafficRouter.HostName)
+				continue
+			}
+			ipv4, ipv6 := serverInterface.GetDefaultAddress()
+			if ipv4 != "" && !*ipv6Only {
+				ipAddresses = append(ipAddresses, ipv4)
+			}
+			if ipv6 != "" && !*ipv4Only {
+				ipAddresses = append(ipAddresses, "["+ipv6+"]")
+			}
+		}
+		if len(ipAddresses) < 1 {
+			log.Warnf("need at least 1 monitored service address on an interface of Traffic Router '%s' to use it for benchmarks, but %d such addresses were found", *trafficRouter.HostName, len(ipAddresses))
+			continue
+		}
+		dsTypeName := tc.DSTypeHTTP
+		httpDSes := getDSes(t, *trafficRouter.CDNID, dsTypeName, tc.DeliveryServiceName(*deliveryServiceName))
+		if len(httpDSes) < 1 {
+			log.Warnf("at least 1 Delivery Service with type '%s' is required to run HTTP load tests on Traffic Router '%s', but %d were found", dsTypeName, *trafficRouter.HostName, len(httpDSes))
+		}
+		dsURL, err := url.Parse(httpDSes[0].ExampleURLs[0])
+		if err != nil {
+			t.Fatalf("parsing Delivery Service URL %s: %s", dsURL, err.Error())
+		}
+		cdnName := tc.CDNName(*trafficRouter.CDNName)
+
+		trafficRouterDetails := TRDetails{
+			Hostname:    *trafficRouter.HostName,
+			IPAddresses: ipAddresses,
+			ClientIP:    *clientIPAddress,
+			Port:        *trafficRouter.TCPPort,
+			DSHost:      dsURL.Host,
+			CDNName:     cdnName,
+		}
+		if *useCoverageZone {
+			_, ok := ipAddressMaps[cdnName]
+			if !ok {
+				ipAddressMaps[cdnName], err = buildIPAddressMap(cdnName)
+				if err != nil {
+					t.Fatalf("building IP Address map for CDN '%s': %s", cdnName, err.Error())
+				}
+			}
+			trafficRouterDetails.ClientIPAddressMap = ipAddressMaps[cdnName]
+		}
+		trafficRouterDetailsList = append(trafficRouterDetailsList, trafficRouterDetails)
+	}
+	if len(trafficRouterDetailsList) < 1 {
+		t.Fatalf("no Traffic Router with at least 1 HTTP Delivery Service and at least 1 monitored service address was found")
+	}
+
+	benchmark := Benchmark{
+		RequestsPerSecondThreshold: *requestsPerSecondThreshold,
+		BenchmarkSeconds:           *benchmarkTime,
+		ThreadCount:                *threadCount,
+		PathCount:                  *pathCount,
+		MaxPathLength:              *maxPathLength,
+		TrafficRouters:             trafficRouterDetailsList,
+		CoverageZoneLocation:       *coverageZoneLocation,
+	}
+
+	passedTests := 0
+	failedTests := 0
+
+	fmt.Printf("Passing criteria: Routing at least %d requests per second\n", benchmark.RequestsPerSecondThreshold)
+	writer := tabwriter.NewWriter(os.Stdout, 20, 8, 1, '\t', tabwriter.AlignRight)
+	fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", "Traffic Router", "Protocol", "Delivery Service", "Passed?", "Requests Per Second", "Redirects", "Failures")
+	for trafficRouterIndex, trafficRouter := range benchmark.TrafficRouters {
+		for ipAddressIndex, ipAddress := range trafficRouter.IPAddresses {
+			trafficRouterURL := fmt.Sprintf("http://%s:%d/", ipAddress, trafficRouter.Port)
+
+			isIPv4 := strings.Contains(ipAddress, ".")
+			if trafficRouter.ClientIP == "" && trafficRouter.ClientIPAddressMap.Zones != nil {
+				if benchmark.CoverageZoneLocation != "" {
+					location := trafficRouter.ClientIPAddressMap.Map[benchmark.CoverageZoneLocation]
+					trafficRouter.ClientIP = location.GetFirstIPAddressOfType(isIPv4)
+				}
+				if trafficRouter.ClientIP == "" {
+					for _, location := range trafficRouter.ClientIPAddressMap.Map {
+						trafficRouter.ClientIP = location.GetFirstIPAddressOfType(isIPv4)
+						if trafficRouter.ClientIP != "" {
+							break
+						}
+					}
+				}
+			}
+
+			redirects, failures := 0, 0
+			redirectsChannels := make([]chan int, benchmark.ThreadCount)
+			failuresChannels := make([]chan int, benchmark.ThreadCount)
+			for threadIndex := 0; threadIndex < benchmark.ThreadCount; threadIndex++ {
+				redirectsChannels[threadIndex] = make(chan int)
+				failuresChannels[threadIndex] = make(chan int)
+				go benchmark.Run(t, redirectsChannels[threadIndex], failuresChannels[threadIndex], trafficRouterIndex, trafficRouterURL, ipAddressIndex)
+			}
+
+			for threadIndex := 0; threadIndex < benchmark.ThreadCount; threadIndex++ {
+				redirects += <-redirectsChannels[threadIndex]
+				failures += <-failuresChannels[threadIndex]
+			}
+			protocol := "IPv6"
+			if isIPv4 {
+				protocol = "IPv4"
+			}
+			var passed string
+			requestsPerSecond := redirects / benchmark.BenchmarkSeconds
+			if requestsPerSecond > benchmark.RequestsPerSecondThreshold {
+				passedTests++
+				passed = "Yes"
+			} else {
+				failedTests++
+				passed = "No"
+			}
+			fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%d\t%d\t%d\n", trafficRouter.Hostname, protocol, trafficRouter.DSHost, passed, requestsPerSecond, redirects, failures)
+			writer.Flush()
+		}
+	}
+	summary := fmt.Sprintf("%d out of %d load tests passed", passedTests, passedTests+failedTests)
+	if failedTests < 1 {
+		t.Logf(summary)
+	} else {
+		t.Fatal(summary)
+	}
+}
+
+func (b *Benchmark) Run(t *testing.T, redirectsChannel chan int, failuresChannel chan int, trafficRouterIndex int, trafficRouterURL string, ipAddressIndex int) {
+	paths := generatePaths(b.PathCount, b.MaxPathLength)
+	stopTime := time.Now().Add(time.Duration(b.BenchmarkSeconds) * time.Second)
+	redirects, failures := 0, 0
+	var req *http.Request
+	var resp *http.Response
+	var err error
+	httpClient := http.Client{
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+		Timeout: 10 * time.Second,
+	}
+	trafficRouter := b.TrafficRouters[trafficRouterIndex]
+	for time.Now().Before(stopTime) {
+		requestURL := trafficRouterURL + paths[rand.Intn(len(paths))]
+		if req, err = http.NewRequest("GET", requestURL, nil); err != nil {
+			t.Fatalf("creating GET request to Traffic Router '%s' (IP address %s): %s",
+				trafficRouter.Hostname, trafficRouter.IPAddresses[ipAddressIndex], err.Error())
+		}
+		req.Header.Set("User-Agent", UserAgent)
+		if trafficRouter.ClientIP != "" {
+			req.Header.Set(tc.X_MM_CLIENT_IP, trafficRouter.ClientIP)
+		}
+		req.Host = trafficRouter.DSHost
+		resp, err = httpClient.Do(req)
+		if err == nil && resp.StatusCode == http.StatusFound {
+			redirects++
+		} else {
+			failures++
+		}
+	}
+	redirectsChannel <- redirects
+	failuresChannel <- failures
+}
+
+func generatePaths(pathCount, maxPathLength int) []string {
+	const alphabetSize = 26 + 26 + 10
+	alphabet := make([]rune, alphabetSize)
+	index := 0
+	for char := 'A'; char <= 'Z'; char++ {
+		alphabet[index] = char
+		index++
+	}
+	for char := 'a'; char <= 'z'; char++ {
+		alphabet[index] = char
+		index++
+	}
+	for char := '0'; char <= '9'; char++ {
+		alphabet[index] = char
+		index++
+	}
+	paths := make([]string, pathCount)
+	for index = 0; index < pathCount; index++ {
+		pathLength := rand.Intn(maxPathLength)
+		generatedURL := make([]rune, pathLength)
+		for runeIndex := 0; runeIndex < pathLength; runeIndex++ {
+			generatedURL[runeIndex] = alphabet[rand.Intn(alphabetSize)]
+		}
+		paths[index] = string(generatedURL)
+	}
+	return paths
+}
+
+func getTrafficRouters(trafficRouterName string, cdnName tc.CDNName) ([]tc.ServerV40, error) {
+	requestOptions := client.RequestOptions{QueryParameters: url.Values{
+		"type":   {tc.RouterTypeName},
+		"status": {tc.CacheStatusOnline.String()},
+	}}
+	if trafficRouterName != "" {
+		requestOptions.QueryParameters.Set("hostName", trafficRouterName)
+	}
+	if cdnName != "" {
+		cdnRequestOptions := client.RequestOptions{QueryParameters: url.Values{
+			"name": {string(cdnName)},
+		}}
+		cdnResponse, _, err := toSession.GetCDNs(cdnRequestOptions)
+		if err != nil {
+			return nil, fmt.Errorf("requesting a CDN named '%s': %s", cdnName, err.Error())
+		}
+		cdns := cdnResponse.Response
+		if len(cdns) != 1 {
+			return nil, fmt.Errorf("did not find exactly 1 CDN with name '%s'", cdnName)
+		}
+		requestOptions.QueryParameters.Set("cdn", string(cdnName))
+	}
+	response, _, err := toSession.GetServers(requestOptions)
+	if err != nil {
+		return nil, fmt.Errorf("requesting %s-status Traffic Routers: %s", requestOptions.QueryParameters["status"], err.Error())
+	}
+	trafficRouters := response.Response
+	if len(trafficRouters) < 1 {
+		return trafficRouters, fmt.Errorf("no Traffic Routers were found with these criteria: %v", requestOptions.QueryParameters)
+	}
+	return trafficRouters, nil
+}
+
+func getDSes(t *testing.T, cdnId int, dsTypeName tc.DSType, dsName tc.DeliveryServiceName) []tc.DeliveryServiceV40 {
+	requestOptions := client.RequestOptions{QueryParameters: url.Values{"name": {dsTypeName.String()}}}
+	var dsType tc.Type
+	{
+		response, _, err := toSession.GetTypes(requestOptions)
+		if err != nil {
+			t.Fatalf("getting type %s: %s", requestOptions.QueryParameters["name"], err.Error())
+		}
+		types := response.Response
+		if len(types) != 1 {
+			t.Fatalf("did not find exactly 1 type with name '%s'", requestOptions.QueryParameters["name"])
+		}
+		dsType = types[0]
+	}
+
+	requestOptions = client.RequestOptions{QueryParameters: url.Values{
+		"cdn":    {strconv.Itoa(cdnId)},
+		"type":   {strconv.Itoa(dsType.ID)},
+		"status": {tc.CacheStatusOnline.String()},
+	}}
+	if dsName != "" {
+		requestOptions.QueryParameters.Set("xmlId", dsName.String())
+	}
+	response, _, err := toSession.GetDeliveryServices(requestOptions)
+	if err != nil {
+		t.Fatalf("getting Delivery Services with type '%s' (type ID %d): %s", dsType.Name, dsType.ID, err.Error())
+	}
+	httpDSes := response.Response
+	return httpDSes
+}