You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by zr...@apache.org on 2021/05/28 19:04:10 UTC

[trafficcontrol] branch master updated: Traffic Monitor Integration Test Framework (#5817)

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

zrhoffman 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 60a9ffd  Traffic Monitor Integration Test Framework (#5817)
60a9ffd is described below

commit 60a9ffdcabdeb011773a900739ec2615da47ab33
Author: Steve Hamrick <sh...@users.noreply.github.com>
AuthorDate: Fri May 28 13:03:53 2021 -0600

    Traffic Monitor Integration Test Framework (#5817)
    
    * Add tm testcaches http commands to change caches
    
    This is with the intention of adding an integration test framework
    for Traffic Monitor, which can use this tool and send commands
    to change the fake caches to test various scenarios.
    
    * Add TM client, integration test framework
    
    * Get tests passing
    
    * Test CacheStatsNew
    
    * Add build script and add licenses
    
    * Forgot script
    
    * Code review fixes
    
    * go vet
    
    * Rename test dir
    
    * Cleanup and use latest api/structs
    
    * Changelog
    
    * Fix CodeQL issues
    
    * More CodeQL
    
    * Code review fixes & code clean up
    
    * English and better build
    
    * Better build instructions
    
    * Clean up readmes
    
    * Code review fixes
    
    * Woops
    
    * Code review fixes
    
    * Missed some feedback
    
    * Didnt mean to commit this
    
    Co-authored-by: Robert Butts <ro...@gmail.com>
---
 CHANGELOG.md                                       |   1 +
 traffic_monitor/.gitignore                         |   3 +
 traffic_monitor/build.sh                           |   2 +-
 traffic_monitor/health/event.go                    |  11 +
 traffic_monitor/tests/_integration/Dockerfile      |  37 +++
 .../tests/_integration/Dockerfile_run.sh           | 165 +++++++++++++
 traffic_monitor/tests/_integration/README.md       |   9 +
 traffic_monitor/tests/_integration/build_tests.sh  |  25 ++
 traffic_monitor/tests/_integration/client_test.go  | 117 +++++++++
 .../tests/_integration/config/config.go            | 157 ++++++++++++
 .../tests/_integration/docker-compose.yml          |  99 ++++++++
 traffic_monitor/tests/_integration/kbps_test.go    |  75 ++++++
 traffic_monitor/tests/_integration/monitoring.json | 135 +++++++++++
 traffic_monitor/tests/_integration/snapshot.json   | 164 +++++++++++++
 .../_integration/tm/Dockerfile}                    |  19 +-
 .../tests/_integration/tm/Dockerfile_run.sh        |  81 +++++++
 .../tests/_integration/traffic-monitor-test.conf   |  17 ++
 .../tests/_integration/traffic_monitor_test.go     |  96 ++++++++
 traffic_monitor/tests/_integration/variables.env   |  34 +++
 traffic_monitor/tmclient/tmclient.go               | 261 ++++++++++++++++++++
 .../{.gitignore => tools/testcaches/Dockerfile}    |  16 +-
 .../testcaches/Dockerfile_run.sh}                  |  25 +-
 traffic_monitor/tools/testcaches/README.md         |  68 +++++-
 traffic_monitor/tools/testcaches/fakesrvr/cmd.go   | 221 +++++++++++++++++
 .../tools/testcaches/fakesrvr/fakesrvr.go          |   2 +-
 .../tools/testcaches/fakesrvr/server.go            |  40 +++
 .../tools/testcaches/fakesrvrdata/fakesrvrdata.go  |   1 +
 .../tools/testcaches/fakesrvrdata/run.go           |  87 ++++---
 .../tools/testcaches/fakesrvrdata/ths.go           |  27 ++-
 .../{.gitignore => tools/testto/Dockerfile}        |  16 +-
 .../{build.sh => tools/testto/Dockerfile_run.sh}   |  19 +-
 traffic_monitor/tools/testto/README.md             |  27 +++
 traffic_monitor/tools/testto/testto.go             | 267 +++++++++++++++++++++
 traffic_monitor/towrap/towrap.go                   |   2 +-
 34 files changed, 2277 insertions(+), 49 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 981b683..9375dd4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -44,6 +44,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 - t3c: bug fix to consider plugin config files for reloading remap.config
 - t3c: Change syncds so that it only warns on package version mismatch.
 - atstccfg: add ##REFETCH## support to regex_revalidate.config processing.
+- Added a Traffic Monitor integration test framework.
 
 ### Fixed
 - [#5690](https://github.com/apache/trafficcontrol/issues/5690) - Fixed github action for added/modified db migration file.
diff --git a/traffic_monitor/.gitignore b/traffic_monitor/.gitignore
index fad340b..8061584 100644
--- a/traffic_monitor/.gitignore
+++ b/traffic_monitor/.gitignore
@@ -17,3 +17,6 @@
 #
 #TM binary
 traffic_monitor
+tests/_integration/traffic_monitor_integration_test
+tools/testcaches/testcaches
+tools/testto/testto
diff --git a/traffic_monitor/build.sh b/traffic_monitor/build.sh
index baf07cc..7751f53 100755
--- a/traffic_monitor/build.sh
+++ b/traffic_monitor/build.sh
@@ -1,3 +1,4 @@
+#!/usr/bin/env 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
@@ -14,5 +15,4 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-#!/usr/bin/env bash
 go build -ldflags "-X main.GitRevision=`git rev-parse HEAD` -X main.BuildTimestamp=`date +'%Y-%M-%dT%H:%M:%S'`"
diff --git a/traffic_monitor/health/event.go b/traffic_monitor/health/event.go
index d913c35..4afec86 100644
--- a/traffic_monitor/health/event.go
+++ b/traffic_monitor/health/event.go
@@ -20,7 +20,9 @@ package health
  */
 
 import (
+	"errors"
 	"fmt"
+	"strconv"
 	"sync"
 	"time"
 
@@ -33,6 +35,15 @@ func (t Time) MarshalJSON() ([]byte, error) {
 	return []byte(fmt.Sprintf("%d", time.Time(t).Unix())), nil
 }
 
+func (t *Time) UnmarshalJSON(data []byte) error {
+	unixTime, err := strconv.ParseInt(string(data), 10, 64)
+	if err != nil {
+		return errors.New("health.Time (" + string(data) + ") must be a unix epoch integer: " + err.Error())
+	}
+	*t = Time(time.Unix(unixTime, 0))
+	return nil
+}
+
 // Event represents an event change in aggregated data. For example, a cache being marked as unavailable.
 type Event struct {
 	Time          Time   `json:"time"`
diff --git a/traffic_monitor/tests/_integration/Dockerfile b/traffic_monitor/tests/_integration/Dockerfile
new file mode 100644
index 0000000..4f8e835
--- /dev/null
+++ b/traffic_monitor/tests/_integration/Dockerfile
@@ -0,0 +1,37 @@
+# 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 a very simple Dockerfile.
+# All it does is install and start the Traffic Monitor, given a Traffic Ops to point it to.
+# It doesn't do any of the complex things the Dockerfiles in infrastructure/docker or infrastructure/cdn-in-a-box do, like inserting itself into Traffic Ops.
+# It is designed for a very simple use case, where the complex orchestration of other Traffic Control components is done elsewhere (or manually).
+
+FROM centos:8
+MAINTAINER dev@trafficcontrol.apache.org
+
+RUN dnf install -y initscripts epel-release golang glibc jq git
+ENV GOPATH=/go
+
+COPY ./tests/_integration/ /tm
+WORKDIR /tm
+
+RUN go get -u  github.com/apache/trafficcontrol/lib/go-log github.com/apache/trafficcontrol/lib/go-tc github.com/apache/trafficcontrol/traffic_monitor
+COPY . ${GOPATH}/src/github.com/apache/trafficcontrol/traffic_monitor/
+
+RUN go test -c -o /traffic_monitor_integration_test
+
+CMD ./Dockerfile_run.sh
diff --git a/traffic_monitor/tests/_integration/Dockerfile_run.sh b/traffic_monitor/tests/_integration/Dockerfile_run.sh
new file mode 100755
index 0000000..48f5932
--- /dev/null
+++ b/traffic_monitor/tests/_integration/Dockerfile_run.sh
@@ -0,0 +1,165 @@
+#!/usr/bin/env 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.
+
+# The following environment variables must be set (ordinarily by `docker run -e` arguments):
+# TO_URI
+# TO_USER
+# TO_PASS
+# CDN
+# Check that env vars are set
+envvars=(TESTTO_URI TESTTO_PORT TESTCACHES_URI TESTCACHES_PORT_START TM_URI)
+for v in $envvars; do
+  if [[ -z ${!v} ]]; then
+    echo "$v is unset"
+    exit 1
+  fi
+done
+
+TO_API_VERSION=4.0
+CFG_FILE=/tm/traffic-monitor-integration-test.cfg
+
+start() {
+  printf "DEBUG traffic_monitor_integration starting\n"
+
+  exec /traffic_monitor_integration_test -test.v -cfg $CFG_FILE
+}
+
+init() {
+  wait_for_endpoint "${TESTTO_URI}/api/${TO_API_VERSION}/servers"
+  wait_for_endpoint "${TESTCACHES_URI}:${TESTCACHES_PORT_START}/_astats"
+  wait_for_endpoint "${TM_URI}"
+  TESTCACHES_ADDRESS=$(ping testcaches -4 -c 1 | head -n 1 | grep -Eo '[0-9]+.[0-9]+.[0-9]+.[0-9]+')
+  TESTCACHES_GATEWAY=$(echo $TESTCACHES_ADDRESS | sed "s/\([0-9]\+.[0-9]\+.[0-9]\+.\)[0-9]/\11/")
+
+  jq "(.. | .address?) |= \"$TESTCACHES_ADDRESS\" | (.. | .gateway?) |= \"$TESTCACHES_GATEWAY\"" \
+    /tm/monitoring.json > /tm/monitoring.json.tmp && mv /tm/monitoring.json.tmp /tm/monitoring.json
+
+  curl -Lvsk ${TESTTO_URI}/api/${TO_API_VERSION}/cdns/fake/snapshot -X POST -d "@/tm/snapshot.json"
+
+  curl -Lvsk ${TESTTO_URI}/api/${TO_API_VERSION}/cdns/fake/configs/monitoring -X POST -d '@/tm/monitoring.json'
+
+  curl -Lvsk ${TESTTO_URI}/api/${TO_API_VERSION}/servers -X POST -d '
+[
+  {
+    "cachegroup": "foo",
+    "cachegroupId": 0,
+    "cdnId": 1,
+    "cdnName": "fake",
+    "deliveryServices": null,
+		"fqdn": "trafficmonitor.traffic-monitor-integration.test",
+    "guid": "foo",
+    "hostName": "trafficmonitor",
+    "httpsPort": null,
+    "id": 1,
+    "iloIpAddress": null,
+    "iloIpGateway": null,
+    "iloIpNetmask": null,
+    "iloPassword": null,
+    "iloUsername": null,
+    "interfaceMtu": null,
+    "interfaceName": "bond0",
+    "ip6Address": null,
+    "ip6Gateway": null,
+    "interfaces": [
+      {
+        "ipAddresses": [
+          {
+            "address": "4.0.16.239.6",
+            "gateway": "4.0.16.239.1",
+            "serviceAddress": true
+          },
+          {
+            "address": "fc01:9400:1000:8::6",
+            "gateway": "fc01:9400:1000:8::1",
+            "serviceAddress": true
+          }
+        ],
+        "maxBandwidth": null,
+        "monitor": true,
+        "mtu": 1500,
+        "name": "eth0"
+      }
+    ],
+    "ipGateway": "4.0.0.0.1",
+    "ipNetmask": "255.255.255.0",
+    "lastUpdated": "2019",
+    "mgmtIpAddress": null,
+    "mgmtIpGateway": null,
+    "mgmtIpNetmask": null,
+    "offlineReason": "none",
+    "physLocation": "",
+    "physLocationId": 0,
+    "profile": "Monitor0",
+    "profileDesc": "nodesc",
+    "profileId": 0,
+    "rack": "",
+    "revalPending": false,
+    "routerHostName": "",
+    "routerPortName": "",
+    "status": "REPORTED",
+    "statusId": 0,
+    "tcpPort": 80,
+    "type": "RASCAL",
+    "typeId": 0,
+    "updPending": false,
+    "xmppId": "",
+    "xmppPasswd": ""
+  }
+]
+'
+
+  cat >$CFG_FILE <<-EOF
+{
+  "trafficMonitor": {
+    "url": "$TM_URI"
+  },
+  "default": {
+    "session": {
+      "timeoutInSecs": 30
+    },
+    "log": {
+      "debug": "stdout",
+      "event": "stdout",
+      "info": "stdout",
+      "error": "stdout",
+      "warning": "stdout"
+    }
+  }
+}
+EOF
+
+  echo "INITIALIZED=1" >>/etc/environment
+}
+
+function wait_for_endpoint() {
+  try=0
+  while [ $(curl -Lsk --write-out "%{http_code}" "$1" -o /dev/null) -ne 200 ] ; do
+    echo "Waiting for $1 to return a 200 OK"
+    try=$(expr $try + 1)
+    if [[ $try -gt 5 ]]; then
+      echo "Unable to get $1"
+      exit 1
+    fi
+    sleep 5
+  done
+}
+export -f wait_for_endpoint
+
+source /etc/environment
+if [ -z "$INITIALIZED" ]; then init; fi
+start
diff --git a/traffic_monitor/tests/_integration/README.md b/traffic_monitor/tests/_integration/README.md
new file mode 100644
index 0000000..7eaaf58
--- /dev/null
+++ b/traffic_monitor/tests/_integration/README.md
@@ -0,0 +1,9 @@
+# Traffic Monitor Integration Test Framework
+
+## Building
+
+From this directory, run `build_tests.sh`. This will build the Traffic Monitor RPM as well as run a docker build.
+
+## Running
+
+From this directory, run `docker-compose run tmintegrationtest`
diff --git a/traffic_monitor/tests/_integration/build_tests.sh b/traffic_monitor/tests/_integration/build_tests.sh
new file mode 100755
index 0000000..371e130
--- /dev/null
+++ b/traffic_monitor/tests/_integration/build_tests.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+
+#   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.
+
+cd ../../../
+./pkg traffic_monitor_build
+rpm=`ls dist | grep monitor | grep -v log | grep -v src | grep "$(git rev-parse --short=8 HEAD)"`
+if [ $? -ne 0 ]; then
+  echo "Unable to build TM"
+  exit 1;
+fi
+
+cp "dist/$rpm"  "traffic_monitor/tests/_integration/tm/traffic_monitor.rpm"
+cd -
+docker-compose build
diff --git a/traffic_monitor/tests/_integration/client_test.go b/traffic_monitor/tests/_integration/client_test.go
new file mode 100644
index 0000000..5839983
--- /dev/null
+++ b/traffic_monitor/tests/_integration/client_test.go
@@ -0,0 +1,117 @@
+package _integration
+
+/*
+   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 (
+	"testing"
+)
+
+func TestClient(t *testing.T) {
+	if actual, err := TMClient.CacheCount(); err != nil {
+		t.Errorf("client CacheCount error expected nil, actual %v\n", err)
+	} else if actual <= 0 {
+		t.Errorf("client CacheCount expected > 0 actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.CacheAvailableCount(); err != nil {
+		t.Errorf("client CacheAvailableCount error expected nil, actual %v\n", err)
+	} else if actual <= 0 {
+		t.Errorf("client CacheAvailableCount expected > 0 actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.CacheDownCount(); err != nil {
+		t.Errorf("client CacheDownCount error expected nil, actual %v\n", err)
+	} else if actual < 0 {
+		t.Errorf("client CacheDownCount expected >= 0 actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.Version(); err != nil {
+		t.Errorf("client Version error expected nil, actual %v\n", err)
+	} else if actual == "" {
+		t.Errorf("client Version expected not empty, actual empty\n")
+	}
+
+	if actual, err := TMClient.TrafficOpsURI(); err != nil {
+		t.Errorf("client TrafficOpsURI error expected nil, actual %v\n", err)
+	} else if actual == "" {
+		t.Errorf("client TrafficOpsURI expected not empty, actual empty\n")
+	}
+
+	if actual, err := TMClient.BandwidthKBPS(); err != nil {
+		t.Errorf("client BandwidthKBPS error expected nil, actual %v\n", err)
+	} else if actual < 0 {
+		t.Errorf("client BandwidthKBPS expected >=0 , actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.BandwidthCapacityKBPS(); err != nil {
+		t.Errorf("client BandwidthCapacityKBPS error expected nil, actual %v\n", err)
+	} else if actual <= 0 {
+		t.Errorf("client BandwidthCapacityKBPS expected >0 , actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.CacheStatuses(); err != nil {
+		t.Errorf("client CacheStatuses error expected nil, actual %v\n", err)
+	} else if len(actual) == 0 {
+		t.Errorf("client len(CacheStatuses) expected >0 , actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.MonitorConfig(); err != nil {
+		t.Errorf("client MonitorConfig error expected nil, actual %v\n", err)
+	} else if len(actual.TrafficServer) == 0 {
+		t.Errorf("client len(TrafficMonitorConfig.TrafficServers) expected not empty, actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.CRConfigHistory(); err != nil {
+		t.Errorf("client CRConfigHistory error expected nil, actual %v\n", err)
+	} else if len(actual) == 0 {
+		t.Errorf("client len(CRConfigHistory) expected !=0, actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.EventLog(); err != nil {
+		t.Errorf("client EventLog error expected nil, actual %v\n", err)
+	} else if len(actual.Events) == 0 {
+		t.Errorf("client len(EventLog.Events) expected !=0, actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.CacheStats(); err != nil {
+		t.Errorf("client CacheStats error expected nil, actual %v\n", err)
+	} else if len(actual.Caches) == 0 {
+		t.Errorf("client len(CacheStats.Caches) expected !=0, actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.CacheStatsNew(); err != nil {
+		t.Errorf("client CacheStatsNew error expected nil, actual %v\n", err)
+	} else if len(actual.Caches) == 0 {
+		t.Errorf("client len(CacheStatsNew.Caches) expected !=0, actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.DSStats(); err != nil {
+		t.Errorf("client DSStats error expected nil, actual %v\n", err)
+	} else if len(actual.DeliveryService) == 0 {
+		t.Errorf("client len(DSStats.DeliveryService) expected !=0, actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.CRStates(false); err != nil {
+		t.Errorf("client CRStates error expected nil, actual %v\n", err)
+	} else if len(actual.Caches) == 0 {
+		t.Errorf("client len(CRStates.Caches) expected !=0, actual %v\n", actual)
+	}
+
+	if actual, err := TMClient.CRConfig(); err != nil {
+		t.Errorf("client CRConfig error expected nil, actual %v\n", err)
+	} else if len(actual.ContentServers) == 0 {
+		t.Errorf("client len(CRConfig.ContentServers) expected !=0, actual %v\n", actual)
+	}
+}
diff --git a/traffic_monitor/tests/_integration/config/config.go b/traffic_monitor/tests/_integration/config/config.go
new file mode 100644
index 0000000..3a10220
--- /dev/null
+++ b/traffic_monitor/tests/_integration/config/config.go
@@ -0,0 +1,157 @@
+package config
+
+/*
+ * 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 (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"reflect"
+
+	"github.com/apache/trafficcontrol/lib/go-log"
+	"github.com/kelseyhightower/envconfig"
+)
+
+// Config reflects the structure of the test-to-api.conf file
+type Config struct {
+	TrafficMonitor TrafficMonitor `json:"trafficMonitor"`
+	Default        Default        `json:"default"`
+}
+
+// TrafficMonitor is the monitor config section.
+type TrafficMonitor struct {
+	// URL points to the Traffic Monitor instance being tested
+	URL string `json:"url" envconfig:"TM_URL"`
+}
+
+// Default - config section
+type Default struct {
+	Session Session   `json:"session"`
+	Log     Locations `json:"logLocations"`
+}
+
+// Session - config section
+type Session struct {
+	TimeoutInSecs int `json:"timeoutInSecs" envconfig:"SESSION_TIMEOUT_IN_SECS"`
+}
+
+// Locations - reflects the structure of the database.conf file
+type Locations struct {
+	Debug   string `json:"debug"`
+	Event   string `json:"event"`
+	Error   string `json:"error"`
+	Info    string `json:"info"`
+	Warning string `json:"warning"`
+}
+
+// LoadConfig - reads the config file into the Config struct
+func LoadConfig(confPath string) (Config, error) {
+	var cfg Config
+
+	if _, err := os.Stat(confPath); !os.IsNotExist(err) {
+		confBytes, err := ioutil.ReadFile(confPath)
+		if err != nil {
+			return Config{}, fmt.Errorf("reading CDN conf '%s': %v", confPath, err)
+		}
+
+		err = json.Unmarshal(confBytes, &cfg)
+		if err != nil {
+			return Config{}, fmt.Errorf("unmarshalling '%s': %v", confPath, err)
+		}
+	}
+	errs := validate(confPath, cfg)
+	if len(errs) > 0 {
+		fmt.Printf("configuration error:\n")
+		for _, e := range errs {
+			fmt.Printf("%v\n", e)
+		}
+		os.Exit(0)
+	}
+	err := envconfig.Process("traffic-ops-client-tests", &cfg)
+	if err != nil {
+		log.Errorln(fmt.Errorf("cannot parse config: %v\n", err))
+		os.Exit(0)
+	}
+
+	return cfg, err
+}
+
+// validate all required fields in the config.
+func validate(confPath string, config Config) []error {
+
+	errs := []error{}
+
+	var f string
+	f = "TrafficMonitor"
+	toTag, ok := getStructTag(config, f)
+	if !ok {
+		errs = append(errs, fmt.Errorf("'%s' must be configured in %s", toTag, confPath))
+	}
+
+	if config.TrafficMonitor.URL == "" {
+		f = "URL"
+		tag, ok := getStructTag(config.TrafficMonitor, f)
+		if !ok {
+			errs = append(errs, fmt.Errorf("cannot lookup structTag: %s", f))
+		}
+		errs = append(errs, fmt.Errorf("'%s.%s' must be configured in %s", toTag, tag, confPath))
+	}
+
+	return errs
+}
+
+func getStructTag(thing interface{}, fieldName string) (string, bool) {
+	var tag string
+	var ok bool
+	t := reflect.TypeOf(thing)
+	if t != nil {
+		if f, ok := t.FieldByName(fieldName); ok {
+			tag = f.Tag.Get("json")
+			return tag, ok
+		}
+	}
+	return tag, ok
+}
+
+// ErrorLog - critical messages
+func (c Config) ErrorLog() log.LogLocation {
+	return log.LogLocation(c.Default.Log.Error)
+}
+
+// WarningLog - warning messages
+func (c Config) WarningLog() log.LogLocation {
+	return log.LogLocation(c.Default.Log.Warning)
+}
+
+// InfoLog - information messages
+func (c Config) InfoLog() log.LogLocation {
+	return log.LogLocation(c.Default.Log.Info)
+}
+
+// DebugLog - troubleshooting messages
+func (c Config) DebugLog() log.LogLocation {
+	return log.LogLocation(c.Default.Log.Debug)
+}
+
+// EventLog - access.log high level transactions
+func (c Config) EventLog() log.LogLocation {
+	return log.LogLocation(c.Default.Log.Event)
+}
diff --git a/traffic_monitor/tests/_integration/docker-compose.yml b/traffic_monitor/tests/_integration/docker-compose.yml
new file mode 100644
index 0000000..a252c7f
--- /dev/null
+++ b/traffic_monitor/tests/_integration/docker-compose.yml
@@ -0,0 +1,99 @@
+# 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.
+#
+# To run the integration test:
+# 1. build Traffic Monitor and then copy the RPM to this directory as traffic_monitor.rpm.
+# 2. build traffic_monitor/tools/testcaches and place the binary in this directory.
+# 3. build traffic_monitor/tools/testto and place the binary in this directory.
+# 4. Run this compose
+#
+# Commands are, from the root trafficcontrol directory:
+# ./pkg -v traffic_monitor_build
+# cp ./dist/traffic_monitor*.rpm ./traffic_monitor/
+# ./build_tests.sh
+#
+# To run the integration tests, your current working directory must be trafficcontrol/traffic_monitor.
+# This is because Docker doesn't allow accessing files outside the current "context".
+#
+#      docker-compose up -d
+#
+
+---
+version: '2.1'
+
+services:
+  testto:
+    build:
+      context: ../../tools/testto
+      dockerfile: ./Dockerfile
+    domainname: traffic-monitor-integration.test
+    env_file:
+      - variables.env
+    hostname: testto
+    image: testto
+    volumes:
+      - shared:/shared
+
+  testcaches:
+    build:
+      context: ../../tools/testcaches
+      dockerfile: ./Dockerfile
+    domainname: traffic-monitor-integration.test
+    env_file:
+      - variables.env
+    hostname: testcaches
+    image: testcaches
+    volumes:
+      - shared:/shared
+
+  trafficmonitor:
+    build:
+      context: ./tm
+      dockerfile: ./Dockerfile
+      args:
+        RPM: ./traffic_monitor.rpm
+    depends_on:
+      - testto
+    domainname: traffic-monitor-integration.test
+    env_file:
+      - variables.env
+    hostname: trafficmonitor
+    image: trafficmonitor
+    ports:
+      - "80:80"
+    volumes:
+      - shared:/shared
+
+  tmintegrationtest:
+    build:
+      context: ../../
+      dockerfile: ./tests/_integration/Dockerfile
+    depends_on:
+      - testto
+      - testcaches
+      - trafficmonitor
+    domainname: traffic-monitor-integration.test
+    env_file:
+      - variables.env
+    hostname: tmintegrationtest
+    image: tmintegrationtest
+    volumes:
+      - shared:/shared
+
+volumes:
+  shared:
+    external: false
diff --git a/traffic_monitor/tests/_integration/kbps_test.go b/traffic_monitor/tests/_integration/kbps_test.go
new file mode 100644
index 0000000..410219f
--- /dev/null
+++ b/traffic_monitor/tests/_integration/kbps_test.go
@@ -0,0 +1,75 @@
+package _integration
+
+/*
+   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 (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-log"
+	"github.com/apache/trafficcontrol/lib/go-tc"
+)
+
+func TestKBPS(t *testing.T) {
+	crc, err := TMClient.CRConfig()
+	if err != nil {
+		t.Fatalf("client CRConfig error expected nil, actual %v\n", err)
+	}
+
+	if len(crc.ContentServers) == 0 {
+		t.Fatalf("Monitor CRConfig has no servers, cannot test KBPS")
+	}
+
+	serverName := ""
+	server := tc.CRConfigTrafficOpsServer{}
+	for crcServerName, crcServer := range crc.ContentServers {
+		server = crcServer
+		serverName = crcServerName
+		break
+	}
+	if server.Ip == nil {
+		t.Fatalf("Monitor CRConfig server '" + serverName + "' has no Ip, cannot test KBPS")
+	}
+	if server.Port == nil {
+		t.Fatalf("Monitor CRConfig server '" + serverName + "' has no Port, cannot test KBPS")
+	}
+
+	const bytesPerKilobit = 125
+
+	expectedKbps := 10000
+
+	httpClient := http.Client{Timeout: time.Duration(Config.Default.Session.TimeoutInSecs) * time.Second}
+
+	kbps10 := bytesPerKilobit * expectedKbps
+	uri := fmt.Sprintf(`http://%v:%v/cmd/setstat?remap=num1.example.net&stat=out_bytes&min=%v&max=%v`, *server.Ip, *server.Port, kbps10, kbps10)
+	resp, err := httpClient.Get(uri)
+	if err != nil {
+		t.Fatalf("Error posting fake cache command '" + uri + "': " + err.Error())
+	}
+	defer log.Close(resp.Body, "Unable to close http client "+uri)
+
+	time.Sleep(time.Second * 5) // TODO determine if there's a faster or more precise way to wait for polled data?
+
+	kbps, err := TMClient.BandwidthKBPS()
+	if err != nil {
+		t.Fatalf("getting monitor bandwidth kbps: %v\n", err)
+	}
+
+	if kbps < float64(expectedKbps/2) || kbps > float64(expectedKbps*2) {
+		t.Errorf("monitor bandwidth kbps expected %v-%v actual %v\n", expectedKbps/2, expectedKbps*2, kbps)
+	}
+}
diff --git a/traffic_monitor/tests/_integration/monitoring.json b/traffic_monitor/tests/_integration/monitoring.json
new file mode 100644
index 0000000..1acdae8
--- /dev/null
+++ b/traffic_monitor/tests/_integration/monitoring.json
@@ -0,0 +1,135 @@
+{
+  "trafficServers": [
+    {
+      "profile": "Edge0",
+      "status": "REPORTED",
+      "cacheGroup": "cg0",
+      "port": 30000,
+      "hostName": "server0",
+      "fqdn": "server0.monitor-integration.test",
+      "interfaces": [
+        {
+          "ipAddresses": [
+            {
+              "address": "172.31.0.2",
+              "gateway": "172.31.0.1",
+              "serviceAddress": true
+            }
+          ],
+          "maxBandwidth": 10000000,
+          "monitor": true,
+          "mtu": 1500,
+          "name": "bond0"
+        }
+      ],
+      "type": "EDGE",
+      "hashId": "server0",
+      "deliveryServices": [
+        {
+          "xmlId": "ds0",
+          "remaps": [
+            "ds0.monitor-integration.test"
+          ]
+        }
+      ]
+    },
+    {
+      "profile": "Edge0",
+      "status": "REPORTED",
+      "cacheGroup": "cg0",
+      "port": 30001,
+      "hostName": "server1",
+      "fqdn": "server1.monitor-integration.test",
+      "interfaces": [
+        {
+          "ipAddresses": [
+            {
+              "address": "172.31.0.2",
+              "gateway": "172.31.0.1",
+              "serviceAddress": true
+            }
+          ],
+          "maxBandwidth": 10000000,
+          "monitor": true,
+          "mtu": 1500,
+          "name": "bond0"
+        }
+      ],
+      "type": "EDGE",
+      "hashId": "server1",
+      "deliveryServices": [
+        {
+          "xmlId": "ds0",
+          "remaps": [
+            "ds0.monitor-integration.test"
+          ]
+        }
+      ]
+    }
+  ],
+  "cacheGroups": [
+    {
+      "cg0": {
+        "name": "cg0",
+        "coordinates": {
+          "latitude": 0,
+          "longitude": 0
+        }
+      }
+    }
+  ],
+  "config": {
+    "peers.polling.interval": 30,
+    "health.polling.interval": 2000,
+    "heartbeat.polling.interval": 2000,
+    "tm.polling.interval": 30
+  },
+  "trafficMonitors": [
+    {
+      "port": 80,
+      "ip6": "",
+      "ip": "trafficmonitor",
+      "hostName": "trafficmonitor",
+      "fqdn": "trafficmonitor.traffic-monitor-integration.test",
+      "profile": "Monitor0",
+      "location": "cg0",
+      "status": "REPORTED"
+    }
+  ],
+  "deliveryServices": [
+    {
+      "xmlId": "ds0",
+      "TotalTpsThreshold": 1000000,
+      "status": "Available",
+      "TotalKbpsThreshold": 10000000
+    }
+  ],
+  "profiles": [
+    {
+      "parameters": {
+        "health.connection.timeout": 10,
+        "health.polling.url": "http://${hostname}/_astats?application=&inf.name=bond0",
+        "health.polling.format": "",
+        "health.polling.type": "",
+        "history.count": 0,
+        "MinFreeKbps": 20000,
+        "health_threshold": {}
+      },
+      "name": "Edge0",
+      "type": "EDGE"
+    },
+    {
+      "parameters": {
+        "health.connection.timeout": 10,
+        "health.polling.url": "",
+        "health.polling.format": "",
+        "health.polling.type": "",
+        "history.count": 5,
+        "MinFreeKbps": 20000,
+        "health_threshold": {}
+      },
+      "name": "Monitor0",
+      "type": "RASCAL"
+    }
+  ]
+}
diff --git a/traffic_monitor/tests/_integration/snapshot.json b/traffic_monitor/tests/_integration/snapshot.json
new file mode 100644
index 0000000..e720abf
--- /dev/null
+++ b/traffic_monitor/tests/_integration/snapshot.json
@@ -0,0 +1,164 @@
+{
+  "config": {
+    "api.cache-control.max_age": "30",
+    "consistent.dns.routing": "true",
+    "coveragezone.polling.interval": "30",
+    "coveragezone.polling.url": "30",
+    "dnssec.dynamic.response.expiration": "60",
+    "dnssec.enabled": "false",
+    "domain_name": "monitor-integration.test",
+    "federationmapping.polling.interval": "60",
+    "federationmapping.polling.url": "foo",
+    "geolocation.polling.interval": "30",
+    "geolocation.polling.url": "foo",
+    "keystore.maintenance.interval": "30",
+    "neustar.polling.interval": "30",
+    "neustar.polling.url": "foo",
+    "soa": {
+    },
+    "dnssec.inception": "0",
+    "ttls": {
+      "admin": "30",
+      "expire": "30",
+      "minimum": "30",
+      "refresh": "30",
+      "retry": "30"
+    },
+    "weight": "1",
+    "zonemanager.cache.maintenance.interval": "30",
+    "zonemanager.threadpool.scale": "1"
+  },
+  "contentServers": {
+    "server0": {
+      "cacheGroup": "cg0",
+      "profile": "Edge0",
+      "fqdn": "server0.monitor-integration.test",
+      "hashCount": 1,
+      "hashId": "server0",
+      "ip": "testcaches",
+      "ip6": null,
+      "locationId": "",
+      "port": 30000,
+      "status": "REPORTED",
+      "type": "EDGE",
+      "interfaceName": "bond0",
+      "deliveryServices": {
+        "ds0": [
+          "ds0.monitor-integration.test"
+        ]
+      },
+      "routingDisabled": 0
+    },
+    "server1": {
+      "cacheGroup": "cg0",
+      "profile": "Edge0",
+      "fqdn": "server1.monitor-integration.test",
+      "hashCount": 1,
+      "hashId": "server1",
+      "ip": "testcaches",
+      "ip6": null,
+      "locationId": "",
+      "port": 30001,
+      "status": "REPORTED",
+      "type": "EDGE",
+      "interfaceName": "bond0",
+      "deliveryServices": {
+        "ds0": [
+          "ds0.monitor-integration.test"
+        ]
+      },
+      "routingDisabled": 0
+    }
+  },
+  "deliveryServices": {
+    "ds0": {
+      "anonymousBlockingEnabled": "false",
+      "consistentHashQueryParams": [],
+      "consistentHashRegex": "",
+      "coverageZoneOnly": "false",
+      "dispersion": {
+        "limit": 1,
+        "shuffled": "false"
+      },
+      "domains": [
+        "ds0.monitor-integration.test"
+      ],
+      "matchsets": [
+        {
+          "protocol": "HTTP",
+          "matchlist": [
+            {
+              "regex": "\\.*ds0\\.*",
+              "match-type": "regex"
+            }
+          ]
+        }
+      ],
+      "missLocation": {
+        "lat": 0,
+        "lon": 0
+      },
+      "protocol": {
+        "acceptHttp": "true",
+        "acceptHttps": "false",
+        "redirectToHttps": "false"
+      },
+      "regionalGeoBlocking": "false",
+      "responseHeaders": {},
+      "requestHeaders": [],
+      "soa": {
+        "admin": "60",
+        "expire": "60",
+        "minimum": "60",
+        "refresh": "60",
+        "retry": "60"
+      },
+      "sslEnabled": "false",
+      "ttl": 60,
+      "ttls": {
+        "A": "60",
+        "AAAA": "60",
+        "DNSKEY": "60",
+        "DS": "60",
+        "NS": "60",
+        "SOA": "60"
+      },
+      "maxDnsIpsForLocation": 3,
+      "ip6RoutingEnabled": "false",
+      "routingName": "ccr",
+      "deepCachingType": "",
+      "staticDnsEntries": []
+    }
+  },
+  "edgeLocations": {
+    "cg0": {
+      "latitude": 0,
+      "longitude": 0
+    }
+  },
+  "trafficRouterLocations": {
+    "tr0": {
+      "latitude": 0,
+      "longitude": 0
+    }
+  },
+  "monitors": {
+    "trafficmonitor": {
+      "fqdn": "trafficmonitor.monitor-integration.test",
+      "ip": "trafficmonitor",
+      "ip6": null,
+      "location": "cg0",
+      "port": 80,
+      "profile": "Monitor0",
+      "status": "REPORTED"
+    }
+  },
+  "stats": {
+    "CDN_name": "fake",
+    "date": 1561000000,
+    "tm_host": "testto",
+    "tm_path": "/fake",
+    "tm_user": "fake",
+    "tm_version": "integrationtest/0.fake"
+  }
+}
diff --git a/traffic_monitor/.gitignore b/traffic_monitor/tests/_integration/tm/Dockerfile
similarity index 54%
copy from traffic_monitor/.gitignore
copy to traffic_monitor/tests/_integration/tm/Dockerfile
index fad340b..cdad32c 100644
--- a/traffic_monitor/.gitignore
+++ b/traffic_monitor/tests/_integration/tm/Dockerfile
@@ -14,6 +14,19 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-#
-#TM binary
-traffic_monitor
+
+# This is a very simple Dockerfile.
+# All it does is install and start the Traffic Monitor, given a Traffic Ops to point it to.
+# It doesn't do any of the complex things the Dockerfiles in infrastructure/docker or infrastructure/cdn-in-a-box do, like inserting itself into Traffic Ops.
+# It is designed for a very simple use case, where the complex orchestration of other Traffic Control components is done elsewhere (or manually).
+
+FROM centos:8
+MAINTAINER dev@trafficcontrol.apache.org
+
+ARG RPM=traffic_monitor.rpm
+ADD $RPM /
+
+RUN yum install -y initscripts jq /$(basename $RPM) && rm /$(basename $RPM)
+
+ADD Dockerfile_run.sh /
+ENTRYPOINT /Dockerfile_run.sh
diff --git a/traffic_monitor/tests/_integration/tm/Dockerfile_run.sh b/traffic_monitor/tests/_integration/tm/Dockerfile_run.sh
new file mode 100755
index 0000000..6ab040a
--- /dev/null
+++ b/traffic_monitor/tests/_integration/tm/Dockerfile_run.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env 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.
+
+# The following environment variables must be set (ordinarily by `docker run -e` arguments):
+# TO_URI
+# TO_USER
+# TO_PASS
+# CDN
+
+# Check that env vars are set
+envvars=( TO_URI TO_USER TO_PASS CDN PORT )
+for v in ${envvars[@]}
+do
+	if [[ -z ${!v} ]]; then echo "$v is unset"; exit 1; fi
+done
+
+start() {
+	service traffic_monitor start
+	touch /opt/traffic_monitor/var/log/traffic_monitor.log
+	exec tail -f /opt/traffic_monitor/var/log/traffic_monitor.log
+}
+
+init() {
+	mkdir -p /opt/traffic_monitor/conf
+	cat > /opt/traffic_monitor/conf/traffic_monitor.cfg <<- EOF
+		{
+				"cache_health_polling_interval_ms": 6000,
+				"cache_stat_polling_interval_ms": 6000,
+				"monitor_config_polling_interval_ms": 15000,
+				"http_timeout_ms": 2000,
+				"peer_polling_interval_ms": 5000,
+				"peer_optimistic": true,
+				"max_events": 200,
+				"max_stat_history": 5,
+				"max_health_history": 5,
+				"health_flush_interval_ms": 20,
+				"stat_flush_interval_ms": 20,
+				"log_location_event": "/opt/traffic_monitor/var/log/event.log",
+				"log_location_error": "/opt/traffic_monitor/var/log/traffic_monitor.log",
+				"log_location_warning": "/opt/traffic_monitor/var/log/traffic_monitor.log",
+				"log_location_info": "null",
+				"log_location_debug": "null",
+				"serve_read_timeout_ms": 10000,
+				"serve_write_timeout_ms": 10000,
+				"http_poll_no_sleep": false,
+				"static_file_dir": "/opt/traffic_monitor/static/"
+		}
+EOF
+
+  cat > /opt/traffic_monitor/conf/traffic_ops.cfg <<- EOF
+		{
+				"username": "$TO_USER",
+				"password": "$TO_PASS",
+				"url": "$TO_URI",
+				"insecure": true,
+				"cdnName": "$CDN",
+				"httpListener": ":$PORT"
+				}
+	EOF
+
+	echo "INITIALIZED=1" >> /etc/environment
+}
+
+source /etc/environment
+if [ -z "$INITIALIZED" ]; then init; fi
+start
diff --git a/traffic_monitor/tests/_integration/traffic-monitor-test.conf b/traffic_monitor/tests/_integration/traffic-monitor-test.conf
new file mode 100644
index 0000000..24956da
--- /dev/null
+++ b/traffic_monitor/tests/_integration/traffic-monitor-test.conf
@@ -0,0 +1,17 @@
+{
+    "default": {
+        "logLocations": {
+            "debug": "stdout",
+            "error": "stdout",
+            "event": "stdout",
+            "info": "stdout",
+            "warning": "stdout"
+        },
+        "session": {
+            "timeoutInSecs": 30
+        }
+    },
+    "trafficMonitor": {
+        "URL": "http://localhost:80"
+    }
+}
diff --git a/traffic_monitor/tests/_integration/traffic_monitor_test.go b/traffic_monitor/tests/_integration/traffic_monitor_test.go
new file mode 100644
index 0000000..0e11f21
--- /dev/null
+++ b/traffic_monitor/tests/_integration/traffic_monitor_test.go
@@ -0,0 +1,96 @@
+package _integration
+
+/*
+
+   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 (
+	"flag"
+	"fmt"
+	"net/http"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-log"
+	"github.com/apache/trafficcontrol/traffic_monitor/tests/_integration/config"
+	"github.com/apache/trafficcontrol/traffic_monitor/tmclient"
+)
+
+var Config config.Config
+var TMClient *tmclient.TMClient
+
+func TestMain(m *testing.M) {
+	var err error
+	configFileName := flag.String("cfg", "traffic-monitor-test.conf", "The config file path")
+	flag.Parse()
+
+	if Config, err = config.LoadConfig(*configFileName); err != nil {
+		fmt.Printf("Error Loading Config %v %v\n", Config, err)
+		os.Exit(1)
+	}
+
+	if err = log.InitCfg(Config); err != nil {
+		fmt.Printf("Error initializing loggers: %v\n", err)
+		os.Exit(1)
+	}
+
+	log.Infof(`Using Config values:
+			   TM Config File:       %s
+			   TM URL:               %s
+			   TM Session Timeout:   %d\n`,
+		*configFileName, Config.TrafficMonitor.URL, Config.Default.Session.TimeoutInSecs)
+
+	tmReqTimeout := time.Second * time.Duration(Config.Default.Session.TimeoutInSecs)
+
+	monitorWaitSpan := 30 * time.Second // TODO make configurable?
+
+	if !WaitForMonitor(Config.TrafficMonitor.URL, monitorWaitSpan) {
+		fmt.Printf("\nError communicating with Monitor '%v' - didn't return a 200 OK in %v\n",
+			Config.TrafficMonitor.URL, monitorWaitSpan)
+		os.Exit(1)
+	}
+
+	TMClient = tmclient.New(Config.TrafficMonitor.URL, tmReqTimeout)
+
+	// Now run the test case
+	rc := m.Run()
+	os.Exit(rc)
+}
+
+// WaitForMonitor waits for the monitor to fully start, and stop serving 5xx codes.
+// If the monitor does not return a 200 from an API endpoint by timeout, returns false.
+func WaitForMonitor(url string, timeout time.Duration) bool {
+	httpClient := http.Client{Timeout: timeout}
+
+	tryInterval := time.Second // TODO make configurable?
+
+	start := time.Now()
+	for {
+		if time.Now().After(start.Add(timeout)) {
+			return false
+		}
+		time.Sleep(tryInterval)
+		resp, err := httpClient.Get(strings.TrimSuffix(url, "/") + "/api/version")
+		if err != nil {
+			continue
+		}
+		resp.Body.Close()
+		if resp.StatusCode != 200 {
+			continue
+		}
+		return true
+	}
+}
diff --git a/traffic_monitor/tests/_integration/variables.env b/traffic_monitor/tests/_integration/variables.env
new file mode 100644
index 0000000..5b40bf2
--- /dev/null
+++ b/traffic_monitor/tests/_integration/variables.env
@@ -0,0 +1,34 @@
+#   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.
+
+# environment variables for the Traffic Monitor Docker Compose files
+
+TO_URI=http://testto
+TO_USER=nouser
+TO_PASS=nopass
+CDN=fake
+PORT=80
+
+# testcaches
+NUM_PORTS=2
+NUM_REMAPS=2
+PORT_START=30000
+
+# testto
+
+# tmintegrationtest
+TESTTO_URI=testto
+TESTTO_PORT=80
+TESTCACHES_URI=testcaches
+TESTCACHES_PORT_START=30000
+
+TM_URI=http://trafficmonitor
diff --git a/traffic_monitor/tmclient/tmclient.go b/traffic_monitor/tmclient/tmclient.go
new file mode 100644
index 0000000..732a52e
--- /dev/null
+++ b/traffic_monitor/tmclient/tmclient.go
@@ -0,0 +1,261 @@
+package tmclient
+
+/*
+ * 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 (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-log"
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/traffic_monitor/datareq"
+	"github.com/apache/trafficcontrol/traffic_monitor/dsdata"
+	"github.com/apache/trafficcontrol/traffic_monitor/handler"
+	"github.com/apache/trafficcontrol/traffic_monitor/towrap"
+)
+
+type TMClient struct {
+	url     string
+	timeout time.Duration
+}
+
+func New(url string, timeout time.Duration) *TMClient {
+	return &TMClient{url: strings.TrimSuffix(url, "/"), timeout: timeout}
+}
+
+func (c *TMClient) CacheCount() (int, error) { return c.getInt("/api/cache-count") }
+
+func (c *TMClient) CacheAvailableCount() (int, error) { return c.getInt("/api/cache-available-count") }
+
+func (c *TMClient) CacheDownCount() (int, error) { return c.getInt("/api/cache-down-count") }
+
+func (c *TMClient) Version() (string, error) { return c.getStr("/api/version") }
+
+func (c *TMClient) TrafficOpsURI() (string, error) { return c.getStr("/api/traffic-ops-uri") }
+
+func (c *TMClient) BandwidthKBPS() (float64, error) { return c.getFloat("/api/bandwidth-kbps") }
+
+func (c *TMClient) BandwidthCapacityKBPS() (float64, error) {
+	return c.getFloat("/api/bandwidth-capacity-kbps")
+}
+
+func (c *TMClient) CacheStatuses() (map[tc.CacheName]datareq.CacheStatus, error) {
+	path := "/api/cache-statuses"
+	obj := map[tc.CacheName]datareq.CacheStatus{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return nil, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) MonitorConfig() (tc.TrafficMonitorConfigMap, error) {
+	path := "/api/monitor-config"
+	obj := tc.TrafficMonitorConfigMap{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return tc.TrafficMonitorConfigMap{}, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) CRConfigHistory() ([]towrap.CRConfigStat, error) {
+	path := "/api/crconfig-history"
+	obj := []towrap.CRConfigStat{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return nil, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) EventLog() (datareq.JSONEvents, error) {
+	path := "/publish/EventLog"
+	obj := datareq.JSONEvents{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return datareq.JSONEvents{}, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) CacheStatsNew() (tc.Stats, error) {
+	path := "/publish/CacheStats"
+	obj := tc.Stats{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return tc.Stats{}, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) CacheStats() (tc.LegacyStats, error) {
+	path := "/publish/CacheStats"
+	obj := tc.LegacyStats{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return tc.LegacyStats{}, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) DSStats() (dsdata.Stats, error) {
+	path := "/publish/DsStats"
+	obj := dsdata.Stats{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return dsdata.Stats{}, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) CRStates(raw bool) (tc.CRStates, error) {
+	path := "/publish/CrStates"
+	if raw {
+		path += "?raw"
+	}
+	obj := tc.CRStates{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return tc.CRStates{}, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) CRConfig() (tc.CRConfig, error) {
+	path := "/publish/CrConfig"
+	obj := tc.CRConfig{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return tc.CRConfig{}, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+// CRConfigBytes returns the raw bytes of the Monitor's CRConfig.
+//
+// If you need a deserialized object, use TMClient.CRConfig() instead.
+//
+// This function exists because the Monitor very intentionally serves the CRConfig bytes as
+// published by Traffic Ops, without deserializing or reserializing it.
+//
+// This can be useful to check for serialization or versioning issues, in case the Go object
+// is missing values sent by Traffic Ops, or has other serialization issues.
+//
+//
+func (c *TMClient) CRConfigBytes() ([]byte, error) { return c.getBytes("publish/CrConfig") }
+
+func (c *TMClient) PeerStates() (datareq.APIPeerStates, error) {
+	path := "/publish/PeerStates"
+	obj := datareq.APIPeerStates{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return datareq.APIPeerStates{}, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) Stats() (datareq.Stats, error) {
+	path := "/publish/Stats"
+	obj := datareq.Stats{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return datareq.Stats{}, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) StatSummary() (datareq.StatSummary, error) {
+	path := "/publish/StatSummary"
+	obj := datareq.StatSummary{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return datareq.StatSummary{}, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) ConfigDoc() (handler.OpsConfig, error) {
+	path := "/publish/ConfigDoc"
+	obj := handler.OpsConfig{}
+	if err := c.GetJSON(path, &obj); err != nil {
+		return handler.OpsConfig{}, err // GetJSON adds context
+	}
+	return obj, nil
+}
+
+func (c *TMClient) getBytes(path string) ([]byte, error) {
+	url := c.url + path
+	httpClient := http.Client{Timeout: c.timeout}
+	resp, err := httpClient.Get(url)
+	if err != nil {
+		return nil, errors.New("getting from '" + url + "': " + err.Error())
+	}
+	defer log.Close(resp.Body, "Unable to close http client "+url)
+
+	if resp.StatusCode < 200 || resp.StatusCode > 299 {
+		return nil, fmt.Errorf("Monitor '"+url+"' returned bad status %v", resp.StatusCode)
+	}
+
+	respBts, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errors.New("reading body from '" + url + "': " + err.Error())
+	}
+	return respBts, nil
+}
+
+func (c *TMClient) GetJSON(path string, obj interface{}) error {
+	bts, err := c.getBytes(path)
+	if err != nil {
+		return err // getBytes already adds context
+	}
+	if err := json.Unmarshal(bts, obj); err != nil {
+		return errors.New("unmarshalling response '" + string(bts) + "' json: " + err.Error())
+	}
+	return nil
+}
+
+func (c *TMClient) getStr(path string) (string, error) {
+	respBts, err := c.getBytes(path)
+	if err != nil {
+		return "", err // getBytes already adds context
+	}
+	return string(respBts), nil
+}
+
+func (c *TMClient) getInt(path string) (int, error) {
+	respStr, err := c.getStr(path)
+	if err != nil {
+		return 0, err // getStr already adds context
+	}
+
+	respInt, err := strconv.Atoi(respStr)
+	if err != nil {
+		return 0, errors.New("parsing response '" + respStr + "': " + err.Error())
+	}
+	return respInt, nil
+}
+
+func (c *TMClient) getFloat(path string) (float64, error) {
+	respStr, err := c.getStr(path)
+	if err != nil {
+		return 0, err // getStr already adds context
+	}
+
+	respFloat, err := strconv.ParseFloat(respStr, 64)
+	if err != nil {
+		return 0, errors.New("parsing response '" + respStr + "': " + err.Error())
+	}
+	return respFloat, nil
+}
diff --git a/traffic_monitor/.gitignore b/traffic_monitor/tools/testcaches/Dockerfile
similarity index 61%
copy from traffic_monitor/.gitignore
copy to traffic_monitor/tools/testcaches/Dockerfile
index fad340b..595e8da 100644
--- a/traffic_monitor/.gitignore
+++ b/traffic_monitor/tools/testcaches/Dockerfile
@@ -14,6 +14,16 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-#
-#TM binary
-traffic_monitor
+
+FROM centos:8
+MAINTAINER dev@trafficcontrol.apache.org
+
+RUN dnf -y install golang git
+ENV GOPATH=/go
+
+RUN go get -u github.com/apache/trafficcontrol/lib/go-rfc
+ADD .   ${GOPATH}/src/github.com/apache/trafficcontrol/traffic_monitor/tools/testcaches/
+WORKDIR ${GOPATH}/src/github.com/apache/trafficcontrol/traffic_monitor/tools/testcaches
+RUN go build && cp testcaches /usr/sbin/
+
+CMD ${GOPATH}/src/github.com/apache/trafficcontrol/traffic_monitor/tools/testcaches/Dockerfile_run.sh
diff --git a/traffic_monitor/build.sh b/traffic_monitor/tools/testcaches/Dockerfile_run.sh
similarity index 66%
copy from traffic_monitor/build.sh
copy to traffic_monitor/tools/testcaches/Dockerfile_run.sh
index baf07cc..95abc17 100755
--- a/traffic_monitor/build.sh
+++ b/traffic_monitor/tools/testcaches/Dockerfile_run.sh
@@ -1,3 +1,4 @@
+#!/usr/bin/env 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
@@ -14,5 +15,25 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-#!/usr/bin/env bash
-go build -ldflags "-X main.GitRevision=`git rev-parse HEAD` -X main.BuildTimestamp=`date +'%Y-%M-%dT%H:%M:%S'`"
+
+start() {
+  ARGS=
+  if [[ -n "${NUM_PORTS}" ]]; then
+    ARGS="$ARGS -numPorts ${NUM_PORTS}"
+  fi
+  if [[ -n "${NUM_REMAPS}" ]]; then
+    ARGS="$ARGS -numRemaps ${NUM_REMAPS}"
+  fi
+  if [[ -n "${PORT_START}" ]]; then
+    ARGS="$ARGS -portStart ${PORT_START}"
+  fi
+  testcaches ${ARGS}
+}
+
+init() {
+  echo "INITIALIZED=1" >>/etc/environment
+}
+
+source /etc/environment
+if [ -z "$INITIALIZED" ]; then init; fi
+start
diff --git a/traffic_monitor/tools/testcaches/README.md b/traffic_monitor/tools/testcaches/README.md
index 2617a93..5448a30 100644
--- a/traffic_monitor/tools/testcaches/README.md
+++ b/traffic_monitor/tools/testcaches/README.md
@@ -23,10 +23,74 @@ The `testcaches` tool simulates multiple ATS caches' `_astats` endpoints.
 
 Its primary goal is for testing the Monitor under load, but it may be useful for testing other components.
 
-A list of parameters can be seen by running `./testcaches -h`. There are only three: the first port to use, the number of ports to use, and the number of remaps (delivery services) to serve in each fake server.
+A list of parameters can be seen by running `./testcaches -h`. There are only three: the first port to use, the number
+of ports to use, and the number of remaps (delivery services) to serve in each fake server.
 
 Each port is a unique fake server, with distinct incrementing stats.
 
 When run with no parameters, it defaults to ports 40000-40999 and 1000 remaps.
 
-Stats are served at the regular ATS `stats_over_http` endpoint, `_astats`. For example, if it's serving on port 40000, it can be reached via `curl http://localhost:40000/_astats`. It also respects the `?application=system` query parameter, and will serve only system stats (the Monitor "health check" [as opposed to the "stat check"]). For example, `curl http://localhost:40000/_astats?application=system`.
+Stats are served at the regular ATS `stats_over_http` endpoint, `_astats`. For example, if it's serving on port 40000,
+it can be reached via `curl http://localhost:40000/_astats`. It also respects the `?application=system` query parameter,
+and will serve only system stats (the Monitor "health check" [as opposed to the "stat check"]). For
+example, `curl http://localhost:40000/_astats?application=system`.
+
+## Commands
+
+The `testcaches` app accepts a number of commands, which manipulate the data it serves. These command are all available
+via HTTP requests.
+
+Each HTTP request is made to a fake cache at a specific port. Thus, you can modify data served by each fake cache
+independently.
+
+The commands are:
+
+### `/cmd/setstat`
+
+Sets how much a stat increments by every interval (currently, an interval is hard-coded to 1 second). Accepts a min and
+max, and will increment by a random number between them. The min may equal the max, if a constant increment is desired.
+
+Query Parameters:
+`remap` - the remap rule to set
+`stat` - the stat to set
+`min` - the minimum number to increment by
+`max` - the minimum to increment by
+
+Example:
+`curl -Lvsk http://localhost:4242/cmd/setstat?remap=num1.example.net&stat=out_bytes&min=10&max=25`
+
+### `setsystem`
+
+Sets system stats to constant values. Multiple stats may be set with a single request.
+
+Query Parameters:
+`loadavg1m` - the 1m loadavg in the `system` object.
+`loadavg5m` - the 5m loadavg in the `system` object.
+`loadavg10m` - the 10m loadavg in the `system` object.
+`speed` - the network interface speed in the `system` object. This number is in kilobits. I.e. 20000 means 20Gbps.
+
+Example:
+`curl -sk 'http://localhost:4242/cmd/setsystem?loadavg1m=10.1&loadavg5m=27.92&loadavg10m=3.4&speed=20000' `
+
+### `setdelay`
+
+Sets the delay for serving all _astats requests to this fake cache. Accepts a minimum and maximum, which may be qual,
+and delays the request by a random interval between them. When a delay is set, the server immediately accepts client
+requests, reads headers and sets up the connection, and then delays writing out the body.
+
+Query Parameters:
+`min` - the minimum delay time, in milliseconds
+`max` - the maximum delay time, in milliseconds
+
+Example:
+`curl -Lvsk 'http://localhost:4242/cmd/setdelay?min=200&max=600'`
+
+## Docker
+
+Build environment variables: none
+
+Run environment variables:
+
+- `NUM_PORTS`  - app `numPorts` argument
+- `NUM_REMAPS` - app `numRemaps` argument
+- `PORT_START` - app `portStart` argument
diff --git a/traffic_monitor/tools/testcaches/fakesrvr/cmd.go b/traffic_monitor/tools/testcaches/fakesrvr/cmd.go
new file mode 100644
index 0000000..c2f7199
--- /dev/null
+++ b/traffic_monitor/tools/testcaches/fakesrvr/cmd.go
@@ -0,0 +1,221 @@
+package fakesrvr
+
+/*
+ * 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 (
+	"html"
+	"net/http"
+	"strconv"
+	"strings"
+	"sync/atomic"
+	"unsafe"
+
+	"github.com/apache/trafficcontrol/traffic_monitor/tools/testcaches/fakesrvrdata"
+)
+
+type CmdFunc = func(http.ResponseWriter, *http.Request, fakesrvrdata.Ths)
+
+var cmds = map[string]CmdFunc{
+	"setstat":   cmdSetStat,
+	"setdelay":  cmdSetDelay,
+	"setsystem": cmdSetSystem,
+}
+
+// cmdSetStat sets the rate of the given stat increase for the given remap.
+//
+// query parameters:
+//   remap: string; required; the full name of the remap whose kbps to set.
+//   stat: string; required; the stat to set (in_bytes, out_bytes, status_2xx, status_3xx, status_4xx, status_5xx).
+//   min:   unsigned integer; required; new minimum of kbps increase of InBytes stat for the given remap.
+//   max:   unsigned integer; required; new maximum of kbps increase of InBytes stat for the given remap.
+//
+func cmdSetStat(w http.ResponseWriter, r *http.Request, fakeSrvrDataThs fakesrvrdata.Ths) {
+	urlQry := r.URL.Query()
+
+	newMinStr := html.EscapeString(urlQry.Get("min"))
+	newMin, err := strconv.ParseUint(newMinStr, 10, 64)
+	if err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte("error parsing query parameter 'min': must be a positive integer: " + err.Error() + "\n"))
+		return
+	}
+
+	newMaxStr := html.EscapeString(urlQry.Get("max"))
+	newMax, err := strconv.ParseUint(newMaxStr, 10, 64)
+	if err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte("error parsing query parameter 'max': must be a positive integer: " + err.Error() + "\n"))
+		return
+	}
+
+	remap := html.EscapeString(urlQry.Get("remap"))
+	if remap == "" {
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte("missing query parameter 'remap': must specify a remap to set\n"))
+		return
+	}
+
+	stat := html.EscapeString(urlQry.Get("stat"))
+
+	validStats := map[string]struct{}{
+		"in_bytes":   {},
+		"out_bytes":  {},
+		"status_2xx": {},
+		"status_3xx": {},
+		"status_4xx": {},
+		"status_5xx": {},
+	}
+
+	if _, ok := validStats[stat]; !ok {
+		w.WriteHeader(http.StatusBadRequest)
+		statNames := []string{}
+		for statName := range validStats {
+			statNames = append(statNames, statName)
+		}
+		w.Write([]byte("error with query parameter 'stat' '" + stat + "': not found. Valid stats are: [" + strings.Join(statNames, ",") + "\n"))
+		return
+	}
+
+	srvr := (*fakesrvrdata.FakeServerData)(fakeSrvrDataThs.Get())
+	if _, ok := srvr.ATS.Remaps[remap]; !ok {
+		w.WriteHeader(http.StatusBadRequest)
+		remapNames := []string{}
+		for remapName := range srvr.ATS.Remaps {
+			remapNames = append(remapNames, remapName)
+		}
+		w.Write([]byte("error with query parameter 'remap' '" + remap + "': not found. Valid remaps are: [" + strings.Join(remapNames, ",") + "\n"))
+		return
+	}
+
+	incs := <-fakeSrvrDataThs.GetIncrementsChan
+	inc := incs[remap]
+
+	switch stat {
+	case "in_bytes":
+		inc.Min.InBytes = newMin
+		inc.Max.InBytes = newMax
+	case "out_bytes":
+		inc.Min.OutBytes = newMin
+		inc.Max.OutBytes = newMax
+	case "status_2xx":
+		inc.Min.Status2xx = newMin
+		inc.Max.Status2xx = newMax
+	case "status_3xx":
+		inc.Min.Status3xx = newMin
+		inc.Max.Status3xx = newMax
+	case "status_4xx":
+		inc.Min.Status4xx = newMin
+		inc.Max.Status4xx = newMax
+	case "status_5xx":
+		inc.Min.Status5xx = newMin
+		inc.Max.Status5xx = newMax
+	default:
+		panic("unknown stat; should never happen")
+	}
+
+	fakeSrvrDataThs.IncrementChan <- fakesrvrdata.IncrementChanT{RemapName: remap, BytesPerSec: inc}
+
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func cmdSetDelay(w http.ResponseWriter, r *http.Request, fakeSrvrDataThs fakesrvrdata.Ths) {
+	urlQry := r.URL.Query()
+
+	newMinStr := urlQry.Get("min")
+	newMin, err := strconv.ParseUint(newMinStr, 10, 64)
+	if err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte("error parsing query parameter 'min': must be a non-negative integer: " + err.Error() + "\n"))
+		return
+	}
+
+	newMaxStr := urlQry.Get("max")
+	newMax, err := strconv.ParseUint(newMaxStr, 10, 64)
+	if err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte("error parsing query parameter 'max': must be a non-negative integer: " + err.Error() + "\n"))
+		return
+	}
+
+	newMinMax := fakesrvrdata.MinMaxUint64{Min: newMin, Max: newMax}
+	newMinMaxPtr := &newMinMax
+
+	p := (unsafe.Pointer)(newMinMaxPtr)
+	atomic.StorePointer(fakeSrvrDataThs.DelayMS, p)
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func cmdSetSystem(w http.ResponseWriter, r *http.Request, fakeSrvrDataThs fakesrvrdata.Ths) {
+	urlQry := r.URL.Query()
+
+	if newSpeedStr := urlQry.Get("speed"); newSpeedStr != "" {
+		newSpeed, err := strconv.ParseInt(newSpeedStr, 10, 32)
+		if err != nil {
+			w.WriteHeader(http.StatusBadRequest)
+			w.Write([]byte("error parsing query parameter 'speed': must be a non-negative integer: " + err.Error() + "\n"))
+			return
+		}
+
+		srvr := (*fakesrvrdata.FakeServerData)(fakeSrvrDataThs.Get())
+		srvr.System.Speed = int(newSpeed)
+		fakeSrvrDataThs.Set(srvr)
+	}
+
+	if newLoadAvg1MStr := urlQry.Get("loadavg1m"); newLoadAvg1MStr != "" {
+		newLoadAvg1M, err := strconv.ParseFloat(newLoadAvg1MStr, 64)
+		if err != nil {
+			w.WriteHeader(http.StatusBadRequest)
+			w.Write([]byte("error parsing query parameter 'loadavg1m': must be a number: " + err.Error() + "\n"))
+			return
+		}
+
+		srvr := (*fakesrvrdata.FakeServerData)(fakeSrvrDataThs.Get())
+		srvr.System.ProcLoadAvg.CPU1m = newLoadAvg1M
+		fakeSrvrDataThs.Set(srvr)
+	}
+
+	if newLoadAvg5MStr := urlQry.Get("loadavg5m"); newLoadAvg5MStr != "" {
+		newLoadAvg5M, err := strconv.ParseFloat(newLoadAvg5MStr, 64)
+		if err != nil {
+			w.WriteHeader(http.StatusBadRequest)
+			w.Write([]byte("error parsing query parameter 'loadavg5m': must be a number: " + err.Error() + "\n"))
+			return
+		}
+
+		srvr := (*fakesrvrdata.FakeServerData)(fakeSrvrDataThs.Get())
+		srvr.System.ProcLoadAvg.CPU5m = newLoadAvg5M
+		fakeSrvrDataThs.Set(srvr)
+	}
+
+	if newLoadAvg10MStr := urlQry.Get("loadavg10m"); newLoadAvg10MStr != "" {
+		newLoadAvg10M, err := strconv.ParseFloat(newLoadAvg10MStr, 64)
+		if err != nil {
+			w.WriteHeader(http.StatusBadRequest)
+			w.Write([]byte("error parsing query parameter 'loadavg10m': must be a non-negative integer: " + err.Error() + "\n"))
+			return
+		}
+
+		srvr := (*fakesrvrdata.FakeServerData)(fakeSrvrDataThs.Get())
+		srvr.System.ProcLoadAvg.CPU10m = newLoadAvg10M
+		fakeSrvrDataThs.Set(srvr)
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}
diff --git a/traffic_monitor/tools/testcaches/fakesrvr/fakesrvr.go b/traffic_monitor/tools/testcaches/fakesrvr/fakesrvr.go
index b9a10e6..a370fe3 100644
--- a/traffic_monitor/tools/testcaches/fakesrvr/fakesrvr.go
+++ b/traffic_monitor/tools/testcaches/fakesrvr/fakesrvr.go
@@ -61,7 +61,7 @@ func newData(remaps []string) (fakesrvrdata.FakeServerData, map[string]fakesrvrd
 	}
 	serverData := fakesrvrdata.FakeServerData{
 		ATS: fakesrvrdata.FakeATS{
-			Server: "6.2.2",
+			Server: "7.1.4",
 			Remaps: serverDataRemap,
 		},
 		System: fakesrvrdata.FakeSystem{
diff --git a/traffic_monitor/tools/testcaches/fakesrvr/server.go b/traffic_monitor/tools/testcaches/fakesrvr/server.go
index acfbbc7..7ba81d2 100644
--- a/traffic_monitor/tools/testcaches/fakesrvr/server.go
+++ b/traffic_monitor/tools/testcaches/fakesrvr/server.go
@@ -22,10 +22,15 @@ package fakesrvr
 import (
 	"encoding/json"
 	"fmt"
+	"html"
+	"math/rand"
 	"net/http"
 	"strconv"
+	"strings"
+	"sync/atomic"
 	"time"
 
+	"github.com/apache/trafficcontrol/lib/go-rfc"
 	"github.com/apache/trafficcontrol/traffic_monitor/tools/testcaches/fakesrvrdata"
 )
 
@@ -39,7 +44,22 @@ func reqIsApplicationSystem(r *http.Request) bool {
 
 func astatsHandler(fakeSrvrDataThs fakesrvrdata.Ths) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add(rfc.ContentType, rfc.ApplicationJSON)
 		srvr := (*fakesrvrdata.FakeServerData)(fakeSrvrDataThs.Get())
+
+		delayMSPtr := (*fakesrvrdata.MinMaxUint64)(atomic.LoadPointer(fakeSrvrDataThs.DelayMS))
+		minDelayMS := delayMSPtr.Min
+		maxDelayMS := delayMSPtr.Max
+
+		if maxDelayMS != 0 {
+			delayMS := minDelayMS
+			if minDelayMS != maxDelayMS {
+				delayMS += uint64(rand.Int63n(int64(maxDelayMS - minDelayMS)))
+			}
+			delay := time.Duration(delayMS) * time.Millisecond
+			time.Sleep(delay)
+		}
+
 		// TODO cast to System, if query string `application=system`
 		b := []byte{}
 		err := error(nil)
@@ -50,15 +70,35 @@ func astatsHandler(fakeSrvrDataThs fakesrvrdata.Ths) http.HandlerFunc {
 			b, err = json.MarshalIndent(&srvr, "", "  ") // TODO debug, change to Marshal
 		}
 		if err != nil {
+			w.WriteHeader(http.StatusInternalServerError)
 			w.Write([]byte(`{"error": "marshalling: ` + err.Error() + `"}`)) // TODO escape error for JSON
+			return
 		}
 		w.Write(b)
 	}
 }
 
+func cmdHandler(fakeSrvrDataThs fakesrvrdata.Ths) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		path := html.EscapeString(r.URL.Path)
+		path = strings.ToLower(path)
+		path = strings.TrimLeft(path, "/cmd")
+		for cmd, cmdF := range cmds {
+			if strings.HasPrefix(path, cmd) {
+				cmdF(w, r, fakeSrvrDataThs)
+				return
+			}
+		}
+		w.WriteHeader(http.StatusNotFound)
+		w.Write([]byte("command '" + path + "' not found\n"))
+	}
+}
+
 func Serve(port int, fakeSrvrData fakesrvrdata.Ths) *http.Server {
 	mux := http.NewServeMux()
 	mux.HandleFunc("/_astats", astatsHandler(fakeSrvrData))
+	mux.HandleFunc("/cmd", cmdHandler(fakeSrvrData))
+	mux.HandleFunc("/cmd/", cmdHandler(fakeSrvrData))
 	server := &http.Server{
 		Addr:           ":" + strconv.Itoa(port),
 		Handler:        mux,
diff --git a/traffic_monitor/tools/testcaches/fakesrvrdata/fakesrvrdata.go b/traffic_monitor/tools/testcaches/fakesrvrdata/fakesrvrdata.go
index 1597925..6bf189b 100644
--- a/traffic_monitor/tools/testcaches/fakesrvrdata/fakesrvrdata.go
+++ b/traffic_monitor/tools/testcaches/fakesrvrdata/fakesrvrdata.go
@@ -125,6 +125,7 @@ type FakeSystem struct {
 	LastReload           uint64          `json:"lastReload"`
 	AstatsLoad           uint64          `json:"astatsLoad"`
 	Something            string          `json:"something"`
+	Version              string          `json:"application_version"`
 }
 
 type FakeProcLoadAvg struct {
diff --git a/traffic_monitor/tools/testcaches/fakesrvrdata/run.go b/traffic_monitor/tools/testcaches/fakesrvrdata/run.go
index 4a1956c..520679b 100644
--- a/traffic_monitor/tools/testcaches/fakesrvrdata/run.go
+++ b/traffic_monitor/tools/testcaches/fakesrvrdata/run.go
@@ -32,7 +32,7 @@ type BytesPerSec struct { // TODO change to PerMin? PerHour? (to allow, e.g. one
 
 // runValidate verifies the FakeServerData Remaps match the RemapIncrements
 func runValidate(s *FakeServerData, remapIncrements map[string]BytesPerSec) error {
-	for r, _ := range s.ATS.Remaps {
+	for r := range s.ATS.Remaps {
 		if _, ok := remapIncrements[r]; !ok {
 			return errors.New("remap increments missing server remap '" + r + "'")
 		}
@@ -72,44 +72,71 @@ func Run(s FakeServerData, remapIncrements map[string]BytesPerSec) (Ths, error)
 	}
 	ths := NewThs()
 	ths.Set(&s)
+
 	go run(ths, remapIncrements)
 	return ths, nil
 }
 
+type IncrementChanT struct {
+	RemapName   string
+	BytesPerSec BytesPerSec
+}
+
 // run starts a goroutine incrementing the FakeServerData's values according to the remapIncrements. Never returns.
 func run(srvrThs Ths, remapIncrements map[string]BytesPerSec) {
 	tickSecs := uint64(1) // adjustable for performance (i.e. a higher number is less CPU work)
+
+	ticker := time.NewTicker(time.Second * time.Duration(tickSecs))
+
 	for {
-		time.Sleep(time.Second * time.Duration(tickSecs))
-		srvr := srvrThs.Get()
-		newRemaps := copyRemaps(srvr.ATS.Remaps)
-		for remap, increments := range remapIncrements {
-			srvrRemap := newRemaps[remap]
-			if increments.Min.InBytes != increments.Min.InBytes {
-				i := uint64(rand.Int63n(int64((increments.Max.InBytes-increments.Min.InBytes)*tickSecs))) + (increments.Min.InBytes * tickSecs)
-				srvrRemap.InBytes += i
-				srvr.System.ProcNetDev.RcvBytes += i
-			}
-			if increments.Min.OutBytes != increments.Max.OutBytes {
-				i := uint64(rand.Int63n(int64((increments.Max.OutBytes-increments.Min.OutBytes)*tickSecs))) + (increments.Min.OutBytes * tickSecs)
-				srvrRemap.OutBytes += i
-				srvr.System.ProcNetDev.SndBytes += i
-			}
-			if increments.Min.Status2xx != increments.Max.Status2xx {
-				srvrRemap.Status2xx += uint64(rand.Int63n(int64((increments.Max.Status2xx-increments.Min.Status2xx)*tickSecs))) + (increments.Min.Status2xx * tickSecs)
-			}
-			if increments.Min.Status3xx != increments.Max.Status3xx {
-				srvrRemap.Status3xx += uint64(rand.Int63n(int64((increments.Max.Status3xx-increments.Min.Status3xx)*tickSecs))) + (increments.Min.Status3xx * tickSecs)
-			}
-			if increments.Min.Status4xx != increments.Max.Status4xx {
-				srvrRemap.Status4xx += uint64(rand.Int63n(int64((increments.Max.Status4xx-increments.Min.Status4xx)*tickSecs))) + (increments.Min.Status4xx * tickSecs)
-			}
-			if increments.Min.Status5xx != increments.Max.Status5xx {
-				srvrRemap.Status5xx += uint64(rand.Int63n(int64((increments.Max.Status5xx-increments.Min.Status5xx)*tickSecs))) + (increments.Min.Status5xx * tickSecs)
+		select {
+		case srvrThs.GetIncrementsChan <- remapIncrements:
+		case newIncrement := <-srvrThs.IncrementChan:
+			remapIncrements[newIncrement.RemapName] = newIncrement.BytesPerSec
+		case <-ticker.C:
+			srvr := srvrThs.Get()
+			newRemaps := copyRemaps(srvr.ATS.Remaps)
+			for remap, increments := range remapIncrements {
+				srvrRemap := newRemaps[remap]
+
+				addInBytes := increments.Min.InBytes * tickSecs
+				if increments.Min.InBytes != increments.Max.InBytes {
+					addInBytes += uint64(rand.Int63n(int64((increments.Max.InBytes - increments.Min.InBytes) * tickSecs)))
+				}
+				srvrRemap.InBytes += addInBytes
+				srvr.System.ProcNetDev.RcvBytes += addInBytes
+
+				addOutBytes := increments.Min.OutBytes * tickSecs
+				if increments.Min.OutBytes != increments.Max.OutBytes {
+					addOutBytes += uint64(rand.Int63n(int64((increments.Max.OutBytes - increments.Min.OutBytes) * tickSecs)))
+				}
+				srvrRemap.OutBytes += addOutBytes
+				srvr.System.ProcNetDev.SndBytes += addOutBytes
+
+				srvrRemap.Status2xx += increments.Min.Status2xx * tickSecs
+				if increments.Min.Status2xx != increments.Max.Status2xx {
+					srvrRemap.Status2xx += uint64(rand.Int63n(int64((increments.Max.Status2xx - increments.Min.Status2xx) * tickSecs)))
+				}
+
+				srvrRemap.Status3xx += increments.Min.Status3xx * tickSecs
+				if increments.Min.Status3xx != increments.Max.Status3xx {
+					srvrRemap.Status3xx += uint64(rand.Int63n(int64((increments.Max.Status3xx - increments.Min.Status3xx) * tickSecs)))
+				}
+
+				srvrRemap.Status4xx += increments.Min.Status4xx * tickSecs
+				if increments.Min.Status4xx != increments.Max.Status4xx {
+					srvrRemap.Status4xx += uint64(rand.Int63n(int64((increments.Max.Status4xx - increments.Min.Status4xx) * tickSecs)))
+				}
+
+				srvrRemap.Status5xx += increments.Min.Status5xx * tickSecs
+				if increments.Min.Status5xx != increments.Max.Status5xx {
+					srvrRemap.Status5xx += uint64(rand.Int63n(int64((increments.Max.Status5xx - increments.Min.Status5xx) * tickSecs)))
+				}
+
+				newRemaps[remap] = srvrRemap
 			}
-			newRemaps[remap] = srvrRemap
+			srvr.ATS.Remaps = newRemaps
+			srvrThs.Set(srvr)
 		}
-		srvr.ATS.Remaps = newRemaps
-		srvrThs.Set(srvr)
 	}
 }
diff --git a/traffic_monitor/tools/testcaches/fakesrvrdata/ths.go b/traffic_monitor/tools/testcaches/fakesrvrdata/ths.go
index e0befa2..a08566a 100644
--- a/traffic_monitor/tools/testcaches/fakesrvrdata/ths.go
+++ b/traffic_monitor/tools/testcaches/fakesrvrdata/ths.go
@@ -21,17 +21,42 @@ package fakesrvrdata
 
 import (
 	"sync"
+	"unsafe"
 )
 
+type MinMaxUint64 struct {
+	Min uint64
+	Max uint64
+}
+
 // Ths provides threadsafe access to a ThsT pointer. Note the object itself is not safe for multiple access, and must not be mutated, either by the original owner after calling Set, or by future users who call Get. If you need to mutate, perform a deep copy.
 type Ths struct {
 	v *ThsT
 	m *sync.RWMutex
+	// IncrementChan may be used to set the increments for a particular remap.
+	// Note this is not synchronized with GetIncrementChan, so multiple writers calling GetIncrementChan and IncrmeentChan to get and set will race, unless they are externally synchronized.
+	IncrementChan chan IncrementChanT
+	// GetIncrementsChan may be used to get the current increments for all remaps.
+	// The returned map must not be modified.
+	// Note this is not synchronized with GetIncrementChan, so multiple writers calling GetIncrementChan and IncrmeentChan to get and set will race, unless they are externally synchronized.
+	GetIncrementsChan chan map[string]BytesPerSec
+
+	// DelayMS is the minimum and maximum delay to serve requests, in milliseconds.
+	// Atomic - MUST be accessed with sync/atomic.LoadUintptr and sync/atomic.StoreUintptr.
+	DelayMS *unsafe.Pointer
 }
 
 func NewThs() Ths {
 	v := ThsT(nil)
-	return Ths{m: &sync.RWMutex{}, v: &v}
+	delayMSPtr := &MinMaxUint64{}
+	delayMSUnsafePtr := unsafe.Pointer(delayMSPtr)
+	return Ths{
+		m:                 &sync.RWMutex{},
+		v:                 &v,
+		IncrementChan:     make(chan IncrementChanT, 10), // arbitrarily allow 10 writes before blocking. TODO document? config?
+		GetIncrementsChan: make(chan map[string]BytesPerSec),
+		DelayMS:           &delayMSUnsafePtr,
+	}
 }
 
 func (t Ths) Set(v ThsT) {
diff --git a/traffic_monitor/.gitignore b/traffic_monitor/tools/testto/Dockerfile
similarity index 60%
copy from traffic_monitor/.gitignore
copy to traffic_monitor/tools/testto/Dockerfile
index fad340b..52b4bad 100644
--- a/traffic_monitor/.gitignore
+++ b/traffic_monitor/tools/testto/Dockerfile
@@ -14,6 +14,16 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-#
-#TM binary
-traffic_monitor
+
+FROM centos:8
+MAINTAINER dev@trafficcontrol.apache.org
+
+RUN dnf -y install golang git
+ENV GOPATH=/go
+
+RUN go get -u github.com/apache/trafficcontrol/lib/go-rfc github.com/apache/trafficcontrol/lib/go-tc
+ADD . ${GOPATH}/src/github.com/apache/trafficcontrol/traffic_monitor/tools/testto
+WORKDIR ${GOPATH}/src/github.com/apache/trafficcontrol/traffic_monitor/tools/testto
+RUN go build && cp testto /usr/sbin
+
+CMD ${GOPATH}/src/github.com/apache/trafficcontrol/traffic_monitor/tools/testto/Dockerfile_run.sh
diff --git a/traffic_monitor/build.sh b/traffic_monitor/tools/testto/Dockerfile_run.sh
similarity index 77%
copy from traffic_monitor/build.sh
copy to traffic_monitor/tools/testto/Dockerfile_run.sh
index baf07cc..b985900 100755
--- a/traffic_monitor/build.sh
+++ b/traffic_monitor/tools/testto/Dockerfile_run.sh
@@ -1,3 +1,4 @@
+#!/usr/bin/env 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
@@ -14,5 +15,19 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-#!/usr/bin/env bash
-go build -ldflags "-X main.GitRevision=`git rev-parse HEAD` -X main.BuildTimestamp=`date +'%Y-%M-%dT%H:%M:%S'`"
+
+start() {
+  ARGS=
+  if [[ -n "${PORT}" ]]; then
+    ARGS="$ARGS -port ${PORT}"
+  fi
+  testto ${ARGS}
+}
+
+init() {
+  echo "INITIALIZED=1" >>/etc/environment
+}
+
+source /etc/environment
+if [ -z "$INITIALIZED" ]; then init; fi
+start
diff --git a/traffic_monitor/tools/testto/README.md b/traffic_monitor/tools/testto/README.md
new file mode 100644
index 0000000..223f4a7
--- /dev/null
+++ b/traffic_monitor/tools/testto/README.md
@@ -0,0 +1,27 @@
+<!--
+    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.
+-->
+
+## Docker
+
+Build environment variables: none
+
+Run environment variables:
+
+- `PORT` - the port to serve on. If none is specified, no port argument will be passed to the app, and it will use its
+  default.
diff --git a/traffic_monitor/tools/testto/testto.go b/traffic_monitor/tools/testto/testto.go
new file mode 100644
index 0000000..950906f
--- /dev/null
+++ b/traffic_monitor/tools/testto/testto.go
@@ -0,0 +1,267 @@
+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 (
+	"encoding/json"
+	"flag"
+	"fmt"
+	"log"
+	"net/http"
+	"regexp"
+	"strconv"
+	"sync"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-rfc"
+	"github.com/apache/trafficcontrol/lib/go-tc"
+)
+
+func main() {
+	port := flag.Int("port", 8000, "Port to serve on")
+	flag.Parse()
+	if *port < 0 || *port > 65535 {
+		fmt.Println("port must be 0-65535")
+		return
+	}
+
+	toDataThs := NewThs()
+	toDataThs.Set(&FakeTOData{Servers: []tc.ServerV40{}})
+
+	Serve(*port, toDataThs)
+	fmt.Printf("Serving on %v\n", *port)
+
+	for {
+		// TODO handle sighup to die
+		time.Sleep(time.Hour)
+	}
+}
+
+type FakeTOData struct {
+	Monitoring tc.TrafficMonitorConfig
+	CRConfig   tc.CRConfig
+	Servers    []tc.ServerV40
+}
+
+// TODO make timeouts configurable?
+
+const readTimeout = time.Second * 10
+const writeTimeout = time.Second * 10
+
+func Serve(port int, fakeTOData Ths) *http.Server {
+	// TODO add HTTPS
+	server := &http.Server{
+		Addr:           ":" + strconv.Itoa(port),
+		Handler:        RouteHandler(fakeTOData),
+		ReadTimeout:    readTimeout,
+		WriteTimeout:   writeTimeout,
+		MaxHeaderBytes: 1 << 20,
+	}
+	go func() {
+		if err := server.ListenAndServe(); err != nil {
+			fmt.Println("Error serving on port " + strconv.Itoa(port) + ": " + err.Error())
+		}
+	}()
+	return server
+}
+
+type Route struct {
+	Regex   *regexp.Regexp
+	Handler http.HandlerFunc
+}
+
+func GetRoutes(fakeTOData Ths) []Route {
+	routes := []Route{}
+	for route, makeHandler := range Routes {
+		routeRegex := regexp.MustCompile(route)
+		routes = append(routes, Route{Regex: routeRegex, Handler: makeHandler(fakeTOData)})
+	}
+	return routes
+}
+
+type MakeHandlerFunc func(fakeTOData Ths) http.HandlerFunc
+
+var Routes = map[string]MakeHandlerFunc{
+	`/api/(.*)/user/login/?(\.json)?$`:          loginHandler,
+	`/api/(.*)/cdns/(.*)/configs/monitoring/?$`: monitoringHandler,
+	`/api/(.*)/servers/?(\.json)?$`:             serversHandler,
+	`/api/(.*)/cdns/(.*)/snapshot/?(\.json)?$`:  crConfigHandler,
+	`/api/(.*)/ping`:                            pingHandler,
+}
+
+func RouteHandler(fakeTOData Ths) http.HandlerFunc {
+	routes := GetRoutes(fakeTOData)
+	return func(w http.ResponseWriter, r *http.Request) {
+		for _, route := range routes {
+			if route.Regex.MatchString(r.URL.Path) {
+				route.Handler(w, r)
+				return
+			}
+		}
+		w.WriteHeader(http.StatusNotFound)
+	}
+}
+
+func pingHandler(fakeTOData Ths) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodGet {
+			w.Header().Set(rfc.ContentType, rfc.ApplicationJSON)
+			w.Write(append([]byte(`{"ping":"pong"}`), '\n'))
+		} else {
+			w.WriteHeader(http.StatusMethodNotAllowed)
+		}
+	}
+}
+
+func monitoringHandler(fakeTOData Ths) http.HandlerFunc {
+	return makeJSONGetPostHandler(fakeTOData, monitoringHandlerGet, monitoringHandlerPost)
+}
+
+func serversHandler(fakeTOData Ths) http.HandlerFunc {
+	return makeJSONGetPostHandler(fakeTOData, serversHandlerGet, serversHandlerPost)
+}
+
+func crConfigHandler(fakeTOData Ths) http.HandlerFunc {
+	return makeJSONGetPostHandler(fakeTOData, crConfigHandlerGet, crConfigHandlerPost)
+}
+
+func makeJSONGetPostHandler(
+	fakeTOData Ths,
+	makeGetHandler func(fakeTOData Ths) http.HandlerFunc,
+	makePostHandler func(fakeTOData Ths) http.HandlerFunc,
+) http.HandlerFunc {
+	getHandler := makeGetHandler(fakeTOData)
+	postHandler := makePostHandler(fakeTOData)
+	return func(w http.ResponseWriter, r *http.Request) {
+		switch r.Method {
+		case http.MethodPost:
+			postHandler(w, r)
+		case http.MethodGet:
+			getHandler(w, r)
+		default:
+			w.WriteHeader(http.StatusMethodNotAllowed)
+		}
+	}
+}
+
+func monitoringHandlerPost(fakeTOData Ths) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		obj := (*FakeTOData)(fakeTOData.Get())
+		postJSONObj(w, r, &obj.Monitoring, obj, fakeTOData)
+	}
+}
+
+func monitoringHandlerGet(fakeTOData Ths) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		writeJSONObj(w, ((*FakeTOData)(fakeTOData.Get())).Monitoring)
+	}
+}
+
+func serversHandlerGet(fakeTOData Ths) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		writeJSONObj(w, ((*FakeTOData)(fakeTOData.Get())).Servers)
+	}
+}
+
+func serversHandlerPost(fakeTOData Ths) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		obj := (*FakeTOData)(fakeTOData.Get())
+		postJSONObj(w, r, &obj.Servers, obj, fakeTOData)
+	}
+}
+
+func crConfigHandlerGet(fakeTOData Ths) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		crConfig := ((*FakeTOData)(fakeTOData.Get())).CRConfig
+		if crConfig.Stats.CDNName == nil && crConfig.Stats.TMHost == nil {
+			w.WriteHeader(http.StatusNotFound)
+			w.Write(append([]byte(http.StatusText(http.StatusNotFound)), '\n'))
+			return
+		}
+		writeJSONObj(w, crConfig)
+	}
+}
+
+func crConfigHandlerPost(fakeTOData Ths) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		obj := (*FakeTOData)(fakeTOData.Get())
+		postJSONObj(w, r, &obj.CRConfig, obj, fakeTOData)
+	}
+}
+
+func writeJSONObj(w http.ResponseWriter, obj interface{}) {
+	bts, err := json.MarshalIndent(&obj, "", "  ")
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		w.Write([]byte(`{"error": "marshalling: ` + err.Error() + `"}`))
+		return
+	}
+	// TODO write content type
+	w.Write([]byte(`{"response":`))
+	w.Write(bts)
+	w.Write([]byte(`}`))
+	w.Write([]byte("\n"))
+}
+
+func postJSONObj(w http.ResponseWriter, r *http.Request, obj interface{}, fakeTOData ThsT, fakeTODataThs Ths) {
+	if err := json.NewDecoder(r.Body).Decode(obj); err != nil {
+		log.Println("unmarshall:" + err.Error() + ", " + r.URL.String())
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte(`{"error": "unmarshalling posted body: ` + err.Error() + `"}`))
+		return
+	}
+	fakeTODataThs.Set(fakeTOData)
+	w.WriteHeader(http.StatusNoContent)
+}
+
+type ThsT *FakeTOData
+
+type Ths struct {
+	v *ThsT
+	m *sync.RWMutex
+}
+
+func NewThs() Ths {
+	v := ThsT(nil)
+	return Ths{
+		m: &sync.RWMutex{},
+		v: &v,
+	}
+}
+
+func (t Ths) Set(v ThsT) {
+	t.m.Lock()
+	defer t.m.Unlock()
+	*t.v = v
+}
+
+func (t Ths) Get() ThsT {
+	t.m.RLock()
+	defer t.m.RUnlock()
+	return *t.v
+}
+
+func loginHandler(Ths) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Set-Cookie", `mojolicious=fake; Path=/; Expires=Thu, 13 Dec 2018 21:21:33 GMT; HttpOnly`)
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(`{"alerts":[{"text": "Successfully logged in.","level": "success"}]}`))
+	}
+}
diff --git a/traffic_monitor/towrap/towrap.go b/traffic_monitor/towrap/towrap.go
index f7ef5b7..e0857e8 100644
--- a/traffic_monitor/towrap/towrap.go
+++ b/traffic_monitor/towrap/towrap.go
@@ -442,7 +442,7 @@ func (s TrafficOpsSessionThreadsafe) fetchLegacyTMConfig(cdn string) (*tc.Traffi
 }
 
 // trafficMonitorConfigMapRaw returns the Traffic Monitor config map from the
-// Traffic Ops, directly from the monitoring.json endpoint. This is not usually
+// Traffic Ops, directly from the monitoring endpoint. This is not usually
 // what is needed, rather monitoring needs the snapshotted CRConfig data, which
 // is filled in by `LegacyTrafficMonitorConfigMap`. This is safe for multiple
 // goroutines.