Repository: apache/trafficcontrol
Pull request #5572 (2021-02-24 17:50:57)

Fix broken reference in docs to removed section
## What does this PR (Pull Request) do?
- [x] This PR is not related to any Issue

Fixes a reference in the documentation to the removed "Data Source Extension" section. Data Source Extensions were only supported by the now-removed Perl implementation of Traffic Ops.


## Which Traffic Control components are affected by this PR?
- Documentation

## What is the best way to verify this PR?
Build the documentation, verify no warnings or errors.

## If this is a bug fix, what versions of Traffic Control are affected?
- master

## The following criteria are ALL met by this PR
- [x] Tests are unnecessary
- [x] This PR includes documentation
- [x] An update to CHANGELOG.md is not necessary
- [x] This PR includes any and all required license headers
- [x] This PR **DOES NOT FIX A SERIOUS SECURITY VULNERABILITY**


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c42ab2f29..4b9a51e54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 - Added functionality to automatically renew ACME certificates.
 
 ### Fixed
+- [#5558](https://github.com/apache/trafficcontrol/issues/5558) - Fixed `TM UI` and `/api/cache-statuses` to report aggregate `bandwidth_kbps` correctly.
 - [#5288](https://github.com/apache/trafficcontrol/issues/5288) - Fixed the ability to create and update a server with MTU value >= 1280.
 - [#5445](https://github.com/apache/trafficcontrol/issues/5445) - When updating a registered user, ignore updates on registration_sent field.
 - [#5335](https://github.com/apache/trafficcontrol/issues/5335) - Don't create a change log entry if the delivery service primary origin hasn't changed
@@ -64,6 +65,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 ### Changed
 - Refactored the Traffic Ops Go client internals so that all public methods have a consistent behavior/implementation
 - Pinned external actions used by Documentation Build and TR Unit Tests workflows to commit SHA-1 and the Docker image used by the Weasel workflow to a SHA-256 digest
+- Updated the Traffic Ops Python client to 3.0
 - Updated Flot libraries to supported versions
 - [apache/trafficcontrol](https://github.com/apache/trafficcontrol) is now a Go module
 - Set Traffic Router to only accept TLSv1.1, TLSv1.2, and TLSv1.3 protocols by default in server.xml
diff --git a/docs/source/admin/traffic_ops.rst b/docs/source/admin/traffic_ops.rst
index 3595758ec..61dff7d42 100644
--- a/docs/source/admin/traffic_ops.rst
+++ b/docs/source/admin/traffic_ops.rst
@@ -214,6 +214,8 @@ Guide
 		| Password for the admin user                        | The password for the administrative Traffic Ops user.                                          |
 		+----------------------------------------------------+------------------------------------------------------------------------------------------------+
 
+.. note:: A Python postinstall script also exists, and, unlike the Perl postinstall script, has no dependencies besides the interpreter itself. To use it, run ``/opt/traffic_ops/install/bin/postinstall.py`` with the same arguments you would have passed to ``/opt/traffic_ops/install/bin/postinstall`` (runs under either Python 3 or Python 2).
+
 .. _to-upgrading:
 
 Upgrading
@@ -654,7 +656,9 @@ You will need to update `cdn.conf`_ with any necessary changes.
 
 Managing Traffic Ops Extensions
 ===============================
-Traffic Ops supports two types of extensions. `Check Extensions`_ are analytics scripts that collect and display information as columns in the table under :menuselection:`Monitor --> Cache Checks` in Traffic Portal. `Data Source Extensions`_ provide ways to add data to the graph views and usage APIs.
+Traffic Ops supports "`Check Extensions`_", which are analytics scripts that collect and display information as columns in the table under :menuselection:`Monitor --> Cache Checks` in Traffic Portal.
+
+.. seealso:: Traffic Ops also supports a more involved type of extension in the form of :ref:`to_go_plugins`.
 
 .. |checkmark| image:: images/good.png
 .. |X| image:: images/bad.png
diff --git a/infrastructure/ansible/roles/traffic-router/defaults/main.yml b/infrastructure/ansible/roles/traffic-router/defaults/main.yml
index 532dc62cb..0c941715a 100644
--- a/infrastructure/ansible/roles/traffic-router/defaults/main.yml
+++ b/infrastructure/ansible/roles/traffic-router/defaults/main.yml
@@ -104,6 +104,7 @@ tr_connectors:
     SSLEnabled: true
     clientAuth: false
     sslProtocol: 'TLS'
+    protocols: '+TLSv1.1,+TLSv1.2,+TLSv1.3'
     sslImplementationName: 'com.comcast.cdn.traffic_control.traffic_router.protocol.RouterSslImplementation'
 
 # dns.properties
diff --git a/infrastructure/cdn-in-a-box/ort/traffic_ops_ort/to_api.py b/infrastructure/cdn-in-a-box/ort/traffic_ops_ort/to_api.py
index 8b6cdb332..973d777b0 100644
--- a/infrastructure/cdn-in-a-box/ort/traffic_ops_ort/to_api.py
+++ b/infrastructure/cdn-in-a-box/ort/traffic_ops_ort/to_api.py
@@ -44,9 +44,9 @@ class API(TOSession):
 	functionality for getting things needed by :term:`ORT`.
 	"""
 
-	#: This should always be the latest API version supported - note this breaks compatability with
+	#: This should always be the latest API version supported - note this breaks compatibility with
 	#: older ATC versions. Go figure.
-	VERSION = "2.0"
+	VERSION = "3.0"
 
 	def __init__(self, conf:Configuration):
 		"""
diff --git a/traffic_control/clients/python/to_access/__init__.py b/traffic_control/clients/python/to_access/__init__.py
index 95a44fc33..1be9a15cf 100644
--- a/traffic_control/clients/python/to_access/__init__.py
+++ b/traffic_control/clients/python/to_access/__init__.py
@@ -271,7 +271,7 @@ def parse_arguments(program):
 	                    help="Request exactly PATH; it won't be prefaced with '/api/{{api-version}}/")
 	parser.add_argument("-a", "--api-version",
 	                    type=float,
-	                    default=2.0,
+	                    default=3.0,
 	                    help="Specify the API version to request against")
 	parser.add_argument("-p", "--pretty",
 	                    action="store_true",
diff --git a/traffic_control/clients/python/trafficops/__version__.py b/traffic_control/clients/python/trafficops/__version__.py
index b1dfe7d0f..ffea76c43 100644
--- a/traffic_control/clients/python/trafficops/__version__.py
+++ b/traffic_control/clients/python/trafficops/__version__.py
@@ -21,10 +21,10 @@ gives the version of this *Apache-TrafficControl package* and **not** the versio
 client to grow in a version-controlled manner without being tied to the release cadence of Apache
 Traffic Control as a whole.
 
-Version 2.0 is supported for use with Apache Traffic Control versions 4.1 and 5.0 (release pending
+Version 3.0 is supported for use with Apache Traffic Control versions 5.0 and 5.1 (release pending
 at the time of this writing). New functionality will be added as the :ref:`to-api` evolves, but
 changes to this client will remain non-breaking for existing code using it until the next major
 version is released.
 """
 
-__version__ = '2.0.2'
+__version__ = '3.0.0'
diff --git a/traffic_control/clients/python/trafficops/tosession.py b/traffic_control/clients/python/trafficops/tosession.py
index 4e2bfb2e2..19b252809 100644
--- a/traffic_control/clients/python/trafficops/tosession.py
+++ b/traffic_control/clients/python/trafficops/tosession.py
@@ -60,7 +60,7 @@ class TOSession(RestApiSession):
 	.. code-block:: python3
 		:caption: Endpoint with no URL parameters and no query parameters
 
-		@api_request('get', 'cdns', ('2.0',))
+		@api_request('get', 'cdns', ('3.0',))
 		def get_cdns(self):
 			pass
 
@@ -68,7 +68,7 @@ class TOSession(RestApiSession):
 	.. code-block:: python3
 		:caption: End-point with URL parameters and no query parameters
 
-		@api_request('get', 'cdns/{cdn_id:d}', ('2.0',))
+		@api_request('get', 'cdns/{cdn_id:d}', ('3.0',))
 		def get_cdn_by_id(self, cdn_id=None):
 			pass
 
@@ -76,28 +76,28 @@ class TOSession(RestApiSession):
 	.. code-block:: python3
 		:caption: End-point with no URL parameters but with query parameters
 
-		@api_request('get', 'deliveryservices', ('2.0',))
+		@api_request('get', 'deliveryservices', ('3.0',))
 		def get_deliveryservices(self, query_params=None):
 			pass
 
 	.. code-block:: python3
 		:caption: End-point with URL parameters and query parameters
 
-		@api_request('get', 'deliveryservices/xmlId/{xml_id}/sslkeys', ('2.0',))
+		@api_request('get', 'deliveryservices/xmlId/{xml_id}/sslkeys', ('3.0',))
 		def get_deliveryservice_ssl_keys_by_xml_id(self, xml_id=None, query_params=None):
 			pass
 
 	.. code-block:: python3
 		:caption: End-point with request data
 
-		@api_request('post', 'cdns', ('2.0',))
+		@api_request('post', 'cdns', ('3.0',))
 		def create_cdn(self, data=None):
 			pass
 
 	.. code-block:: python3
 		:caption: End-point with URL parameters and request data
 
-		@api_request('put', 'cdns', ('2.0',))
+		@api_request('put', 'cdns', ('3.0',))
 		def update_cdn_by_id(self, cdn_id=None, data=None):
 			pass
 
@@ -112,7 +112,7 @@ class TOSession(RestApiSession):
 	.. note:: Only a small subset of the API endpoints are implemented. More can be implemented as needed.
 	"""
 
-	def __init__(self, host_ip, host_port=443, api_version='2.0', ssl=True, headers=None,
+	def __init__(self, host_ip, host_port=443, api_version='3.0', ssl=True, headers=None,
 	             verify_cert=True):
 		"""
 		The class initializer.
@@ -254,7 +254,7 @@ class TOSession(RestApiSession):
 	#
 	#	API Capabilities
 	#
-	@api_request('get', 'api_capabilities', ('2.0',))
+	@api_request('get', 'api_capabilities', ('3.0',))
 	def get_api_capabilities(self, query_params=None):
 		"""
 		Get all API-capability mappings
@@ -266,7 +266,7 @@ class TOSession(RestApiSession):
 	#
 	# ASN
 	#
-	@api_request('get', 'asns', ('2.0',))
+	@api_request('get', 'asns', ('3.0',))
 	def get_asns(self, query_params=None):
 		"""
 		Get ASNs.
@@ -275,7 +275,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'asns', ('2.0',))
+	@api_request('post', 'asns', ('3.0',))
 	def create_asn(self, data=None):
 		"""
 		Create ASN
@@ -286,7 +286,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'asns', ('2.0',))
+	@api_request('put', 'asns', ('3.0',))
 	def update_asn(self, query_params=None):
 		"""
 		Update ASN
@@ -297,7 +297,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'asns', ('2.0',))
+	@api_request('delete', 'asns', ('3.0',))
 	def delete_asn(self, query_params=None):
 		"""
 		Delete ASN
@@ -311,7 +311,7 @@ class TOSession(RestApiSession):
 	#
 	# Cache Statistics
 	#
-	@api_request('get', 'cache_stats', ('2.0',))
+	@api_request('get', 'cache_stats', ('3.0',))
 	def get_cache_stats(self, query_params=None):
 		"""
 		Retrieves statistics about the CDN.
@@ -322,7 +322,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'caches/stats', ('2.0',))
+	@api_request('get', 'caches/stats', ('3.0',))
 	def get_traffic_monitor_cache_stats(self):
 		"""
 		Retrieves cache stats from Traffic Monitor. Also includes rows for aggregates
@@ -334,7 +334,7 @@ class TOSession(RestApiSession):
 	#
 	# Cache Groups
 	#
-	@api_request('get', 'cachegroups', ('2.0',))
+	@api_request('get', 'cachegroups', ('3.0',))
 	def get_cachegroups(self, query_params=None):
 		"""
 		Get Cache Groups.
@@ -343,7 +343,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'cachegroups/{cache_group_id:d}/parameters', ('2.0',))
+	@api_request('get', 'cachegroups/{cache_group_id:d}/parameters', ('3.0',))
 	def get_cachegroup_parameters(self, cache_group_id=None):
 		"""
 		Get a cache groups parameters
@@ -354,7 +354,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'cachegroupparameters', ('2.0',))
+	@api_request('get', 'cachegroupparameters', ('3.0',))
 	def get_all_cachegroup_parameters(self):
 		"""
 		A collection of all cache group parameters.
@@ -363,7 +363,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'cachegroups', ('2.0',))
+	@api_request('post', 'cachegroups', ('3.0',))
 	def create_cachegroups(self, data=None):
 		"""
 		Create a Cache Group
@@ -374,7 +374,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'cachegroups/{cache_group_id:d}', ('2.0',))
+	@api_request('put', 'cachegroups/{cache_group_id:d}', ('3.0',))
 	def update_cachegroups(self, cache_group_id=None, data=None):
 		"""
 		Update a cache group
@@ -387,7 +387,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'cachegroups/{cache_group_id:d}', ('2.0',))
+	@api_request('delete', 'cachegroups/{cache_group_id:d}', ('3.0',))
 	def delete_cachegroups(self, cache_group_id=None):
 		"""
 		Delete a cache group
@@ -398,7 +398,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'cachegroups/{cache_group_id:d}/queue_update', ('2.0',))
+	@api_request('post', 'cachegroups/{cache_group_id:d}/queue_update', ('3.0',))
 	def cachegroups_queue_update(self, cache_group_id=None, data=None):
 		"""
 		Queue Updates by Cache Group ID
@@ -411,7 +411,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'cachegroupparameters/{cache_group_id:d}/{parameter_id:d}', ('2.0',))
+	@api_request('delete', 'cachegroupparameters/{cache_group_id:d}/{parameter_id:d}', ('3.0',))
 	def delete_cache_group_parameters(self, cache_group_id=None, parameter_id=None):
 		"""
 		Delete a cache group parameter association
@@ -427,7 +427,7 @@ class TOSession(RestApiSession):
 	#
 	# Capabilities
 	#
-	@api_request('get', 'capabilities', ('2.0',))
+	@api_request('get', 'capabilities', ('3.0',))
 	def get_capabilities(self, query_params=None):
 		"""
 		Retrieves capabilities
@@ -441,7 +441,7 @@ class TOSession(RestApiSession):
 	#
 	# CDN
 	#
-	@api_request('get', 'cdns', ('2.0',))
+	@api_request('get', 'cdns', ('3.0',))
 	def get_cdns(self, query_params=None):
 		"""
 		Get all CDNs.
@@ -452,7 +452,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'cdns', ('2.0',))
+	@api_request('post', 'cdns', ('3.0',))
 	def create_cdn(self, data=None):
 		"""
 		Create a new CDN.
@@ -463,7 +463,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'cdns/{cdn_id:d}', ('2.0',))
+	@api_request('put', 'cdns/{cdn_id:d}', ('3.0',))
 	def update_cdn_by_id(self, cdn_id=None, data=None):
 		"""
 		Update a CDN by Id.
@@ -476,7 +476,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'cdns/{cdn_id:d}', ('2.0',))
+	@api_request('delete', 'cdns/{cdn_id:d}', ('3.0',))
 	def delete_cdn_by_id(self, cdn_id=None):
 		"""
 		Delete a CDN by Id.
@@ -487,7 +487,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'cdns/{cdn_id:d}/queue_update', ('2.0',))
+	@api_request('post', 'cdns/{cdn_id:d}/queue_update', ('3.0',))
 	def cdns_queue_update(self, cdn_id=None, data=None):
 		"""
 		Queue Updates by CDN Id.
@@ -503,7 +503,7 @@ class TOSession(RestApiSession):
 	#
 	# CDN Health/Usage
 	#
-	@api_request('get', 'cdns/health', ('2.0',))
+	@api_request('get', 'cdns/health', ('3.0',))
 	def get_cdns_health(self):
 		"""
 		Retrieves the health of all locations (cache groups) for all CDNs
@@ -513,7 +513,7 @@ class TOSession(RestApiSession):
 		"""
 
 
-	@api_request('get', 'cdns/{cdn_name:s}/health', ('2.0',))
+	@api_request('get', 'cdns/{cdn_name:s}/health', ('3.0',))
 	def get_cdn_health_by_name(self, cdn_name=None):
 		"""
 		Retrieves the health of all locations (cache groups) for a given CDN
@@ -524,7 +524,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'cdns/capacity', ('2.0',))
+	@api_request('get', 'cdns/capacity', ('3.0',))
 	def get_cdns_capacity(self):
 		"""
 		Retrieves the aggregate capacity percentages of all locations (cache groups) for a given CDN.
@@ -536,7 +536,7 @@ class TOSession(RestApiSession):
 	#
 	# CDN Routing
 	#
-	@api_request('get', 'cdns/routing', ('2.0',))
+	@api_request('get', 'cdns/routing', ('3.0',))
 	def get_cdns_routing(self):
 		"""
 		Retrieves the aggregate routing percentages of all locations (cache groups) for a given CDN.
@@ -548,7 +548,7 @@ class TOSession(RestApiSession):
 	#
 	# CDN Domains
 	#
-	@api_request('get', 'cdns/domains', ('2.0',))
+	@api_request('get', 'cdns/domains', ('3.0',))
 	def get_cdns_domains(self):
 		"""
 		Retrieves the different CDN domains
@@ -561,7 +561,7 @@ class TOSession(RestApiSession):
 	# CDN Topology
 	#
 
-	@api_request('get', 'cdns/{cdn_name:s}/configs/monitoring', ('2.0',))
+	@api_request('get', 'cdns/{cdn_name:s}/configs/monitoring', ('3.0',))
 	def get_cdn_monitoring_info(self, cdn_name=None):
 		"""
 		Retrieves CDN monitoring information
@@ -575,7 +575,7 @@ class TOSession(RestApiSession):
 	#
 	# DNSSEC Keys
 	#
-	@api_request('get', 'cdns/name/{cdn_name:s}/dnsseckeys', ('2.0',))
+	@api_request('get', 'cdns/name/{cdn_name:s}/dnsseckeys', ('3.0',))
 	def get_cdn_dns_sec_keys(self, cdn_name=None):
 		"""
 		Gets a list of dnsseckeys for a CDN and all associated Delivery Services
@@ -586,7 +586,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'cdns/name/{cdn_name:s}/dnsseckeys', ('2.0',))
+	@api_request('delete', 'cdns/name/{cdn_name:s}/dnsseckeys', ('3.0',))
 	def delete_cdn_dns_sec_keys(self, cdn_name=None):
 		"""
 		Delete dnssec keys for a cdn and all associated delivery services
@@ -597,7 +597,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'cnds/dnsseckeys/generate', ('2.0',))
+	@api_request('post', 'cnds/dnsseckeys/generate', ('3.0',))
 	def create_cdn_dns_sec_keys(self, data=None):
 		"""
 		Generates ZSK and KSK keypairs for a CDN and all associated Delivery Services
@@ -611,7 +611,7 @@ class TOSession(RestApiSession):
 	#
 	# CDN SSL Keys
 	#
-	@api_request('get', 'cdns/name/{cdn_name:s}/sslkeys', ('2.0',))
+	@api_request('get', 'cdns/name/{cdn_name:s}/sslkeys', ('3.0',))
 	def get_cdn_ssl_keys(self, cdn_name=None):
 		"""
 		Returns ssl certificates for all Delivery Services that are a part of the CDN.
@@ -625,7 +625,7 @@ class TOSession(RestApiSession):
 	#
 	# Change Logs
 	#
-	@api_request('get', 'logs', ('2.0',))
+	@api_request('get', 'logs', ('3.0',))
 	def get_change_logs(self, query_params=None):
 		"""
 		Retrieve all change logs from traffic ops
@@ -634,7 +634,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'logs/newcount', ('2.0',))
+	@api_request('get', 'logs/newcount', ('3.0',))
 	def get_change_logs_newcount(self):
 		"""
 		Get amount of new logs from traffic ops
@@ -646,7 +646,7 @@ class TOSession(RestApiSession):
 	#
 	# Delivery Service
 	#
-	@api_request('get', 'deliveryservices', ('2.0',))
+	@api_request('get', 'deliveryservices', ('3.0',))
 	def get_deliveryservices(self, query_params=None):
 		"""
 		Retrieves all delivery services (if admin or ops) or all delivery services assigned to user.
@@ -655,7 +655,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'deliveryservices', ('2.0',))
+	@api_request('post', 'deliveryservices', ('3.0',))
 	def create_deliveryservice(self, data=None):
 		"""
 		Allows user to create a delivery service.
@@ -666,7 +666,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'deliveryservices/{delivery_service_id:d}', ('2.0',))
+	@api_request('put', 'deliveryservices/{delivery_service_id:d}', ('3.0',))
 	def update_deliveryservice_by_id(self, delivery_service_id=None, data=None):
 		"""
 		Update a Delivery Service by Id.
@@ -679,7 +679,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'deliveryservices/{delivery_service_id:d}/safe', ('2.0',))
+	@api_request('put', 'deliveryservices/{delivery_service_id:d}/safe', ('3.0',))
 	def update_deliveryservice_safe(self, delivery_service_id=None, data=None):
 		"""
 		Allows a user to edit limited fields of a Delivery Service.
@@ -693,7 +693,7 @@ class TOSession(RestApiSession):
 		"""
 
 
-	@api_request('delete', 'deliveryservices/{delivery_service_id:d}', ('2.0',))
+	@api_request('delete', 'deliveryservices/{delivery_service_id:d}', ('3.0',))
 	def delete_deliveryservice_by_id(self, delivery_service_id=None):
 		"""
 		Allows user to delete a delivery service.
@@ -707,7 +707,7 @@ class TOSession(RestApiSession):
 	#
 	# Delivery Service Health
 	#
-	@api_request('get', 'deliveryservices/{delivery_service_id:d}/health', ('2.0',))
+	@api_request('get', 'deliveryservices/{delivery_service_id:d}/health', ('3.0',))
 	def get_delivery_service_health(self, delivery_service_id=None):
 		"""
 		Retrieves the health of all locations (cache groups) for a delivery service. Delivery
@@ -719,7 +719,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'deliveryservices/{delivery_service_id:d}/capacity', ('2.0',))
+	@api_request('get', 'deliveryservices/{delivery_service_id:d}/capacity', ('3.0',))
 	def get_delivery_service_capacity(self, delivery_service_id=None):
 		"""
 		Retrieves the capacity percentages of a delivery service. Delivery service must be assigned
@@ -734,7 +734,7 @@ class TOSession(RestApiSession):
 	#
 	# Delivery Service Server
 	#
-	@api_request('get', 'deliveryserviceserver', ('2.0',))
+	@api_request('get', 'deliveryserviceserver', ('3.0',))
 	def get_deliveryserviceserver(self, query_params=None):
 		"""
 		Retrieves delivery service / server assignments. (Allows pagination and limits)
@@ -745,7 +745,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'deliveryserviceserver', ('2.0',))
+	@api_request('post', 'deliveryserviceserver', ('3.0',))
 	def assign_deliveryservice_servers_by_ids(self, data=None):
 		"""
 		Assign servers by id to a Delivery Service. (New Method)
@@ -756,7 +756,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'deliveryservices/{xml_id}/servers', ('2.0',))
+	@api_request('post', 'deliveryservices/{xml_id}/servers', ('3.0',))
 	def assign_deliveryservice_servers_by_names(self, xml_id=None, data=None):
 		"""
 		Assign servers by name to a Delivery Service by xmlId.
@@ -769,7 +769,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'deliveryserviceserver/{delivery_service_id:d}/{server_id:d}',('2.0',))
+	@api_request('delete', 'deliveryserviceserver/{delivery_service_id:d}/{server_id:d}',('3.0',))
 	def delete_deliveryservice_servers_by_id(self, delivery_service_id=None, server_id=None):
 		"""
 		Removes a server (cache) from a delivery service.
@@ -782,7 +782,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'deliveryservices/{delivery_service_id:d}/servers', ('2.0',))
+	@api_request('get', 'deliveryservices/{delivery_service_id:d}/servers', ('3.0',))
 	def get_deliveryservice_servers(self, delivery_service_id=None):
 		"""
 		Retrieves properties of CDN EDGE or ORG servers assigned to a delivery service.
@@ -793,7 +793,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'deliveryservices/{delivery_service_id:d}/servers/eligible', ('2.0',))
+	@api_request('get', 'deliveryservices/{delivery_service_id:d}/servers/eligible', ('3.0',))
 	def get_deliveryservice_ineligible_servers(self, delivery_service_id=None):
 		"""
 		Retrieves properties of CDN EDGE or ORG servers not eligible for assignment to a delivery
@@ -808,7 +808,7 @@ class TOSession(RestApiSession):
 	#
 	# Delivery Service SSL Keys
 	#
-	@api_request('get', 'deliveryservices/xmlId/{xml_id}/sslkeys', ('2.0',))
+	@api_request('get', 'deliveryservices/xmlId/{xml_id}/sslkeys', ('3.0',))
 	def get_deliveryservice_ssl_keys_by_xml_id(self, xml_id=None, query_params=None):
 		"""
 		Get SSL keys for a Delivery Service by xmlId.
@@ -821,7 +821,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'deliveryservices/xmlId/{xml_id}/sslkeys', ('2.0',))
+	@api_request('delete', 'deliveryservices/xmlId/{xml_id}/sslkeys', ('3.0',))
 	def delete_deliveryservice_ssl_keys_by_xml_id(self, xml_id=None, query_params=None):
 		"""
 		Delete SSL keys for a Delivery Service by xmlId.
@@ -834,7 +834,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'deliveryservices/sslkeys/generate', ('2.0',))
+	@api_request('post', 'deliveryservices/sslkeys/generate', ('3.0',))
 	def generate_deliveryservice_ssl_keys(self, data=None):
 		"""
 		Generate an SSL certificate. (self-signed)
@@ -845,7 +845,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'deliveryservices/sslkeys/add', ('2.0',))
+	@api_request('post', 'deliveryservices/sslkeys/add', ('3.0',))
 	def add_ssl_keys_to_deliveryservice(self, data=None):
 		"""
 		Add SSL keys to a Delivery Service.
@@ -859,7 +859,7 @@ class TOSession(RestApiSession):
 	#
 	# Delivery Service URL Sig Keys
 	#
-	@api_request('post', 'deliveryservices/xmlId/{xml_id}/urlkeys/generate', ('2.0',))
+	@api_request('post', 'deliveryservices/xmlId/{xml_id}/urlkeys/generate', ('3.0',))
 	def generate_deliveryservice_url_signature_keys(self, xml_id=None):
 		"""
 		Generate URL Signature Keys for a Delivery Service by xmlId.
@@ -873,7 +873,7 @@ class TOSession(RestApiSession):
 	#
 	# Delivery Service Regexes
 	#
-	@api_request('get', 'deliveryservices_regexes', ('2.0',))
+	@api_request('get', 'deliveryservices_regexes', ('3.0',))
 	def get_deliveryservices_regexes(self):
 		"""
 		Get RegExes for all Delivery Services.
@@ -882,7 +882,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'deliveryservices/{delivery_service_id:d}/regexes', ('2.0',))
+	@api_request('get', 'deliveryservices/{delivery_service_id:d}/regexes', ('3.0',))
 	def get_deliveryservice_regexes_by_id(self, delivery_service_id=None, query_params=None):
 		"""
 		Get RegExes for a Delivery Service by Id.
@@ -895,7 +895,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'deliveryservices/{delivery_service_id:d}/regexes', ('2.0',))
+	@api_request('post', 'deliveryservices/{delivery_service_id:d}/regexes', ('3.0',))
 	def create_deliveryservice_regexes(self, delivery_service_id=None, data=None):
 		"""
 		Create a regex for a delivery service
@@ -908,7 +908,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'deliveryservices/{delivery_service_id:d}/regexes/{regex_id:d}', ('2.0',))
+	@api_request('put', 'deliveryservices/{delivery_service_id:d}/regexes/{regex_id:d}', ('3.0',))
 	def update_deliveryservice_regexes(self, delivery_service_id=None, regex_id=None,
 	                                   query_params=None):
 		"""
@@ -925,7 +925,7 @@ class TOSession(RestApiSession):
 		"""
 
 	@api_request('delete', 'deliveryservices/{delivery_service_id:d}/regexes/'
-	                        '{delivery_service_regex_id:d}', ('2.0',))
+	                        '{delivery_service_regex_id:d}', ('3.0',))
 	def delete_deliveryservice_regex_by_regex_id(self, delivery_service_id=None,
 	                                             delivery_service_regex_id=None):
 		"""
@@ -942,7 +942,7 @@ class TOSession(RestApiSession):
 	#
 	# Delivery Service Statistics
 	#
-	@api_request('get', 'deliveryservice_stats', ('2.0',))
+	@api_request('get', 'deliveryservice_stats', ('3.0',))
 	def get_delivery_service_stats(self, query_params=None):
 		"""
 		Retrieves statistics on the delivery services.
@@ -956,7 +956,7 @@ class TOSession(RestApiSession):
 	#
 	# Divisions
 	#
-	@api_request('get', 'divisions', ('2.0',))
+	@api_request('get', 'divisions', ('3.0',))
 	def get_divisions(self, query_params=None):
 		"""
 		Get all divisions.
@@ -965,7 +965,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'divisions/{division_id:d}', ('2.0',))
+	@api_request('put', 'divisions/{division_id:d}', ('3.0',))
 	def update_division(self, division_id=None, query_params=None):
 		"""
 		Update a division by division id
@@ -978,7 +978,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'divisions', ('2.0',))
+	@api_request('post', 'divisions', ('3.0',))
 	def create_division(self, data=None):
 		"""
 		Create a division
@@ -989,7 +989,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'divisions/{division_id:d}', ('2.0',))
+	@api_request('delete', 'divisions/{division_id:d}', ('3.0',))
 	def delete_division(self, division_id=None, query_params=None):
 		"""
 		Delete a division by division id
@@ -1005,7 +1005,7 @@ class TOSession(RestApiSession):
 	#
 	# Federation
 	#
-	@api_request('get', 'federations', ('2.0',))
+	@api_request('get', 'federations', ('3.0',))
 	def get_federations(self):
 		"""
 		Retrieves a list of federation mappings (aka federation resolvers) for a the current user
@@ -1015,7 +1015,7 @@ class TOSession(RestApiSession):
 		"""
 
 
-	@api_request('post', 'federations', ('2.0',))
+	@api_request('post', 'federations', ('3.0',))
 	def create_federation(self, data=None):
 		"""
 		Allows a user to add federations for their delivery service(s).
@@ -1027,7 +1027,7 @@ class TOSession(RestApiSession):
 		"""
 
 
-	@api_request('get', 'cdns/{cdn_name:s}/federations', ('2.0',))
+	@api_request('get', 'cdns/{cdn_name:s}/federations', ('3.0',))
 	def get_federations_for_cdn(self, cdn_name=None, query_params=None):
 		"""
 		Retrieves a list of federations for a cdn.
@@ -1041,7 +1041,7 @@ class TOSession(RestApiSession):
 		"""
 
 
-	@api_request('post', 'cdns/{cdn_name:s}/federations', ('2.0',))
+	@api_request('post', 'cdns/{cdn_name:s}/federations', ('3.0',))
 	def create_federation_in_cdn(self, cdn_name=None, data=None):
 		"""
 		Create a federation.
@@ -1052,7 +1052,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'cdns/{cdn_name:s}/federations/{federation_id:d}', ('2.0',))
+	@api_request('put', 'cdns/{cdn_name:s}/federations/{federation_id:d}', ('3.0',))
 	def update_federation_in_cdn(self, cdn_name=None, federation_id=None, query_params=None):
 		"""
 		Update a federation.
@@ -1065,7 +1065,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'cdns/{cdn_name:s}/federations/{federation_id:d}', ('2.0',))
+	@api_request('delete', 'cdns/{cdn_name:s}/federations/{federation_id:d}', ('3.0',))
 	def delete_federation_in_cdn(self, cdn_name=None, federation_id=None):
 		"""
 		Delete a federation.
@@ -1081,7 +1081,7 @@ class TOSession(RestApiSession):
 	#
 	# Federation Delivery Service
 	#
-	@api_request('get', 'federations/{federation_id:d}/deliveryservices', ('2.0',))
+	@api_request('get', 'federations/{federation_id:d}/deliveryservices', ('3.0',))
 	def get_federation_delivery_services(self, federation_id=None):
 		"""
 		Retrieves delivery services assigned to a federation
@@ -1095,7 +1095,7 @@ class TOSession(RestApiSession):
 		"""
 
 
-	@api_request('post', 'federations/{federation_id:d}/deliveryservices', ('2.0',))
+	@api_request('post', 'federations/{federation_id:d}/deliveryservices', ('3.0',))
 	def assign_delivery_services_to_federations(self, federation_id=None, data=None):
 		"""
 		Create one or more federation / delivery service assignments.
@@ -1111,7 +1111,7 @@ class TOSession(RestApiSession):
 	#
 	# Federation Federation Resolver
 	#
-	@api_request('get', 'federations/{federation_id:d}/federation_resolvers', ('2.0',))
+	@api_request('get', 'federations/{federation_id:d}/federation_resolvers', ('3.0',))
 	def get_federation_resolvers_by_id(self, federation_id=None):
 		"""
 		:ref:`to-api-federations-id-federation_resolvers`
@@ -1123,7 +1123,7 @@ class TOSession(RestApiSession):
 		"""
 
 
-	@api_request('post', 'federations/{federation_id:d}/federation_resolvers', ('2.0',))
+	@api_request('post', 'federations/{federation_id:d}/federation_resolvers', ('3.0',))
 	def assign_federation_resolver_to_federations(self, federation_id=None, data=None):
 		"""
 		Create one or more federation / federation resolver assignments.
@@ -1139,7 +1139,7 @@ class TOSession(RestApiSession):
 	#
 	# Federation Resolver
 	#
-	@api_request('get', 'federation_resolvers', ('2.0',))
+	@api_request('get', 'federation_resolvers', ('3.0',))
 	def get_federation_resolvers(self, query_params=None):
 		"""
 		Get federation resolvers.
@@ -1149,7 +1149,7 @@ class TOSession(RestApiSession):
 		"""
 
 
-	@api_request('post', 'federation_resolvers', ('2.0',))
+	@api_request('post', 'federation_resolvers', ('3.0',))
 	def create_federation_resolver(self, data=None):
 		"""
 		Create a federation resolver.
@@ -1159,7 +1159,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'federation_resolvers/{federation_resolver_id:d}', ('2.0',))
+	@api_request('delete', 'federation_resolvers/{federation_resolver_id:d}', ('3.0',))
 	def delete_federation_resolver(self, federation_resolver_id=None):
 		"""
 		Delete a federation resolver.
@@ -1172,7 +1172,7 @@ class TOSession(RestApiSession):
 	#
 	# Federation User
 	#
-	@api_request('get', 'federations/{federation_id:d}/users', ('2.0',))
+	@api_request('get', 'federations/{federation_id:d}/users', ('3.0',))
 	def get_federation_users(self, federation_id=None):
 		"""
 		Retrieves users assigned to a federation.
@@ -1181,7 +1181,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'federations/{federation_id:d}/users', ('2.0',))
+	@api_request('post', 'federations/{federation_id:d}/users', ('3.0',))
 	def create_federation_user(self, federation_id=None, data=None):
 		"""
 		Create one or more federation / user assignments.
@@ -1194,7 +1194,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'federations/{federation_id:d}/users/{user_id:d}', ('2.0',))
+	@api_request('delete', 'federations/{federation_id:d}/users/{user_id:d}', ('3.0',))
 	def delete_federation_user(self, federation_id=None, user_id=None):
 		"""
 		Delete one or more federation / user assignments.
@@ -1210,7 +1210,7 @@ class TOSession(RestApiSession):
 	#
 	# ISO
 	#
-	@api_request('get', 'osversions', ('2.0',))
+	@api_request('get', 'osversions', ('3.0',))
 	def get_osversions(self):
 		"""
 		Get all OS versions for ISO generation and the directory where the kickstarter files are
@@ -1222,7 +1222,7 @@ class TOSession(RestApiSession):
 		"""
 
 	#TODO: this currently doesn't work, as /isos wasn't rewritten yet
-	@api_request('post', 'isos', ('2.0',))
+	@api_request('post', 'isos', ('3.0',))
 	def generate_iso(self, data=None):
 		"""
 		Generate an ISO
@@ -1236,7 +1236,7 @@ class TOSession(RestApiSession):
 	#
 	# Jobs
 	#
-	@api_request('get', 'jobs', ('2.0',))
+	@api_request('get', 'jobs', ('3.0',))
 	def get_jobs(self, query_params=None):
 		"""
 		Get all content-invalidation jobs (tenancy permitting).
@@ -1245,7 +1245,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'jobs', ('2.0',))
+	@api_request('post', 'jobs', ('3.0',))
 	def create_job(self, data=None):
 		"""
 		Creates a new content-invalidation job sorted by start time.
@@ -1256,7 +1256,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'jobs', ('2.0',))
+	@api_request('get', 'jobs', ('3.0',))
 	def update_job(self, data=None, query_params=None):
 		"""
 		Replaces a content-invalidation job with the one passed.
@@ -1268,7 +1268,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'jobs', ('2.0',))
+	@api_request('delete', 'jobs', ('3.0',))
 	def delete_job(self, query_params=None):
 		"""
 		Deletes a content-invalidation job.
@@ -1281,7 +1281,7 @@ class TOSession(RestApiSession):
 	#
 	# Parameter
 	#
-	@api_request('get', 'parameters', ('2.0',))
+	@api_request('get', 'parameters', ('3.0',))
 	def get_parameters(self, query_params=None):
 		"""
 		Get all Parameters.
@@ -1290,7 +1290,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'profiles/{id:d}/parameters', ('2.0',))
+	@api_request('get', 'profiles/{id:d}/parameters', ('3.0',))
 	def get_parameters_by_profile_id(self, profile_id=None):
 		"""
 		Get all Parameters associated with a Profile by Id.
@@ -1301,7 +1301,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'profiles/name/{profile_name}/parameters', ('2.0',))
+	@api_request('get', 'profiles/name/{profile_name}/parameters', ('3.0',))
 	def get_parameters_by_profile_name(self, profile_name=None):
 		"""
 		Get all Parameters associated with a Profile by Name.
@@ -1312,7 +1312,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'parameters', ('2.0',))
+	@api_request('post', 'parameters', ('3.0',))
 	def create_parameter(self, data=None):
 		"""
 		Create Parameter
@@ -1323,7 +1323,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'parameters/{parameter_id:d}', ('2.0',))
+	@api_request('put', 'parameters/{parameter_id:d}', ('3.0',))
 	def update_parameter(self, parameter_id=None, query_params=None):
 		"""
 		Update Parameter
@@ -1335,7 +1335,7 @@ class TOSession(RestApiSession):
 		"""
 
 
-	@api_request('delete', 'parameters/{parameter_id:d}', ('2.0',))
+	@api_request('delete', 'parameters/{parameter_id:d}', ('3.0',))
 	def delete_parameter(self, parameter_id=None):
 		"""
 		Delete Parameter
@@ -1349,7 +1349,7 @@ class TOSession(RestApiSession):
 	#
 	# Physical Location
 	#
-	@api_request('get', 'phys_locations', ('2.0',))
+	@api_request('get', 'phys_locations', ('3.0',))
 	def get_physical_locations(self, query_params=None):
 		"""
 		Get Physical Locations.
@@ -1358,7 +1358,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'phys_locations/{physical_location_id:d}', ('2.0',))
+	@api_request('put', 'phys_locations/{physical_location_id:d}', ('3.0',))
 	def update_physical_location(self, physical_location_id=None, query_params=None):
 		"""
 		Update Physical Location by id
@@ -1369,7 +1369,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'phys_locations/{physical_location_id:d}', ('2.0',))
+	@api_request('delete', 'phys_locations/{physical_location_id:d}', ('3.0',))
 	def delete_physical_location(self, physical_location_id=None, query_params=None):
 		"""
 		Delete Physical Location by id
@@ -1383,7 +1383,7 @@ class TOSession(RestApiSession):
 	#
 	# Plugins
 	#
-	@api_request('get', 'plugins', ('2.0',))
+	@api_request('get', 'plugins', ('3.0',))
 	def get_plugins(self):
 		"""
 		Retrieves the list of plugins.
@@ -1395,7 +1395,7 @@ class TOSession(RestApiSession):
 	#
 	# Profiles
 	#
-	@api_request('get', 'profiles', ('2.0',))
+	@api_request('get', 'profiles', ('3.0',))
 	def get_profiles(self, query_params=None):
 		"""
 		Get Profiles.
@@ -1404,7 +1404,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'profiles', ('2.0',))
+	@api_request('post', 'profiles', ('3.0',))
 	def create_profile(self, data=None):
 		"""
 		Create a profile
@@ -1415,7 +1415,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'profiles/name/{new_profile_name:s}/copy/{copy_profile_name:s}', ('2.0',))
+	@api_request('post', 'profiles/name/{new_profile_name:s}/copy/{copy_profile_name:s}', ('3.0',))
 	def copy_profile(self, new_profile_name=None, copy_profile_name=None, data=None):
 		"""
 		Copy profile to a new profile. The new profile name must not exist
@@ -1430,7 +1430,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'profiles/{profile_id:d}', ('2.0',))
+	@api_request('put', 'profiles/{profile_id:d}', ('3.0',))
 	def update_profile_by_id(self, profile_id=None, data=None):
 		"""
 		Update Profile by Id.
@@ -1443,7 +1443,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'profiles/{profile_id:d}', ('2.0',))
+	@api_request('delete', 'profiles/{profile_id:d}', ('3.0',))
 	def delete_profile_by_id(self, profile_id=None):
 		"""
 		Delete Profile by Id.
@@ -1457,7 +1457,7 @@ class TOSession(RestApiSession):
 	#
 	# Profile Parameters
 	#
-	@api_request('post', 'profileparameters', ('2.0',))
+	@api_request('post', 'profileparameters', ('3.0',))
 	def associate_paramater_to_profile(self, data=None):
 		"""
 		Associate parameter to profile.
@@ -1467,7 +1467,7 @@ class TOSession(RestApiSession):
 		"""
 
 
-	@api_request('post', 'profiles/{profile_id:d}/parameters', ('2.0',))
+	@api_request('post', 'profiles/{profile_id:d}/parameters', ('3.0',))
 	def associate_parameters_by_profile_id(self, profile_id=None, data=None):
 		"""
 		Associate Parameters to a Profile by Id.
@@ -1480,7 +1480,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'profileparameter', ('2.0',))
+	@api_request('post', 'profileparameter', ('3.0',))
 	def assign_profile_to_parameter_ids(self, data=None):
 		"""
 		Create one or more profile / parameter assignments.
@@ -1491,7 +1491,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'parameterprofile', ('2.0',))
+	@api_request('post', 'parameterprofile', ('3.0',))
 	def assign_parameter_to_profile_ids(self, data=None):
 		"""
 		Create one or more parameter / profile assignments.
@@ -1503,7 +1503,7 @@ class TOSession(RestApiSession):
 		"""
 
 
-	@api_request('post', 'profiles/name/{profile_name}/parameters', ('2.0',))
+	@api_request('post', 'profiles/name/{profile_name}/parameters', ('3.0',))
 	def associate_parameters_by_profile_name(self, profile_name=None, data=None):
 		"""
 		Associate Parameters to a Profile by Name.
@@ -1516,7 +1516,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'profileparameters/{profile_id:d}/{parameter_id:d}', ('2.0',))
+	@api_request('delete', 'profileparameters/{profile_id:d}/{parameter_id:d}', ('3.0',))
 	def delete_profile_parameter_association_by_id(self, profile_id=None, parameter_id=None):
 		"""
 		Delete Parameter association by Id for a Profile by Id.
@@ -1532,7 +1532,7 @@ class TOSession(RestApiSession):
 	#
 	# Regions
 	#
-	@api_request('get', 'regions', ('2.0',))
+	@api_request('get', 'regions', ('3.0',))
 	def get_regions(self, query_params=None):
 		"""
 		Get Regions.
@@ -1543,7 +1543,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'regions', ('2.0',))
+	@api_request('post', 'regions', ('3.0',))
 	def create_region(self, query_params=None, data=None):
 		"""
 		Create a region
@@ -1554,7 +1554,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'regions', ('2.0',))
+	@api_request('delete', 'regions', ('3.0',))
 	def delete_region(self, query_params=None):
 		"""
 		Delete a region by name or ID as a query parameter
@@ -1565,7 +1565,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'regions/{region_id:d}', ('2.0',))
+	@api_request('put', 'regions/{region_id:d}', ('3.0',))
 	def update_region(self, region_id=None):
 		"""
 		Update a region
@@ -1579,7 +1579,7 @@ class TOSession(RestApiSession):
 	#
 	# Roles
 	#
-	@api_request('get', 'roles', ('2.0',))
+	@api_request('get', 'roles', ('3.0',))
 	def get_roles(self):
 		"""
 		Get Roles.
@@ -1588,7 +1588,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'roles', ('2.0',))
+	@api_request('post', 'roles', ('3.0',))
 	def create_role(self, data=None):
 		"""
 		Create a new Role.
@@ -1599,7 +1599,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'roles', ('2.0',))
+	@api_request('put', 'roles', ('3.0',))
 	def update_role(self, data=None, query_params=None):
 		"""
 		Get Roles.
@@ -1611,7 +1611,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'roles', ('2.0',))
+	@api_request('delete', 'roles', ('3.0',))
 	def delete_role(self, query_params=None):
 		"""
 		Delete a Role.
@@ -1623,7 +1623,7 @@ class TOSession(RestApiSession):
 	#
 	# Server
 	#
-	@api_request('get', 'servers', ('2.0',))
+	@api_request('get', 'servers', ('3.0',))
 	def get_servers(self, query_params=None):
 		"""
 		Get Servers.
@@ -1634,7 +1634,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'servers/{server_id:d}/deliveryservices', ('2.0',))
+	@api_request('get', 'servers/{server_id:d}/deliveryservices', ('3.0',))
 	def get_server_delivery_services(self, server_id=None):
 		"""
 		Retrieves all delivery services assigned to the server
@@ -1645,7 +1645,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'servercheck', ('2.0',))
+	@api_request('post', 'servercheck', ('3.0',))
 	def create_servercheck(self, data=None):
 		"""
 		Post a server check result to the serverchecks table.
@@ -1656,7 +1656,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'servers', ('2.0',))
+	@api_request('post', 'servers', ('3.0',))
 	def create_server(self, data=None):
 		"""
 		Create a new Server.
@@ -1667,7 +1667,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'servers/{server_id:d}', ('2.0',))
+	@api_request('put', 'servers/{server_id:d}', ('3.0',))
 	def update_server_by_id(self, server_id=None, data=None):
 		"""
 		Update a Server by Id.
@@ -1679,7 +1679,7 @@ class TOSession(RestApiSession):
 		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], requests.Response]
 		:raises: Union[LoginError, OperationError]
 		"""
-	@api_request('put', 'servers/{server_id:d}/status', ('2.0',))
+	@api_request('put', 'servers/{server_id:d}/status', ('3.0',))
 	def update_server_status_by_id(self, server_id=None, data=None):
 		"""
 		Update server_status by Id.
@@ -1691,7 +1691,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'servers/{server_id:d}', ('2.0',))
+	@api_request('delete', 'servers/{server_id:d}', ('3.0',))
 	def delete_server_by_id(self, server_id=None):
 		"""
 		Delete a Server by Id.
@@ -1702,7 +1702,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'servers/{server_id:d}/queue_update', ('2.0',))
+	@api_request('post', 'servers/{server_id:d}/queue_update', ('3.0',))
 	def servers_queue_update(self, server_id=None, data=None):
 		"""
 		Queue Updates by Server Id.
@@ -1715,7 +1715,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'servers/{server_name}/update_status', ('2.0',))
+	@api_request('get', 'servers/{server_name}/update_status', ('3.0',))
 	def get_server_update_status(self, server_name=None):
 		"""
 		Gets the current update status of a server named ``server_name``.
@@ -1728,7 +1728,7 @@ class TOSession(RestApiSession):
 	#
 	# Static DNS Entries
 	#
-	@api_request('get', 'staticdnsentries', ('2.0',))
+	@api_request('get', 'staticdnsentries', ('3.0',))
 	def get_staticdnsentries(self, query_params=None):
 		"""
 		Get static DNS entries associated with the delivery service
@@ -1739,7 +1739,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'staticdnsentries', ('2.0',))
+	@api_request('post', 'staticdnsentries', ('3.0',))
 	def create_staticdnsentries(self, data=None):
 		"""
 		Create static DNS entries associated with the delivery service
@@ -1750,7 +1750,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'staticdnsentries', ('2.0',))
+	@api_request('put', 'staticdnsentries', ('3.0',))
 	def update_staticdnsentries(self, data=None, query_params=None):
 		"""
 		Update static DNS entries associated with the delivery service
@@ -1763,7 +1763,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'staticdnsentries', ('2.0',))
+	@api_request('delete', 'staticdnsentries', ('3.0',))
 	def delete_staticdnsentries(self, query_params=None):
 		"""
 		Delete static DNS entries associated with the delivery service
@@ -1777,7 +1777,7 @@ class TOSession(RestApiSession):
 	#
 	# Status
 	#
-	@api_request('get', 'statuses', ('2.0',))
+	@api_request('get', 'statuses', ('3.0',))
 	def get_statuses(self, query_params=None):
 		"""
 		Retrieves a list of the server status codes available.
@@ -1789,7 +1789,7 @@ class TOSession(RestApiSession):
 	#
 	# System
 	#
-	@api_request('get', 'system/info', ('2.0',))
+	@api_request('get', 'system/info', ('3.0',))
 	def get_system_info(self):
 		"""
 		Get information on the traffic ops system.
@@ -1802,7 +1802,7 @@ class TOSession(RestApiSession):
 	#
 	# Tenants
 	#
-	@api_request('get', 'tenants', ('2.0',))
+	@api_request('get', 'tenants', ('3.0',))
 	def get_tenants(self, query_params=None):
 		"""
 		Get all tenants.
@@ -1811,7 +1811,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'tenants/{tenant_id:d}', ('2.0',))
+	@api_request('put', 'tenants/{tenant_id:d}', ('3.0',))
 	def update_tenant(self, tenant_id=None):
 		"""
 		Update a tenant
@@ -1822,7 +1822,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'tenants', ('2.0',))
+	@api_request('post', 'tenants', ('3.0',))
 	def create_tenant(self, data=None):
 		"""
 		Create a tenant
@@ -1837,7 +1837,7 @@ class TOSession(RestApiSession):
 	#
 	# TO Extensions
 	#
-	@api_request('get', 'servercheck/extensions', ('2.0',))
+	@api_request('get', 'servercheck/extensions', ('3.0',))
 	def get_servercheck_extensions(self):
 		"""
 		Retrieves the list of extensions.
@@ -1846,7 +1846,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'servercheck/extensions', ('2.0',))
+	@api_request('post', 'servercheck/extensions', ('3.0',))
 	def create_to_extension(self, data=None):
 		"""
 		Creates a Traffic Ops extension.
@@ -1857,7 +1857,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'servercheck/extensions/{extension_id:d}', ('2.0',))
+	@api_request('delete', 'servercheck/extensions/{extension_id:d}', ('3.0',))
 	def delete_to_extension(self, extension_id=None):
 		"""
 		Deletes a Traffic Ops extension.
@@ -1874,6 +1874,7 @@ class TOSession(RestApiSession):
 	@api_request('post', 'topologies', ('3.0',))
 	def create_topology(self, data: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]:
 		"""
+		Create a topology
 		:ref:`to-api-topologies`
 		:param data: The Topology data to use for Topology creation.
 		:type data: Dict[str, Any]
@@ -1882,45 +1883,46 @@ class TOSession(RestApiSession):
 		"""
 
 	@api_request('get', 'topologies', ('3.0',))
-	def create_topology(self, data: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]:
+	def get_topologies(self, query_params: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]:
 		"""
-		:ref:`to-api-topologies`
 		Get Topologies.
 		:ref:`to-api-topologies`
+		:param query_params: The optional url query parameters for the call
+		:type query_params: Dict[str, Any]
 		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'topologies', ('3.0',))
-	def update_topologies(self, data: Dict[str, Any]=None, query_params: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]:
+	@api_request('put', 'topologies?name={name:s}', ('3.0',))
+	def update_topology(self, name: str=None, data: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]:
 		"""
 		Update a Topology
 		:ref:`to-api-topologies`
+		:param name: The name of the Topology
+		:type name: str
 		:param data: The new values for the Topology
 		:type data: Dict[str, Any]
-		:type query: Dict[str, Any]
-		:param query_params: 'id' is a required parameter, defining the Topology to be replaced.
 		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'topologies', ('3.0',))
-	def delete_topology(self, query_params: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]:
+	@api_request('delete', 'topologies?name={name:s}', ('3.0',))
+	def delete_topology(self, name: str=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]:
 		"""
 		Delete a Topology
 		:ref:`to-api-topologies`
-		:param topology_id: The ID of the Topology to delete
-		:type topology_id: int
+		:param name: The name of the Topology to delete
+		:type name: str
 		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'topologies/{topology_name:s}/queue_update', ('3.0',))
-	def topologies_queue_update(self, topology_name=None, data=None):
+	@api_request('post', 'topologies/{name:s}/queue_update', ('3.0',))
+	def topologies_queue_update(self, name=None, data=None):
 		"""
 		Queue Updates by Topology name.
 		:ref:`to-api-topologies-name-queue_update`
-		:param topology_name: The Topology name
+		:param name: The Topology name
 		:param data: The update action.
 		:type data: Dict[str, Any]
 		:rtype: Tuple[Dict[str, Any], requests.Response]
@@ -1931,7 +1933,7 @@ class TOSession(RestApiSession):
 	#
 	# Types
 	#
-	@api_request('get', 'types', ('2.0',))
+	@api_request('get', 'types', ('3.0',))
 	def get_types(self, query_params=None):
 		"""
 		Get Data Types.
@@ -1943,7 +1945,7 @@ class TOSession(RestApiSession):
 	#
 	# Users
 	#
-	@api_request('get', 'users', ('2.0',))
+	@api_request('get', 'users', ('3.0',))
 	def get_users(self):
 		"""
 		Retrieves all users.
@@ -1952,7 +1954,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'users/{user_id:d}', ('2.0',))
+	@api_request('get', 'users/{user_id:d}', ('3.0',))
 	def get_user_by_id(self, user_id=None):
 		"""
 		Retrieves user by ID.
@@ -1963,7 +1965,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'users', ('2.0',))
+	@api_request('post', 'users', ('3.0',))
 	def create_user(self, data=None):
 		"""
 		Create a user.
@@ -1974,7 +1976,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'users/{user_id:d}', ('2.0',))
+	@api_request('put', 'users/{user_id:d}', ('3.0',))
 	def update_user_by_id(self, user_id=None, data=None):
 		"""
 		Update a user.
@@ -1985,7 +1987,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'users/register', ('2.0',))
+	@api_request('post', 'users/register', ('3.0',))
 	def create_user_with_registration(self, data=None):
 		"""
 		Register a user and send registration email
@@ -1996,7 +1998,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'user/current', ('2.0',))
+	@api_request('get', 'user/current', ('3.0',))
 	def get_authenticated_user(self):
 		"""
 		Retrieves the profile for the authenticated user.
@@ -2005,7 +2007,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'user/current', ('2.0',))
+	@api_request('put', 'user/current', ('3.0',))
 	def replace_authenticated_user(self, data=None):
 		"""
 		Updates the currently authenticated user.
@@ -2019,7 +2021,7 @@ class TOSession(RestApiSession):
 	#
 	# Snapshot CRConfig
 	#
-	@api_request('get', 'cdns/{cdn_name}/snapshot', ('2.0',))
+	@api_request('get', 'cdns/{cdn_name}/snapshot', ('3.0',))
 	def get_current_snapshot_crconfig(self, cdn_name=None):
 		"""
 		Retrieves the CURRENT snapshot for a CDN which doesn't necessarily represent the current
@@ -2032,7 +2034,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('get', 'cdns/{cdn_name}/snapshot/new', ('2.0',))
+	@api_request('get', 'cdns/{cdn_name}/snapshot/new', ('3.0',))
 	def get_pending_snapshot_crconfig(self, cdn_name=None):
 		"""
 		Retrieves a PENDING snapshot for a CDN which represents the current state of the CDN. The
@@ -2046,7 +2048,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'snapshot', ('2.0',))
+	@api_request('put', 'snapshot', ('3.0',))
 	def snapshot_crconfig(self, query_params=None):
 		"""
 		Snapshot CRConfig by CDN Name or ID.
@@ -2060,7 +2062,7 @@ class TOSession(RestApiSession):
 	#
 	# Coordinate
 	#
-	@api_request('get', 'coordinates', ('2.0',))
+	@api_request('get', 'coordinates', ('3.0',))
 	def get_coordinates(self, query_params=None):
 		"""
 		Get all coordinates associated with the cdn
@@ -2071,7 +2073,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'coordinates', ('2.0',))
+	@api_request('post', 'coordinates', ('3.0',))
 	def create_coordinates(self, data=None):
 		"""
 		Create coordinates
@@ -2082,7 +2084,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'coordinates', ('2.0',))
+	@api_request('put', 'coordinates', ('3.0',))
 	def update_coordinates(self, query_params=None, data=None):
 		"""
 		Update coordinates
@@ -2095,7 +2097,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'coordinates', ('2.0',))
+	@api_request('delete', 'coordinates', ('3.0',))
 	def delete_coordinates(self, query_params=None):
 		"""
 		Delete coordinates
@@ -2109,7 +2111,7 @@ class TOSession(RestApiSession):
 	#
 	# Origin
 	#
-	@api_request('get', 'origins', ('2.0',))
+	@api_request('get', 'origins', ('3.0',))
 	def get_origins(self, query_params=None):
 		"""
 		Get origins associated with the delivery service
@@ -2120,7 +2122,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('post', 'origins', ('2.0',))
+	@api_request('post', 'origins', ('3.0',))
 	def create_origins(self, data=None):
 		"""
 		Creates origins associated with a delivery service
@@ -2131,7 +2133,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('put', 'origins', ('2.0',))
+	@api_request('put', 'origins', ('3.0',))
 	def update_origins(self, query_params=None):
 		"""
 		Updates origins associated with a delivery service
@@ -2144,7 +2146,7 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
-	@api_request('delete', 'origins', ('2.0',))
+	@api_request('delete', 'origins', ('3.0',))
 	def delete_origins(self, query_params=None):
 		"""
 		Updates origins associated with a delivery service
diff --git a/traffic_monitor/datareq/cachestate.go b/traffic_monitor/datareq/cachestate.go
index 9ce1dbb19..4597e2e94 100644
--- a/traffic_monitor/datareq/cachestate.go
+++ b/traffic_monitor/datareq/cachestate.go
@@ -177,9 +177,7 @@ func createCacheStatuses(
 				infStatus.Status, infStatus.Available = interfaceStatus(inf, health[0])
 				if infVit, ok := health[0].InterfaceVitals[inf.Name]; ok {
 					infStatus.BandwidthKbps = float64(infVit.KbpsOut)
-					if inf.Monitor {
-						totalKbps += infStatus.BandwidthKbps
-					}
+					totalKbps += infStatus.BandwidthKbps
 				} else {
 					log.Infof("Cache server '%s' interface '%s' not in last health measurement.", cacheName, inf.Name)
 				}
diff --git a/traffic_ops/build/traffic_ops.spec b/traffic_ops/build/traffic_ops.spec
index 9e9e2d9db..b788b6dc3 100644
--- a/traffic_ops/build/traffic_ops.spec
+++ b/traffic_ops/build/traffic_ops.spec
@@ -38,6 +38,7 @@ Requires:         openssl-devel, perl, perl-core, perl-DBD-Pg, perl-DBI, perl-Di
 Requires:         libidn-devel, libcurl-devel, libcap
 Requires:         postgresql96 >= 9.6.2 , postgresql96-devel >= 9.6.2
 Requires:         perl-JSON, perl-libwww-perl, perl-Test-CPAN-Meta, perl-WWW-Curl, perl-TermReadKey, perl-Crypt-ScryptKDF
+Requires:         python(abi)
 Requires(pre):    /usr/sbin/useradd, /usr/bin/getent
 Requires(postun): /usr/sbin/userdel
 
diff --git a/traffic_ops/install/bin/postinstall.py b/traffic_ops/install/bin/postinstall.py
index 289d6fc91..ef13458b3 100755
--- a/traffic_ops/install/bin/postinstall.py
+++ b/traffic_ops/install/bin/postinstall.py
@@ -1,8 +1,24 @@
-#!/usr/bin/env python3
+#!/usr/bin/env bash
+"exec" "bash" "-c" "PATH+=:/usr/libexec/; exec \$(type -p python38 python3.8 python36 python3.8 python3 python python27 python2.7 python2 platform-python | head -n1) \"$0\" $*"
+#
+# 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.
+#
 # There's a bug in asteroid with Python 3.9's NamedTuple being
 # recognized for the dynamically generated class that it is. Should be fixed
 # with the next release, but 'til then...
 #pylint:disable=inherit-non-class
+from __future__ import print_function
+
 """
 This script is meant as a drop-in replacement for the old _postinstall Perl script.
 
@@ -30,28 +46,16 @@ testing.
 >>> [c for c in [[a for a in b if not a.config_var] for b in DEFAULTS.values()] if c]
 []
 """
-
-#
-# 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 argparse
 import base64
+import errno
 import getpass
+import grp
 import hashlib
 import json
 import logging
 import os
+import pwd
 import random
 import re
 import shutil
@@ -59,7 +63,9 @@ import stat
 import string
 import subprocess
 import sys
-from typing import Dict, NamedTuple, List, Optional
+
+from collections import namedtuple
+from struct import unpack, pack
 
 # Paths for output configuration files
 DATABASE_CONF_FILE = "/opt/traffic_ops/app/conf/production/database.conf"
@@ -87,7 +93,14 @@ POST_INSTALL_CFG = "/opt/traffic_ops/install/data/json/post_install.json"
 # Python, instead, outputs to stdout. This is breaking, but more flexible. Change it?
 # OUTPUT_CONFIG_FILE = "/opt/traffic_ops/install/bin/configuration_file.json"
 
-class Question:
+if sys.version_info.major >= 3:
+	# Accepting a string for json.dump()'s `indent` keyword argument is a Python 3 feature
+	indent = "\t"  # type: str
+else:
+	indent = 4 #  type: int
+	str = unicode  # type: type[unicode]
+
+class Question(object):
 	"""
 	Question represents a single question to be asked of the user, to determine a configuration
 	value.
@@ -96,25 +109,25 @@ class Question:
 	Question(question='question', default='answer', config_var='var', hidden=False)
 	"""
 
-	def __init__(self, question: str, default: str, config_var: str, hidden: bool = False):
+	def __init__(self, question, default, config_var, hidden = False): # type: (str, str, str, bool) -> None
 		self.question = question
 		self.default = default
 		self.config_var = config_var
 		self.hidden = hidden
 
-	def __str__(self) -> str:
+	def __str__(self): # type: () -> str
 		if self.default:
-			return f"{self.question} [{self.default}]: "
-		return f"{self.question}: "
+			return "{question} [{default}]: ".format(question=self.question, default=self.default)
+		return "{question}: ".format(question=self.question)
 
-	def __repr__(self) -> str:
+	def __repr__(self): # type: () -> str
 		qstn = self.question
 		ans = self.default
 		cfgvr = self.config_var
 		hddn = self.hidden
-		return f"Question(question='{qstn}', default='{ans}', config_var='{cfgvr}', hidden={hddn})"
+		return "Question(question='{qstn}', default='{ans}', config_var='{cfgvr}', hidden={hddn})".format(qstn=qstn, ans=ans, cfgvr=cfgvr, hddn=hddn)
 
-	def ask(self) -> str:
+	def ask(self): # type: () -> str
 		"""
 		Asks the user the Question interactively.
 
@@ -125,13 +138,13 @@ class Question:
 				passwd = getpass.getpass(str(self))
 				if not passwd:
 					continue
-				if passwd == getpass.getpass(f"Re-Enter {self.question}: "):
+				if passwd == getpass.getpass("Re-Enter {question}: ".format(question=self.question)):
 					return passwd
 				print("Error: passwords do not match, try again")
 		ipt = input(self)
 		return ipt if ipt else self.default
 
-	def to_json(self) -> str:
+	def to_json(self): # type: () -> str
 		"""
 		Converts a question to JSON encoding.
 
@@ -144,39 +157,36 @@ class Question:
 		ans = self.default
 		cfgvr = self.config_var
 		if self.hidden:
-			return f'{{"{qstn}": "{ans}", "config_var": "{cfgvr}", "hidden": true}}'
-		return f'{{"{qstn}": "{ans}", "config_var": "{cfgvr}"}}'
+			return '{{"{qstn}": "{ans}", "config_var": "{cfgvr}", "hidden": true}}'.format(qstn=qstn, ans=ans, cfgvr=cfgvr)
+		return '{{"{qstn}": "{ans}", "config_var": "{cfgvr}"}}'.format(qstn=qstn, ans=ans, cfgvr=cfgvr)
 
-	def serialize(self) -> object:
+	def serialize(self): # type: () -> object
 		"""Returns a serializable dictionary, suitable for converting to JSON."""
 		return {self.question: self.default, "config_var": self.config_var, "hidden": self.hidden}
 
-class User(NamedTuple):
-	"""Users represents a user that will be inserted into the Traffic Ops database."""
+class User(namedtuple('User', ['username', 'password'])):
+	"""Users represents a user that will be inserted into the Traffic Ops database.
 
-	#: The user's username.
-	username: str
-	#: The user's password - IN PLAINTEXT.
-	password: str
+	Attributes
+	----------
+	self.username: str
+		The user's username.
+	self.password: str
+		The user's password - IN PLAINTEXT.
+	"""
 
 class SSLConfig:
 	"""SSLConfig bundles the options for generating new (self-signed) SSL certificates"""
 
-	def __init__(self, gen_cert: bool, cfg_map: Dict[str, str]):
+	def __init__(self, gen_cert, cfg_map): # type: (bool, dict[str, str]) -> None
 
 		self.gen_cert = gen_cert
 		self.rsa_password = cfg_map["rsaPassword"]
 		self.params = "/C={country}/ST={state}/L={locality}/O={company}/OU={org_unit}/CN={common_name}/"
 		self.params = self.params.format(**cfg_map)
 
-class CDNConfig(NamedTuple):
+class CDNConfig(namedtuple('CDNConfig', ['gen_secret', 'num_secrets', 'port', 'num_workers', 'url', 'ldap_conf_location'])):
 	"""CDNConfig holds all of the options needed to format a cdn.conf file."""
-	gen_secret: bool
-	num_secrets: int
-	port: int
-	num_workers: int
-	url: str
-	ldap_conf_location: str
 
 	def generate_secret(self, conf):
 		"""
@@ -279,7 +289,7 @@ class ConfigEncoder(json.JSONEncoder):
 	"""
 
 	# The linter is just wrong about this
-	def default(self, o) -> object:
+	def default(self, o): # type: (object) -> object
 		"""
 		Returns a serializable representation of 'o'.
 
@@ -292,7 +302,7 @@ class ConfigEncoder(json.JSONEncoder):
 
 		return json.JSONEncoder.default(self, o)
 
-def get_config(questions: List[Question], fname: str, automatic: bool = False) -> Dict[str, str]:
+def get_config(questions, fname, automatic = False): # type: (list[Question], str, bool) -> dict[str, str]
 	"""Asks all provided questions, or uses their defaults in automatic mode"""
 
 	logging.info("===========%s===========", fname)
@@ -306,7 +316,7 @@ def get_config(questions: List[Question], fname: str, automatic: bool = False) -
 
 	return config
 
-def generate_db_conf(qstns: List[Question], fname: str, automatic: bool, root: str) -> dict:
+def generate_db_conf(qstns, fname, automatic, root): # (list[Question], str, bool, str) -> dict
 	"""
 	Generates the database.conf file and returns a map of its configuration.
 
@@ -317,18 +327,18 @@ def generate_db_conf(qstns: List[Question], fname: str, automatic: bool, root: s
 	hostname = db_conf.get("hostname", "UNKNOWN")
 	port = db_conf.get("port", "UNKNOWN")
 
-	db_conf["description"] = f"{typ} database on {hostname}:{port}"
+	db_conf["description"] = "{typ} database on {hostname}:{port}".format(typ=typ, hostname=hostname, port=port)
 
 	path = os.path.join(root, fname.lstrip('/'))
 	with open(path, 'w+') as conf_file:
-		json.dump(db_conf, conf_file, indent="\t")
+		json.dump(db_conf, conf_file, indent=indent)
 		print(file=conf_file)
 
 	logging.info("Database configuration has been saved")
 
 	return db_conf
 
-def generate_todb_conf(qstns: list, fname: str, auto: bool, root: str, conf: dict) -> dict:
+def generate_todb_conf(qstns, fname, auto, root, conf): # (list, str, bool, str, dict) -> dict
 	"""
 	Generates the dbconf.yml file and returns a map of its configuration.
 
@@ -349,15 +359,15 @@ def generate_todb_conf(qstns: list, fname: str, auto: bool, root: str, conf: dic
 	password = conf.get('password', 'UNKNOWN')
 	dbname = conf.get('dbname', 'UNKNOWN')
 
-	open_line = f"host={hostname} port={port} user={user} password={password} dbname={dbname}"
+	open_line = "host={hostname} port={port} user={user} password={password} dbname={dbname}".format(hostname=hostname, port=port, user=user, password=password, dbname=dbname)
 	with open(path, 'w+') as conf_file:
 		print("production:", file=conf_file)
 		print("    driver:", driver, file=conf_file)
-		print(f"    open: {open_line} sslmode=disable", file=conf_file)
+		print("    open: {open_line} sslmode=disable".format(open_line=open_line), file=conf_file)
 
 	return todbconf
 
-def generate_ldap_conf(questions: List[Question], fname: str, automatic: bool, root: str):
+def generate_ldap_conf(questions, fname, automatic, root): # type: (list[Question], str, bool, str) -> None
 	"""
 	Generates the ldap.conf file by asking the questions or using default answers in auto mode.
 
@@ -369,7 +379,7 @@ def generate_ldap_conf(questions: List[Question], fname: str, automatic: bool, r
 		return
 	use_ldap = use_ldap_question[0].default if automatic else use_ldap_question[0].ask()
 
-	if use_ldap.casefold() not in {'y', 'yes'}:
+	if use_ldap.lower() not in {'y', 'yes'}:
 		logging.info("Not setting up ldap")
 		return
 
@@ -386,35 +396,168 @@ def generate_ldap_conf(questions: List[Question], fname: str, automatic: bool, r
 
 	for key in keys:
 		if key not in ldap_conf:
-			raise ValueError(f"{key} is a required key in {fname}")
+			raise ValueError("{key} is a required key in {fname}".format(key=key, fname=fname))
 
-	if not re.fullmatch(r"\S+:\d+", ldap_conf["host"]):
-		raise ValueError(f"host in {fname} must be of form 'hostname:port'")
+	if not re.match(r"^\S+:\d+$", ldap_conf["host"]):
+		raise ValueError("host in {fname} must be of form 'hostname:port'".format(fname=fname))
 
 	path = os.path.join(root, fname.lstrip('/'))
-	os.makedirs(os.path.dirname(path), exist_ok=True)
+	try:
+		os.makedirs(os.path.dirname(path))
+	except OSError as e:
+		if e.errno == errno.EEXIST:
+			pass
 	with open(path, 'w+') as conf_file:
-		json.dump(ldap_conf, conf_file, indent="\t")
+		json.dump(ldap_conf, conf_file, indent=indent)
 		print(file=conf_file)
 
-def hash_pass(passwd: str) -> str:
+def hash_pass(passwd): # type: (str) -> str
 	"""
 	Generates a Scrypt-based hash of the given password in a Perl-compatible format.
 	It's hard-coded - like the Perl - to use 64 random bytes for the salt, n=16384,
 	r=8, p=1 and dklen=64.
 	"""
-	salt = os.urandom(64)
-	n = 16384
+	n = 2 ** 14
 	r_val = 8
 	p_val = 1
-	hashed = hashlib.scrypt(passwd.encode(), salt=salt, n=n, r=r_val, p=p_val, dklen=64)
-
+	dklen = 64
+	salt = os.urandom(dklen)
+	hashed = Scrypt(password=passwd.encode(), salt=salt, cost_factor=n, block_size_factor=r_val, parallelization_factor=p_val, key_length=dklen).derive()
 	hashed_b64 = base64.standard_b64encode(hashed).decode()
 	salt_b64 = base64.standard_b64encode(salt).decode()
 
-	return f"SCRYPT:{n}:{r_val}:{p_val}:{salt_b64}:{hashed_b64}"
-
-def generate_users_conf(qstns: List[Question], fname: str, auto: bool, root: str) -> User:
+	return "SCRYPT:{n}:{r_val}:{p_val}:{salt_b64}:{hashed_b64}".format(n=n, r_val=r_val, p_val=p_val, salt_b64=salt_b64, hashed_b64=hashed_b64)
+
+
+class Scrypt:
+	def __init__(self, password, salt, cost_factor, block_size_factor, parallelization_factor, key_length):  # type: (bytes, bytes, int, int, int, int) -> None
+		self.password = password  # type: bytes
+		self.salt = salt  # type: bytes
+		self.cost_factor = cost_factor  # type: int
+		self.block_size_factor = block_size_factor  # type: int
+		self.parallelization_factor = parallelization_factor  # type: int
+		self.key_length = key_length
+		self.block_unit = 32 * self.block_size_factor  # 1 block unit = 32 * block_size_factor 32-bit ints
+
+	def derive(self):  # type: () -> bytes
+		salt_length = 2 ** 7 * self.block_size_factor * self.parallelization_factor  # type: int
+		pack_format = '<' + 'L' * int(salt_length / 4)  # `<` means `little-endian` and `L` means `unsigned long`
+		salt = hashlib.pbkdf2_hmac('sha256', password=self.password, salt=self.salt, iterations=1, dklen=salt_length)  # type: bytes
+		block = list(unpack(pack_format, salt))  # type: list[int]
+		block = self.ROMix(block)
+		salt = pack(pack_format, *block)
+		key = hashlib.pbkdf2_hmac('sha256', password=self.password, salt=salt, iterations=1, dklen=self.key_length)  # type: bytes
+		return key
+
+	def ROMix(self, block):  # type: (list[int]) -> list[int]
+		xored_block = [0] * len(block)  # type: list[int]
+		variations = [list()] * self.cost_factor  # type: list[list[int]]
+		variations[0] = block
+		index = 1
+		while index < self.cost_factor:
+			variations[index] = self.block_mix(variations[index - 1])
+			index += 1
+		block = self.block_mix(variations[-1])
+		for unused in variations:
+			variation_index = block[self.block_unit - 16] % self.cost_factor  # type: int
+			variation = variations[variation_index]
+			for index, unused in enumerate(xored_block):
+				xored_block[index] = block[index] ^ variation[index]
+			block = self.block_mix(xored_block)
+		return block
+
+	def block_mix(self, previous_block):  # type: (list[int]) -> list[int]
+		block = previous_block[:]  # type: list[int]
+		X_length = 16  # X is the list of numbers within `block` that we mix
+		copy_index = self.block_unit - X_length
+		X = previous_block[copy_index:copy_index + X_length]  # type: list[int]
+		octet_index = 0  # type: int
+		block_xor_index = 0
+		while octet_index < 2 * self.block_size_factor:
+			for index, unused in enumerate(X):
+				X[index] ^= previous_block[block_xor_index + index]
+			block_xor_index += X_length
+			self.salsa20(X)
+			block_offset = (int(octet_index / 2) + octet_index % 2 * self.block_size_factor) * X_length
+			block[block_offset:block_offset + X_length] = X
+			octet_index += 1
+		return block
+
+	def salsa20(self, block):  # type: (list[int]) -> None
+		X = block[:]  # make a copy (list.copy() is Python 3-only)
+		for i in range(0, 4):
+			# These bit shifting operations could be condensed into a single line of list comprehensions,
+			# but there is a >3x performance benefit from writing it out explicitly.
+			bits = X[0] + X[12] & 0xffffffff
+			X[4] ^= bits << 7 | bits >> 32 - 7
+			bits = X[4] + X[0] & 0xffffffff
+			X[8] ^= bits << 9 | bits >> 32 - 9
+			bits = X[8] + X[4] & 0xffffffff
+			X[12] ^= bits << 13 | bits >> 32 - 13
+			bits = X[12] + X[8] & 0xffffffff
+			X[0] ^= bits << 18 | bits >> 32 - 18
+			bits = X[5] + X[1] & 0xffffffff
+			X[9] ^= bits << 7 | bits >> 32 - 7
+			bits = X[9] + X[5] & 0xffffffff
+			X[13] ^= bits << 9 | bits >> 32 - 9
+			bits = X[13] + X[9] & 0xffffffff
+			X[1] ^= bits << 13 | bits >> 32 - 13
+			bits = X[1] + X[13] & 0xffffffff
+			X[5] ^= bits << 18 | bits >> 32 - 18
+			bits = X[10] + X[6] & 0xffffffff
+			X[14] ^= bits << 7 | bits >> 32 - 7
+			bits = X[14] + X[10] & 0xffffffff
+			X[2] ^= bits << 9 | bits >> 32 - 9
+			bits = X[2] + X[14] & 0xffffffff
+			X[6] ^= bits << 13 | bits >> 32 - 13
+			bits = X[6] + X[2] & 0xffffffff
+			X[10] ^= bits << 18 | bits >> 32 - 18
+			bits = X[15] + X[11] & 0xffffffff
+			X[3] ^= bits << 7 | bits >> 32 - 7
+			bits = X[3] + X[15] & 0xffffffff
+			X[7] ^= bits << 9 | bits >> 32 - 9
+			bits = X[7] + X[3] & 0xffffffff
+			X[11] ^= bits << 13 | bits >> 32 - 13
+			bits = X[11] + X[7] & 0xffffffff
+			X[15] ^= bits << 18 | bits >> 32 - 18
+			bits = X[0] + X[3] & 0xffffffff
+			X[1] ^= bits << 7 | bits >> 32 - 7
+			bits = X[1] + X[0] & 0xffffffff
+			X[2] ^= bits << 9 | bits >> 32 - 9
+			bits = X[2] + X[1] & 0xffffffff
+			X[3] ^= bits << 13 | bits >> 32 - 13
+			bits = X[3] + X[2] & 0xffffffff
+			X[0] ^= bits << 18 | bits >> 32 - 18
+			bits = X[5] + X[4] & 0xffffffff
+			X[6] ^= bits << 7 | bits >> 32 - 7
+			bits = X[6] + X[5] & 0xffffffff
+			X[7] ^= bits << 9 | bits >> 32 - 9
+			bits = X[7] + X[6] & 0xffffffff
+			X[4] ^= bits << 13 | bits >> 32 - 13
+			bits = X[4] + X[7] & 0xffffffff
+			X[5] ^= bits << 18 | bits >> 32 - 18
+			bits = X[10] + X[9] & 0xffffffff
+			X[11] ^= bits << 7 | bits >> 32 - 7
+			bits = X[11] + X[10] & 0xffffffff
+			X[8] ^= bits << 9 | bits >> 32 - 9
+			bits = X[8] + X[11] & 0xffffffff
+			X[9] ^= bits << 13 | bits >> 32 - 13
+			bits = X[9] + X[8] & 0xffffffff
+			X[10] ^= bits << 18 | bits >> 32 - 18
+			bits = X[15] + X[14] & 0xffffffff
+			X[12] ^= bits << 7 | bits >> 32 - 7
+			bits = X[12] + X[15] & 0xffffffff
+			X[13] ^= bits << 9 | bits >> 32 - 9
+			bits = X[13] + X[12] & 0xffffffff
+			X[14] ^= bits << 13 | bits >> 32 - 13
+			bits = X[14] + X[13] & 0xffffffff
+			X[15] ^= bits << 18 | bits >> 32 - 18
+
+		for index in range(0, 16):
+			block[index] = block[index] + X[index] & 0xffffffff
+
+
+def generate_users_conf(qstns, fname, auto, root): # type: (list[Question], str, bool, str) -> User
 	"""
 	Generates a users.json file from the given questions and returns a User containing the same
 	information.
@@ -422,18 +565,18 @@ def generate_users_conf(qstns: List[Question], fname: str, auto: bool, root: str
 	config = get_config(qstns, fname, auto)
 
 	if "tmAdminUser" not in config or "tmAdminPw" not in config:
-		raise ValueError(f"{fname} must include 'tmAdminUser' and 'tmAdminPw'")
+		raise ValueError("{fname} must include 'tmAdminUser' and 'tmAdminPw'".format(fname=fname))
 
 	hashed_pass = hash_pass(config["tmAdminPw"])
 
 	path = os.path.join(root, fname.lstrip('/'))
 	with open(path, 'w+') as conf_file:
-		json.dump({"username": config["tmAdminUser"], "password": hashed_pass}, conf_file, indent="\t")
+		json.dump({"username": config["tmAdminUser"], "password": hashed_pass}, conf_file, indent=indent)
 		print(file=conf_file)
 
 	return User(config["tmAdminUser"], config["tmAdminPw"])
 
-def generate_profiles_dir(questions: List[Question]):
+def generate_profiles_dir(questions): # type: (list[Question]) -> None
 	"""
 	I truly have no idea what's going on here. This is what the Perl did, so I
 	copied it. It does nothing. Literally nothing.
@@ -442,7 +585,7 @@ def generate_profiles_dir(questions: List[Question]):
 	user_in = questions
 	#pylint:enable=unused-variable
 
-def generate_openssl_conf(questions: List[Question], fname: str, auto: bool) -> SSLConfig:
+def generate_openssl_conf(questions, fname, auto): # type: (list[Question], str, bool) -> SSLConfig
 	"""
 	Constructs an SSLConfig by asking the passed questions, or using their default answers if in
 	auto mode.
@@ -451,11 +594,11 @@ def generate_openssl_conf(questions: List[Question], fname: str, auto: bool) ->
 	if "genCert" not in cfg_map:
 		raise ValueError("missing 'genCert' key")
 
-	gen_cert = cfg_map["genCert"].casefold() in {"y", "yes"}
+	gen_cert = cfg_map["genCert"].lower() in {"y", "yes"}
 
 	return SSLConfig(gen_cert, cfg_map)
 
-def generate_param_conf(qstns: List[Question], fname: str, auto: bool, root: str) -> dict:
+def generate_param_conf(qstns, fname, auto, root): # type: (list[Question], str, bool, str) -> dict
 	"""
 	Generates a profiles.json by asking the passed questions, or using their default answers in auto
 	mode.
@@ -466,12 +609,12 @@ def generate_param_conf(qstns: List[Question], fname: str, auto: bool, root: str
 
 	path = os.path.join(root, fname.lstrip('/'))
 	with open(path, 'w+') as conf_file:
-		json.dump(conf, conf_file, indent="\t")
+		json.dump(conf, conf_file, indent=indent)
 		print(file=conf_file)
 
 	return conf
 
-def sanity_check_config(cfg: Dict[str, List[Question]], automatic: bool) -> int:
+def sanity_check_config(cfg, automatic): # type: (dict[str, list[Question]], bool) -> int
 	"""
 	Checks a user-input configuration file, and outputs the number of files in the
 	default question set that did not appear in the input.
@@ -511,7 +654,7 @@ def sanity_check_config(cfg: Dict[str, List[Question]], automatic: bool) -> int:
 
 	return diffs
 
-def unmarshal_config(dct: dict) -> Dict[str, List[Question]]:
+def unmarshal_config(dct): # type: (dict) -> dict[str, list[Question]]
 	"""
 	Reads in a raw parsed configuration file and returns the resulting configuration.
 
@@ -523,20 +666,20 @@ def unmarshal_config(dct: dict) -> Dict[str, List[Question]]:
 	ret = {}
 	for file, questions in dct.items():
 		if not isinstance(questions, list):
-			raise ValueError(f"file '{file}' has malformed questions")
+			raise ValueError("file '{file}' has malformed questions".format(file=file))
 
 		qstns = []
 		for qstn in questions:
 			if not isinstance(qstn, dict):
-				raise ValueError(f"file '{file}' has a malformed question ({qstn})")
+				raise ValueError("file '{file}' has a malformed question ({qstn})".format(file=file, qstn=qstn))
 			try:
-				question = next(key for key in qstn.keys() if qstn not in ("hidden", "config_var"))
+				question = next(key for key in qstn.keys() if key not in ("hidden", "config_var"))
 			except StopIteration:
-				raise ValueError(f"question in '{file}' has no question/answer properties ({qstn})")
+				raise ValueError("question in '{file}' has no question/answer properties ({qstn})".format(file=file, qstn=qstn))
 
 			answer = qstn[question]
 			if not isinstance(question, str) or not isinstance(answer, str):
-				errstr = f"question in '{file}' has malformed question/answer property ({question}: {answer})"
+				errstr = "question in '{file}' has malformed question/answer property ({question}: {answer})".format(file=file, question=question, answer=answer)
 				raise ValueError(errstr)
 
 			del qstn[question]
@@ -546,10 +689,10 @@ def unmarshal_config(dct: dict) -> Dict[str, List[Question]]:
 				del qstn["hidden"]
 
 			if "config_var" not in qstn:
-				raise ValueError(f"question in '{file}' has no 'config_var' property")
+				raise ValueError("question in '{file}' has no 'config_var' property".format(file=file))
 			cfg_var = qstn["config_var"]
 			if not isinstance(cfg_var, str):
-				raise ValueError(f"question in '{file}' has malformed 'config_var' property ({cfg_var})")
+				raise ValueError("question in '{file}' has malformed 'config_var' property ({cfg_var})".format(file=file, cfg_var=cfg_var))
 			del qstn["config_var"]
 
 			if qstn:
@@ -560,48 +703,52 @@ def unmarshal_config(dct: dict) -> Dict[str, List[Question]]:
 
 	return ret
 
-def setup_maxmind(maxmind_answer: str, root: str):
+def setup_maxmind(maxmind_answer, root): # type: (str, str) -> None
 	"""
 	If 'maxmind_answer' is a truthy response ('y' or 'yes' (case-insensitive), sets up a Maxmind
 	database using `wget`.
 	"""
-	if maxmind_answer.casefold() not in {'y', 'yes'}:
+	if maxmind_answer.lower() not in {'y', 'yes'}:
 		logging.info("Not downloading Maxmind data")
 		return
 
 	os.chdir(os.path.join(root, 'opt/traffic_ops/app/public/routing'))
 
+	def failed_download(e, ip_version):  # type: (Exception, int) -> None
+		logging.error("Failed to download MaxMind data")
+		logging.debug("(ipv%d) Exception: %s", ip_version, e)
+
 	wget = "/usr/bin/wget"
 	cmd = [wget, "https://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz"]
 	# Perl ignored errors downloading the databases, so we do too
 	try:
-		subprocess.run(
+		subprocess.check_call(
 			cmd,
 			stderr=subprocess.PIPE,
 			stdout=subprocess.PIPE,
-			check=True,
 			universal_newlines=True
 		)
+	except subprocess.CalledProcessError as e:
+		failed_download(e, 4)
 	except subprocess.SubprocessError as e:
-		logging.error("Failed to download MaxMind data")
-		logging.debug("(ipv4) Exception: %s", e)
+		failed_download(e, 4)
 
 	cmd[1] = (
 		"https://geolite.maxmind.com/download/geoip/database/GeoLiteCityv6-beta/GeoLiteCityv6.dat.gz"
 	)
 	try:
-		subprocess.run(
+		subprocess.check_call(
 			cmd,
 			stderr=subprocess.PIPE,
 			stdout=subprocess.PIPE,
-			check=True,
 			universal_newlines=True
 		)
+	except subprocess.CalledProcessError as e:
+		failed_download(e, 6)
 	except subprocess.SubprocessError as e:
-		logging.error("Failed to download MaxMind data")
-		logging.debug("(ipv6) Exception: %s", e)
+		failed_download(e, 6)
 
-def exec_openssl(description: str, *cmd_args) -> bool:
+def exec_openssl(description, *cmd_args): # type: (str, ...) -> bool
 	"""
 	Executes openssl with the supplied command-line arguments.
 
@@ -610,28 +757,28 @@ def exec_openssl(description: str, *cmd_args) -> bool:
 	"""
 	logging.info(description)
 
-	cmd = ["/usr/bin/openssl", *cmd_args]
+	cmd = ("/usr/bin/openssl",) + cmd_args
 
 	while True:
-		proc = subprocess.run(
+		proc = subprocess.Popen(
 			cmd,
 			stderr=subprocess.PIPE,
 			stdout=subprocess.PIPE,
 			universal_newlines=True,
-			check=False
 		)
+		proc.wait()
 		if proc.returncode == 0:
 			return True
 
 		logging.debug("openssl exec failed with code %s; stderr: %s", proc.returncode, proc.stderr)
 		while True:
-			ans = input(f"{description} failed. Try again (y/n) [y]: ")
-			if not ans or ans.casefold().startswith('n'):
+			ans = input("{description} failed. Try again (y/n) [y]: ".format(description=description))
+			if not ans or ans.lower().startswith('n'):
 				return False
-			if ans.casefold().startswith('y'):
+			if ans.lower().startswith('y'):
 				break
 
-def setup_certificates(conf: SSLConfig, root: str, ops_user: str, ops_group: str) -> int:
+def setup_certificates(conf, root, ops_user, ops_group): # type: (SSLConfig, str, str, str) -> int
 	"""
 	Generates self-signed SSL certificates from the given configuration.
 	:returns: For whatever reason this subroutine needs to dictate the return code of the script, so that's what it returns.
@@ -659,7 +806,7 @@ def setup_certificates(conf: SSLConfig, root: str, ops_user: str, ops_group: str
 		"-out",
 		"server.key",
 		"-passout",
-		f"pass:{conf.rsa_password}",
+		"pass:{rsa_password}".format(rsa_password=conf.rsa_password),
 		"1024"
 	)
 	if not exec_openssl("Generating an RSA Private Server Key", *args):
@@ -673,7 +820,7 @@ def setup_certificates(conf: SSLConfig, root: str, ops_user: str, ops_group: str
 		"-out",
 		"server.csr",
 		"-passin",
-		f"pass:{conf.rsa_password}",
+		"pass:{rsa_password}".format(rsa_password=conf.rsa_password),
 		"-subj",
 		conf.params
 	)
@@ -690,7 +837,7 @@ def setup_certificates(conf: SSLConfig, root: str, ops_user: str, ops_group: str
 		"-out",
 		"server.key",
 		"-passin",
-		f"pass:{conf.rsa_password}"
+		"pass:{rsa_password}".format(rsa_password=conf.rsa_password)
 	)
 	if not exec_openssl("Removing the pass phrase from the server key", *args):
 		return 1
@@ -718,7 +865,7 @@ def setup_certificates(conf: SSLConfig, root: str, ops_user: str, ops_group: str
 	keypath = os.path.join(root, 'etc/pki/tls/private/localhost.key')
 	shutil.copy("server.key", keypath)
 	os.chmod(keypath, stat.S_IRUSR | stat.S_IWUSR)
-	shutil.chown(keypath, user=ops_user, group=ops_group)
+	os.chown(keypath, pwd.getpwnam(ops_user).pw_uid, grp.getgrnam(ops_group).gr_gid)
 
 	logging.info("The private key has been installed")
 	logging.info("Installing self signed certificate")
@@ -726,14 +873,14 @@ def setup_certificates(conf: SSLConfig, root: str, ops_user: str, ops_group: str
 	certpath = os.path.join(root, 'etc/pki/tls/certs/localhost.crt')
 	shutil.copy("server.crt", certpath)
 	os.chmod(certpath, stat.S_IRUSR | stat.S_IWUSR)
-	shutil.chown(certpath, user=ops_user, group=ops_group)
+	os.chown(certpath, pwd.getpwnam(ops_user).pw_uid, grp.getgrnam(ops_group).gr_gid)
 
 	logging.info("Saving the self signed csr")
 
 	csrpath = os.path.join(root, 'etc/pki/tls/certs/localhost.csr')
 	shutil.copy("server.csr", csrpath)
 	os.chmod(csrpath, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH)
-	shutil.chown(csrpath, user=ops_user, group=ops_group)
+	os.chown(csrpath, pwd.getpwnam(ops_user).pw_uid, grp.getgrnam(ops_group).gr_gid)
 
 	log_msg = """
         The self signed certificate has now been installed.
@@ -749,8 +896,10 @@ def setup_certificates(conf: SSLConfig, root: str, ops_user: str, ops_group: str
 	try:
 		with open(cdn_conf_path) as conf_file:
 			cdn_conf = json.load(conf_file)
-	except (OSError, json.JSONDecodeError) as e:
-		raise OSError(f"reading {cdn_conf_path}: {e}") from e
+	except (OSError, ValueError) as e:
+		exception = OSError("reading {cdn_conf_path}: {e}".format(cdn_conf_path=cdn_conf_path, e=e))
+		exception.__cause__ = e
+		raise exception
 
 	if (
 		not isinstance(cdn_conf, dict) or
@@ -774,7 +923,7 @@ def setup_certificates(conf: SSLConfig, root: str, ops_user: str, ops_group: str
 
 	listen = hypnotoad["listen"][0]
 
-	if f"cert={certpath}" not in listen or f"key={keypath}" not in listen:
+	if "cert={certpath}".format(certpath=certpath) not in listen or "key={keypath}".format(keypath=keypath) not in listen:
 		log_msg = """	The "listen" portion of %s is:
 	%s
 	and does not reference the same "cert=" and "key=" values as are created here.
@@ -785,7 +934,7 @@ def setup_certificates(conf: SSLConfig, root: str, ops_user: str, ops_group: str
 
 	return 0
 
-def random_word(length: int = 12) -> str:
+def random_word(length = 12): # type: (int) -> str
 	"""
 	Returns a randomly generated string 'length' characters long containing only word
 	characters ([a-zA-Z0-9_]).
@@ -793,7 +942,7 @@ def random_word(length: int = 12) -> str:
 	word_chars = string.ascii_letters + string.digits + '_'
 	return ''.join(random.choice(word_chars) for _ in range(length))
 
-def generate_cdn_conf(questions: List[Question], fname: str, automatic: bool, root: str):
+def generate_cdn_conf(questions, fname, automatic, root): # type: (list[Question], str, bool, str) -> None
 	"""
 	Generates some properties of a cdn.conf file based on the passed questions.
 
@@ -804,38 +953,54 @@ def generate_cdn_conf(questions: List[Question], fname: str, automatic: bool, ro
 	if "genSecret" not in cdn_conf:
 		raise ValueError("missing 'genSecret' config_var")
 
-	gen_secret = cdn_conf["genSecret"].casefold() in {'y', 'yes'}
+	gen_secret = cdn_conf["genSecret"].lower() in {'y', 'yes'}
 
 	try:
 		num_secrets = int(cdn_conf["keepSecrets"])
 	except KeyError as e:
-		raise ValueError("missing 'keepSecrets' config_var") from e
+		exception = ValueError("missing 'keepSecrets' config_var")
+		exception.__cause__ = e
+		raise exception
 	except ValueError as e:
-		raise ValueError(f"invalid 'keepSecrets' config_var value: {e}") from e
+		exception = ValueError("invalid 'keepSecrets' config_var value: {e}".format(e=e))
+		exception.__cause__ = e
+		raise exception
 
 	try:
 		port = int(cdn_conf["port"])
 	except KeyError as e:
-		raise ValueError("missing 'port' config_var") from e
+		exception = ValueError("missing 'port' config_var")
+		exception.__cause__ = e
+		raise exception
 	except ValueError as e:
-		raise ValueError(f"invalid 'port' config_var value: {e}") from e
+		exception = ValueError("invalid 'port' config_var value: {e}".format(e=e))
+		exception.__cause__ = e
+		raise exception
 
 	try:
 		workers = int(cdn_conf["workers"])
 	except KeyError as e:
-		raise ValueError("missing 'workers' config_var") from e
+		exception = ValueError("missing 'workers' config_var")
+		exception.__cause__ = e
+		raise exception
 	except ValueError as e:
-		raise ValueError(f"invalid 'workers' config_var value: {e}") from e
+		exception = ValueError("invalid 'workers' config_var value: {e}".format(e=e))
+		exception.__cause__ = e
+		raise exception
 
 	try:
 		url = cdn_conf["base_url"]
 	except KeyError as e:
-		raise ValueError("missing 'base_url' config_var") from e
+		exception = ValueError("missing 'base_url' config_var")
+		exception.__cause__ = e
+		raise exception
 
 	try:
 		ldap_loc = cdn_conf["ldap_conf_location"]
 	except KeyError as e:
-		raise ValueError("missing 'ldap_conf_location' config_var") from e
+		exception = ValueError("missing 'ldap_conf_location' config_var")
+		exception.__cause__ = e
+		raise exception
 
 	conf = CDNConfig(gen_secret, num_secrets, port, workers, url, ldap_loc)
 
@@ -845,8 +1010,10 @@ def generate_cdn_conf(questions: List[Question], fname: str, automatic: bool, ro
 		with open(path) as conf_file:
 			try:
 				existing_conf = json.load(conf_file)
-			except json.JSONDecodeError as e:
-				raise ValueError(f"invalid existing cdn.config at {path}: {e}") from e
+			except ValueError as e:
+				exception = ValueError("invalid existing cdn.config at {path}: {e}".format(path=path, e=e))
+				exception.__cause__ = e
+				raise exception
 
 	if not isinstance(existing_conf, dict):
 		logging.warning("Existing cdn.conf (at '%s') is not an object - overwriting", path)
@@ -871,11 +1038,11 @@ def generate_cdn_conf(questions: List[Question], fname: str, automatic: bool, ro
 		existing_conf["hypnotoad"]["workers"] = conf.num_workers
 
 	with open(path, "w+") as conf_file:
-		json.dump(existing_conf, conf_file, indent="\t")
+		json.dump(existing_conf, conf_file, indent=indent)
 		print(file=conf_file)
 	logging.info("CDN configuration has been saved")
 
-def db_connection_string(dbconf: dict) -> str:
+def db_connection_string(dbconf): # type: (dict) -> str
 	"""
 	Constructs a database connection string from the passed configuration object.
 	"""
@@ -884,9 +1051,9 @@ def db_connection_string(dbconf: dict) -> str:
 	db_name = "traffic_ops" if dbconf["type"] == "Pg" else dbconf["type"]
 	hostname = dbconf["hostname"]
 	port = dbconf["port"]
-	return f"postgresql://{user}:{password}@{hostname}:{port}/{db_name}"
+	return "postgresql://{user}:{password}@{hostname}:{port}/{db_name}".format(user=user, password=password, hostname=hostname, port=port, db_name=db_name)
 
-def exec_psql(conn_str: str, query: str) -> str:
+def exec_psql(conn_str, query): # type: (str, str) -> str
 	"""
 	Executes SQL queries by forking and exec-ing '/usr/bin/psql'.
 
@@ -896,19 +1063,22 @@ def exec_psql(conn_str: str, query: str) -> str:
 	:returns: The comma-separated columns of each line-delimited row of the results of the query.
 	"""
 	cmd = ["/usr/bin/psql", "--tuples-only", "-d", conn_str, "-c", query]
-	proc = subprocess.run(
+	proc = subprocess.Popen(
 		cmd,
 		stderr=subprocess.PIPE,
 		stdout=subprocess.PIPE,
 		universal_newlines=True,
-		check=False
 	)
+	proc.wait()
 	if proc.returncode != 0:
 		logging.debug("psql exec failed; stderr: %s\n\tstdout: %s", proc.stderr, proc.stdout)
 		raise OSError("failed to execute database query")
-	return proc.stdout.strip()
+	if sys.version_info.major >= 3:
+		return proc.stdout.strip()
+	else:
+		return string.strip(proc.stdout)
 
-def invoke_db_admin_pl(action: str, root: str):
+def invoke_db_admin_pl(action, root): # type: (str, str) -> None
 	"""
 	Exectues admin with the given action, and looks for it from the given root directory.
 	"""
@@ -917,19 +1087,19 @@ def invoke_db_admin_pl(action: str, root: str):
 	# should be fixed at some point, IMO, but for now this works.
 	os.chdir(path)
 	cmd = [os.path.join(path, "db/admin"), "--env=production", action]
-	proc = subprocess.run(
+	proc = subprocess.Popen(
 		cmd,
 		stderr=subprocess.PIPE,
 		stdout=subprocess.PIPE,
 		universal_newlines=True,
-		check=False
 	)
+	proc.wait()
 	if proc.returncode != 0:
 		logging.debug("admin exec failed; stderr: %s\n\tstdout: %s", proc.stderr, proc.stdout)
-		raise OSError(f"Database {action} failed")
+		raise OSError("Database {action} failed".format(action=action))
 	logging.info("Database %s succeeded", action)
 
-def setup_database_data(conn_str: str, user: User, param_conf: dict, root: str):
+def setup_database_data(conn_str, user, param_conf, root): # type: (str, User, dict, str) -> None
 	"""
 	Sets up all necessary initial database data using `/usr/bin/sql`
 	"""
@@ -1044,18 +1214,19 @@ def setup_database_data(conn_str: str, user: User, param_conf: dict, root: str):
 	_ = exec_psql(conn_str, insert_cdn_query)
 
 def main(
-automatic: bool,
-debug: bool,
-defaults: Optional[str],
-cfile: Optional[str],
-root_dir: str,
-ops_user: str,
-ops_group: str,
-no_restart_to: bool,
-no_database: bool
-) -> int:
+automatic, # type: bool
+debug, # type: bool
+defaults, # type: str
+cfile, # type: str
+root_dir, # type: str
+ops_user, # type: str
+ops_group, # type: str
+no_restart_to, # type: bool
+no_database, # type: bool
+):
 	"""
 	Runs the main routine given the parsed arguments as input.
+	:rtype: int
 	"""
 	if debug:
 		logging.getLogger().setLevel(logging.DEBUG)
@@ -1077,12 +1248,12 @@ no_database: bool
 			if defaults:
 				try:
 					with open(defaults, "w") as dump_file:
-						json.dump(DEFAULTS, dump_file, indent="\t")
+						json.dump(DEFAULTS, dump_file, indent=indent)
 				except OSError as e:
 					logging.critical("Writing output: %s", e)
 					return 1
 			else:
-				json.dump(DEFAULTS, sys.stdout, cls=ConfigEncoder, indent="\t")
+				json.dump(DEFAULTS, sys.stdout, cls=ConfigEncoder, indent=indent)
 				print()
 		except ValueError as e:
 			logging.critical("Converting defaults to JSON: %s", e)
@@ -1103,7 +1274,7 @@ no_database: bool
 			diffs,
 			'' if diffs == 1 else 's'
 			)
-		except (OSError, ValueError, json.JSONDecodeError) as e:
+		except (OSError, ValueError) as e:
 			logging.critical("Reading in input file '%s': %s", cfile, e)
 			return 1
 
@@ -1161,40 +1332,47 @@ no_database: bool
 				"Use the script `/opt/traffic_ops/install/bin/todb_bootstrap.sh` " \
 				"on the db server to create it and run `postinstall` again."
 			)
-			return -1
+			return 1
 
 		if not os.path.isfile("/usr/bin/psql") or not os.access("/usr/bin/psql", os.X_OK):
 			logging.critical("psql is not installed, please install it to continue with database setup")
 			return 1
 
-		try:
-			setup_database_data(conn_str, admin_conf, paramconf, root_dir)
-		except (OSError, subprocess.SubprocessError)as e:
+		def db_connect_failed():
 			logging.error("Failed to set up database: %s", e)
 			logging.error(
-				"Can't connect to the database.  " \
-				"Use the script `/opt/traffic_ops/install/bin/todb_bootstrap.sh` " \
+				"Can't connect to the database.  "
+				"Use the script `/opt/traffic_ops/install/bin/todb_bootstrap.sh` "
 				"on the db server to create it and run `postinstall` again."
 			)
-			return -1
+
+		try:
+			setup_database_data(conn_str, admin_conf, paramconf, root_dir)
+		except (OSError, subprocess.CalledProcessError)as e:
+			db_connect_failed()
+			return 1
+		except subprocess.SubprocessError as e:
+			db_connect_failed()
+			return 1
 
 
 	if not no_restart_to:
 		logging.info("Starting Traffic Ops")
 		try:
 			cmd = ["/sbin/service", "traffic_ops", "restart"]
-			subprocess.run(
+			proc = subprocess.Popen(
 				cmd,
 				stderr=subprocess.PIPE,
 				stdout=subprocess.PIPE,
 				universal_newlines=True,
-				check=True
 			)
+			if proc.wait():
+				raise subprocess.CalledProcessError(proc.returncode, cmd)
 		except subprocess.CalledProcessError as e:
 			logging.critical("Failed to restart Traffic Ops, return code %s: %s", e.returncode, e)
-			logging.debug("stderr: %s\n\tstdout: %s", e.stderr, e.stdout)
+			logging.debug("stderr: %s\n\tstdout: %s", proc.stderr, proc.stdout)
 			return 1
-		except (OSError, subprocess.SubprocessError) as e:
+		except OSError as e:
 			logging.critical("Failed to restart Traffic Ops: unknown error occurred: %s", e)
 			return 1
 		# Perl didn't actually do any "waiting" before reporting success, so
diff --git a/traffic_ops/traffic_ops_golang/server/servers.go b/traffic_ops/traffic_ops_golang/server/servers.go
index 5e67dff43..b16362893 100644
--- a/traffic_ops/traffic_ops_golang/server/servers.go
+++ b/traffic_ops/traffic_ops_golang/server/servers.go
@@ -151,6 +151,22 @@ SELECT
 	s.status_last_updated
 ` + serversFromAndJoin
 
+const selectIDQuery = `
+SELECT
+	s.id
+` + serversFromAndJoin
+
+const midWhereClause = `
+WHERE t.name = :cache_type_mid AND s.cachegroup IN (
+	SELECT cg.parent_cachegroup_id FROM cachegroup AS cg
+	WHERE cg.id IN (
+	SELECT s.cachegroup FROM server AS s
+	WHERE s.id = ANY(:edge_ids)))
+	AND (SELECT d.topology
+		FROM deliveryservice d
+		WHERE d.id = :ds_id) IS NULL
+`
+
 const insertQueryV3 = `
 INSERT INTO server (
 	cachegroup,
@@ -1119,10 +1135,11 @@ func getServers(h http.Header, params map[string]string, tx *sqlx.Tx, user *auth
 
 	// if ds requested uses mid-tier caches, add those to the list as well
 	if usesMids {
-		midIDs, userErr, sysErr, errCode := getMidServers(ids, servers, dsID, cdnID, tx)
+		midIDs, userErr, sysErr, errCode := getMidServers(ids, servers, dsID, cdnID, tx, dsHasRequiredCapabilities)
 
 		log.Debugf("getting mids: %v, %v, %s\n", userErr, sysErr, http.StatusText(errCode))
 
+		serverCount = serverCount + uint64(len(midIDs))
 		if userErr != nil || sysErr != nil {
 			return nil, serverCount, userErr, sysErr, errCode, nil
 		}
@@ -1215,7 +1232,7 @@ func getServers(h http.Header, params map[string]string, tx *sqlx.Tx, user *auth
 }
 
 // getMidServers gets the mids used by the edges provided with an option to filter for a given cdn
-func getMidServers(edgeIDs []int, servers map[int]tc.ServerV40, dsID int, cdnID int, tx *sqlx.Tx) ([]int, error, error, int) {
+func getMidServers(edgeIDs []int, servers map[int]tc.ServerV40, dsID int, cdnID int, tx *sqlx.Tx, includeCapabilities bool) ([]int, error, error, int) {
 	if len(edgeIDs) == 0 {
 		return nil, nil, nil, http.StatusOK
 	}
@@ -1226,17 +1243,48 @@ func getMidServers(edgeIDs []int, servers map[int]tc.ServerV40, dsID int, cdnID
 		"ds_id":          dsID,
 	}
 
-	// TODO: include secondary parent?
-	query := selectQuery + `
-	WHERE t.name = :cache_type_mid AND s.cachegroup IN (
-	SELECT cg.parent_cachegroup_id FROM cachegroup AS cg
-	WHERE cg.id IN (
-	SELECT s.cachegroup FROM server AS s
-	WHERE s.id = ANY(:edge_ids)))
-	AND (SELECT d.topology
-		FROM deliveryservice d
-		WHERE d.id = :ds_id) IS NULL
-	`
+	midIDs := []int{}
+	query := ""
+	if includeCapabilities {
+		// Query to select the associated mids for this DS
+		q := selectIDQuery + midWhereClause
+		rows, err := tx.NamedQuery(q, filters)
+		if err != nil {
+			return nil, err, nil, http.StatusBadRequest
+		}
+		defer rows.Close()
+
+		for rows.Next() {
+			var midID int
+			if err := rows.Scan(&midID); err != nil {
+				log.Errorf("could not scan mid server id: %s\n", err)
+				return nil, nil, err, http.StatusInternalServerError
+			}
+			midIDs = append(midIDs, midID)
+		}
+		filters["mid_ids"] = pq.Array(midIDs)
+
+		// Query to select only those mids that match the required capabilities of the DS
+		query = selectQuery + midWhereClause + `
+		AND s.id IN (
+		WITH capabilities AS (
+		SELECT ARRAY_AGG(ssc.server_capability), server
+		FROM server_server_capability ssc
+		WHERE ssc.server = ANY(:mid_ids)
+		GROUP BY server)
+		SELECT server
+		FROM capabilities WHERE
+		capabilities.array_agg
+		@>
+		(
+		SELECT ARRAY_AGG(drc.required_capability) 
+		FROM deliveryservices_required_capability drc 
+		WHERE drc.deliveryservice_id=:ds_id)
+		)`
+	} else {
+		// TODO: include secondary parent?
+		query = selectQuery + midWhereClause
+	}
 
 	if cdnID > 0 {
 		query += ` AND s.cdn_id = :cdn_id`


Test patch:
diff --git a/traffic_monitor/datareq/cachestate_test.go b/traffic_monitor/datareq/cachestate_test.go
new file mode 100644
index 000000000..4de10f259
--- /dev/null
+++ b/traffic_monitor/datareq/cachestate_test.go
@@ -0,0 +1,172 @@
+package datareq
+
+/*
+ * 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 (
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_monitor/cache"
+	"github.com/apache/trafficcontrol/traffic_monitor/dsdata"
+	"github.com/apache/trafficcontrol/traffic_monitor/threadsafe"
+)
+
+func TestCreateCacheStatusesForKbps(t *testing.T) {
+	var cacheTypes map[tc.CacheName]tc.CacheType
+	var statInfoHistory cache.ResultInfoHistory
+	statResultHistory := threadsafe.NewResultStatHistory()
+	healthHistory := make(map[tc.CacheName][]cache.Result, 0)
+	cacheResult := cache.Result{
+		Available:       true,
+		Error:           nil,
+		ID:              "1",
+		Miscellaneous:   nil,
+		PollFinished:    nil,
+		PollID:          0,
+		PrecomputedData: cache.PrecomputedData{},
+		RequestTime:     0,
+		Statistics:      cache.Statistics{},
+		Time:            time.Now(),
+		UsingIPv4:       true,
+		Vitals:          cache.Vitals{},
+		InterfaceVitals: map[string]cache.Vitals{"interface1": cache.Vitals{
+			LoadAvg:    23.2,
+			BytesOut:   200,
+			BytesIn:    140,
+			KbpsOut:    300,
+			MaxKbpsOut: 1000,
+		}},
+	}
+	healthHistory["edgeserver"] = []cache.Result{cacheResult}
+
+	var lastHealthDurations map[tc.CacheName]time.Duration
+	var cacheStates map[tc.CacheName]tc.IsAvailable
+	var lastStats dsdata.LastStats
+	localCacheStatusThreadsafe := threadsafe.NewCacheAvailableStatus()
+	statMaxKbpses := threadsafe.NewCacheKbpses()
+	servers := make(map[string]tc.TrafficServer, 0)
+	interfaces := make([]tc.ServerInterfaceInfo, 0)
+	ipAddresses := make([]tc.ServerIPAddress, 0)
+	ipAddresses = append(ipAddresses, tc.ServerIPAddress{
+		Address:        "123.24.25.26",
+		Gateway:        util.StrPtr("255.255.0.0"),
+		ServiceAddress: true,
+	})
+	i := tc.ServerInterfaceInfo{
+		IPAddresses:  ipAddresses,
+		MaxBandwidth: util.Uint64Ptr(1000),
+		Monitor:      false,
+		MTU:          util.Uint64Ptr(9000),
+		Name:         "interface1",
+	}
+	interfaces = append(interfaces, i)
+
+	s := tc.TrafficServer{
+		CacheGroup:       "cg",
+		DeliveryServices: nil,
+		FQDN:             "fqdn",
+		HashID:           "hashID",
+		HostName:         "hostName",
+		HTTPSPort:        443,
+		Interfaces:       interfaces,
+		Port:             8080,
+		Profile:          "profile",
+		ServerStatus:     "REPORTED",
+		Type:             "EDGE",
+	}
+	servers["edgeserver"] = s
+	result := createCacheStatuses(cacheTypes,
+		statInfoHistory,
+		statResultHistory,
+		healthHistory,
+		lastHealthDurations,
+		cacheStates,
+		lastStats,
+		localCacheStatusThreadsafe,
+		statMaxKbpses,
+		servers)
+
+	if len(result) != 1 {
+		t.Fatalf("expected only one cache in result, but got %d", len(result))
+	}
+	if status, ok := result["edgeserver"]; !ok {
+		t.Fatalf("result status did not contain the expected key 'edgeserver'")
+	} else {
+		if status.BandwidthKbps == nil {
+			t.Fatalf("expected a valid value in BandwidthKbps, but got nothing")
+		}
+		if *status.BandwidthKbps != 300 {
+			t.Errorf("expected BandwidthKbps to be equal to the sum of the values in the interfaces (300), but got %f", *status.BandwidthKbps)
+		}
+	}
+
+	// Add another interface to the server and test again
+	ipAddresses = append(ipAddresses, tc.ServerIPAddress{
+		Address:        "223.24.25.26",
+		Gateway:        util.StrPtr("255.255.0.0"),
+		ServiceAddress: true,
+	})
+
+	i = tc.ServerInterfaceInfo{
+		IPAddresses:  ipAddresses,
+		MaxBandwidth: util.Uint64Ptr(1000),
+		Monitor:      false,
+		MTU:          util.Uint64Ptr(9000),
+		Name:         "interface2",
+	}
+	interfaces = append(interfaces, i)
+	s.Interfaces = interfaces
+	servers["edgeserver"] = s
+	cacheResult.InterfaceVitals["interface2"] = cache.Vitals{
+		LoadAvg:    19.23,
+		BytesOut:   45,
+		BytesIn:    40,
+		KbpsOut:    500,
+		MaxKbpsOut: 1000,
+	}
+	healthHistory["edgeserver"] = []cache.Result{cacheResult}
+
+	result = createCacheStatuses(cacheTypes,
+		statInfoHistory,
+		statResultHistory,
+		healthHistory,
+		lastHealthDurations,
+		cacheStates,
+		lastStats,
+		localCacheStatusThreadsafe,
+		statMaxKbpses,
+		servers)
+
+	if len(result) != 1 {
+		t.Fatalf("expected only one cache in result, but got %d", len(result))
+	}
+	if status, ok := result["edgeserver"]; !ok {
+		t.Fatalf("result status did not contain the expected key 'edgeserver'")
+	} else {
+		if status.BandwidthKbps == nil {
+			t.Fatalf("expected a valid value in BandwidthKbps, but got nothing")
+		}
+		if *status.BandwidthKbps != 800 {
+			t.Errorf("expected BandwidthKbps to be equal to the sum of the values in the interfaces (800), but got %f", *status.BandwidthKbps)
+		}
+	}
+}
diff --git a/traffic_ops/install/bin/postinstall.test.sh b/traffic_ops/install/bin/postinstall.test.sh
index c5317708c..b0c44fe56 100755
--- a/traffic_ops/install/bin/postinstall.test.sh
+++ b/traffic_ops/install/bin/postinstall.test.sh
@@ -15,18 +15,83 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
 set -e;
 
-if [[ ! -x /usr/bin/python3 ]]; then
+cd "$(dirname "${BASH_SOURCE[0]}")";
+readonly MY_DIR="$(pwd)";
+
+help_string="$(<<-'HELP_STRING' cat
+	Usage: ./postinstall.test.h [
+	    -2        Set Python version to 2
+	    -3        Set Python version to 3
+	    -b        Explicitly set the path to the Python binary as this value
+	    -h, ?     Print this help text and exit
+	    -s        Do not test Python 2 after testing Python 3
+HELP_STRING
+)"
+
+while getopts :23hsb: opt; do
+	case "$opt" in
+		2) python_version=2;;
+		3) python_version=3;;
+		b) python_bin="$OPTARG";;
+		h) echo "$help_string" && exit;;
+		s) skip_python2=true;;
+		?) echo "$help_string" && exit;;
+		*) echo "Invalid flag received: ${OPTARG}" >&2 && echo "$help_string" && exit 1;;
+	esac;
+done;
+
+python_version="${python_version:-3}";
+python_bin="${python_bin:-/usr/bin/python${python_version}}";
+
+if [[ ! -x "$python_bin" && "$python_version" -ge 3 ]]; then
 	echo "Python 3.6+ is required to run - or test - postinstall.py" >&2;
 	exit 1;
+elif [[ ! -x "$python_bin" && "$python_version" == 2 ]]; then
+	echo "Python ${python_version} is required to run - or test - postinstall.py against Python 2" >&2;
 fi
 
+readonly TO_PASSWORD=twelve;
 readonly ROOT_DIR="$(mktemp -d)";
 
 trap 'rm -rf $ROOT_DIR' EXIT;
 
+"$python_bin" <<EOF;
+from __future__ import print_function
+import sys
+from postinstall import Scrypt
+
+passwd = '${TO_PASSWORD}'
+n = 2 ** 10
+r_val = 1
+p_val = 1
+dklen = 2 ** 4
+salt = bytearray([196, 187, 115, 30, 109, 244, 168, 124, 70, 67, 229, 123, 156, 3, 138, 243, 234, 79, 79, 31, 67, 239, 249, 177, 237, 240, 201, 216, 81, 116, 186, 172, 153, 99, 240, 184, 186, 0, 119, 34, 165, 220, 3, 201, 104, 13, 13, 189, 135, 76, 160, 6, 206, 154, 124, 78, 112, 243, 132, 30, 48, 223, 224, 28])
+scrypt = Scrypt(password=passwd.encode(), salt=salt, cost_factor=n, block_size_factor=r_val,
+				parallelization_factor=p_val, key_length=dklen)
+
+expected_block = [82000861, 2203666842, 4001293736, 627876473, 3101038348, 376175724, 2967675936, 3143524608, 1069098580, 1894075103, 3699786793, 3537442772, 3575269184, 2926196224, 913960627, 2079499993]
+actual_block = [2245251288, 1072667772, 4071019211, 2090053191, 2877361598, 1101440729, 1502049634, 3905719376, 3112080378, 1388114151, 3517514506, 1152690600, 2085938056, 2696735995, 3835186347, 283826820]
+scrypt.salsa20(actual_block)
+if expected_block != actual_block:
+	print('Expected {expected_block} for salsa20 result, got {actual_block}'.format(expected_block=expected_block, actual_block=actual_block), file=sys.stderr)
+	exit(1)
+
+input_block = [1923378, 729355550, 2408212191, 579221939, 681409774, 1765430015, 3846256959, 831940078, 1480976199, 2878095125, 4245323720, 2776886825, 3332759976, 3497079966, 3107631655, 3763839506, 1283955177, 2851514107, 1743501900, 1888209181, 3387403441, 2898469985, 3685946334, 2122268467, 2234902587, 2934192414, 2528543680, 3247696936, 4144265372, 1687923239, 1573958329, 422403479]
+expected_block = [54040099, 3246390556, 3905565410, 4170358448, 2569315507, 3679433373, 2964493607, 3621375783, 318358481, 2014381982, 3240374105, 3569092356, 3150068788, 569153936, 2099549087, 2807540417, 2384835523, 4053238240, 1126008925, 1477842924, 1740405559, 1762470512, 2159908599, 1049875013, 2630682622, 1368095319, 1753173294, 3987760372, 3175003396, 1324304335, 775131569, 2728051478]
+actual_block = scrypt.block_mix(input_block)
+if expected_block != actual_block:
+	print('Expected {expected_block} for block mix result, got {actual_block}'.format(expected_block=expected_block, actual_block=actual_block), file=sys.stderr)
+	exit(1)
+
+expected_digest = bytearray([86, 124, 148, 28, 117, 181, 239, 64, 228, 6, 247, 83, 210, 88, 43, 144])
+actual_digest = scrypt.derive()
+if expected_digest != actual_digest:
+	print('Expected {expected_digest} for scrypt, got {actual_digest}'.format(expected_digest=expected_digest, actual_digest=actual_digest), file=sys.stderr)
+	exit(1)
+EOF
+
 mkdir -p "$ROOT_DIR/etc/pki/tls/certs";
 mkdir "$ROOT_DIR/etc/pki/tls/private";
 mkdir -p "$ROOT_DIR/opt/traffic_ops/app/public/routing";
@@ -45,8 +110,6 @@ EOF
 mkdir -p "$ROOT_DIR/opt/traffic_ops/install/data/json";
 mkdir "$ROOT_DIR/opt/traffic_ops/install/bin";
 
-readonly MY_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )";
-
 # defaults.json is used as input into the `--cfile` option of postinstall.py
 # for testing purposes
 cat <<- EOF > "$ROOT_DIR/defaults.json"
@@ -78,7 +141,7 @@ cat <<- EOF > "$ROOT_DIR/defaults.json"
 			"hidden": false
 		},
 		{
-			"Password for Traffic Ops database user": "twelve",
+			"Password for Traffic Ops database user": "${TO_PASSWORD}",
 			"config_var": "password",
 			"hidden": true
 		}
@@ -90,7 +153,7 @@ cat <<- EOF > "$ROOT_DIR/defaults.json"
 			"hidden": false
 		},
 		{
-			"Password for database server admin": "twelve",
+			"Password for database server admin": "${TO_PASSWORD}",
 			"config_var": "pgPassword",
 			"hidden": true
 		},
@@ -255,7 +318,7 @@ cat <<- EOF > "$ROOT_DIR/defaults.json"
 }
 EOF
 
-"$MY_DIR/postinstall.py" --no-root --root-directory="$ROOT_DIR" --no-restart-to --no-database --ops-user="$(whoami)" --ops-group="$(id -gn)" --automatic --cfile="$ROOT_DIR/defaults.json" --debug 2>"$ROOT_DIR/stderr" | tee "$ROOT_DIR/stdout"
+"$python_bin" "$MY_DIR/postinstall.py" --no-root --root-directory="$ROOT_DIR" --no-restart-to --no-database --ops-user="$(whoami)" --ops-group="$(id -gn)" --automatic --cfile="$ROOT_DIR/defaults.json" --debug 2>"$ROOT_DIR/stderr" | tee "$ROOT_DIR/stdout"
 
 if grep -q 'ERROR' $ROOT_DIR/stderr; then
 	echo "Errors found in script logs" >&2;
@@ -266,10 +329,14 @@ fi
 
 readonly USERS_JSON_FILE="$ROOT_DIR/opt/traffic_ops/install/data/json/users.json";
 
-/usr/bin/python3 <<EOF
+"$python_bin" <<EOF
+from __future__ import print_function
 import json
 import sys
 
+if sys.version_info.major < 3:
+	str = unicode
+
 try:
 	with open('$USERS_JSON_FILE') as fd:
 		users_json = json.load(fd)
@@ -309,12 +376,12 @@ if [[ "$(cat $POST_INSTALL_JSON)" != "{}" ]]; then
 fi
 
 readonly PROFILES_JSON_EXPECTED="{
-	\"tm.url\": \"https://localhost\",
 	\"cdn_name\": \"kabletown_cdn\",
-	\"dns_subdomain\": \"cdn1.kabletown.net\"
+	\"dns_subdomain\": \"cdn1.kabletown.net\",
+	\"tm.url\": \"https://localhost\"
 }";
 
-readonly PROFILES_JSON_ACTUAL="$(cat $ROOT_DIR/opt/traffic_ops/install/data/json/profiles.json)";
+readonly PROFILES_JSON_ACTUAL="$(<"$ROOT_DIR/opt/traffic_ops/install/data/json/profiles.json" jq -S --tab .)";
 if [[ "$PROFILES_JSON_ACTUAL" != "$PROFILES_JSON_EXPECTED" ]]; then
 	echo "Incorrect profiles.json, expected: $PROFILES_JSON_EXPECTED, got: $PROFILES_JSON_ACTUAL" >&2;
 	exit 1;
@@ -333,11 +400,15 @@ if [[ "$DB_CONF_ACTUAL" != "$DB_CONF_EXPECTED" ]]; then
 	exit 1;
 fi
 
-/usr/bin/python3 <<EOF
+"$python_bin" <<EOF
+from __future__ import print_function
 import json
 import string
 import sys
 
+if sys.version_info.major < 3:
+	str = unicode
+
 try:
 	with(open('$ROOT_DIR/opt/traffic_ops/app/conf/cdn.conf')) as fd:
 		conf = json.load(fd)
@@ -394,16 +465,16 @@ exit(0)
 EOF
 
 readonly DATABASE_CONF_EXPECTED='{
-	"type": "Pg",
 	"dbname": "traffic_ops",
+	"description": "Pg database on localhost:5432",
 	"hostname": "localhost",
-	"port": "5432",
-	"user": "traffic_ops",
 	"password": "twelve",
-	"description": "Pg database on localhost:5432"
+	"port": "5432",
+	"type": "Pg",
+	"user": "traffic_ops"
 }';
 
-readonly DATABASE_CONF_ACTUAL="$(cat $ROOT_DIR/opt/traffic_ops/app/conf/production/database.conf)";
+readonly DATABASE_CONF_ACTUAL="$(<"$ROOT_DIR/opt/traffic_ops/app/conf/production/database.conf" jq -S --tab .)";
 if [[ "$DATABASE_CONF_ACTUAL" != "$DATABASE_CONF_EXPECTED" ]]; then
 	echo "Incorrect database.conf, expected: $DATABASE_CONF_EXPECTED, got $DATABASE_CONF_ACTUAL" >&2;
 	exit 1;
@@ -429,3 +500,7 @@ if [[ "$KEY_FILE_TYPE" != "$KEY_FILE: PEM RSA private key" ]]; then
 	echo "Incorrect key file, expected PEM RSA private key, got: $KEY_FILE_TYPE" >&2;
 	exit 1;
 fi
+
+if [[ "$python_version" != 2 && -z "$skip_python2" ]]; then
+	exec "${MY_DIR}/$(basename "${BASH_SOURCE[0]}")" -2;
+fi;
diff --git a/traffic_ops/testing/api/v3/serverservercapability_test.go b/traffic_ops/testing/api/v3/serverservercapability_test.go
index eb5903e29..f971f0d55 100644
--- a/traffic_ops/testing/api/v3/serverservercapability_test.go
+++ b/traffic_ops/testing/api/v3/serverservercapability_test.go
@@ -19,6 +19,7 @@ import (
 	"net/http"
 	"net/url"
 	"sort"
+	"strconv"
 	"testing"
 	"time"
 
@@ -32,6 +33,7 @@ func TestServerServerCapabilities(t *testing.T) {
 		SortTestServerServerCapabilities(t)
 		GetTestServerServerCapabilitiesIMS(t)
 		GetTestServerServerCapabilities(t)
+		GetDeliveryServiceServersWithCapabilities(t)
 	})
 }
 
@@ -385,3 +387,97 @@ func DeleteTestServerServerCapabilitiesForTopologies(t *testing.T) {
 	}
 
 }
+
+func GetDeliveryServiceServersWithCapabilities(t *testing.T) {
+	dses, _, err := TOSession.GetDeliveryServicesV30WithHdr(nil, url.Values{"xmlId": []string{"ds4"}})
+	if err != nil {
+		t.Fatalf("Failed to get Delivery Services: %v", err)
+	}
+	if len(dses) < 1 {
+		t.Fatal("Failed to get at least one Delivery Service")
+	}
+
+	ds := dses[0]
+	if ds.ID == nil {
+		t.Fatal("Got Delivery Service with nil ID")
+	}
+
+	// Get an edge
+	params := url.Values{}
+	params.Add("hostName", "atlanta-edge-16")
+	rs, _, err := TOSession.GetServersWithHdr(&params, nil)
+	if err != nil {
+		t.Fatalf("Failed to fetch server information: %v", err)
+	} else if len(rs.Response) == 0 {
+		t.Fatalf("Failed to fetch server information: No results returned!")
+	}
+	edgeID := *rs.Response[0].ID
+
+	// Get a MID
+	params = url.Values{}
+	params.Add("hostName", "atlanta-mid-02")
+	rs, _, err = TOSession.GetServersWithHdr(&params, nil)
+	if err != nil {
+		t.Fatalf("Failed to fetch server information: %v", err)
+	} else if len(rs.Response) == 0 {
+		t.Fatalf("Failed to fetch server information: No results returned!")
+	}
+	midID := *rs.Response[0].ID
+	// assign edge and mid
+	_, _, err = TOSession.CreateDeliveryServiceServers(*ds.ID, []int{edgeID, midID}, true)
+	if err != nil {
+		t.Fatalf("expected no error while assigning servers to DS, but got %s", err.Error())
+	}
+	params = url.Values{}
+	params.Add("dsId", strconv.Itoa(*ds.ID))
+	servers, _, err := TOSession.GetServersWithHdr(&params, nil)
+	if err != nil {
+		t.Fatalf("Failed to get server by Delivery Service ID: %v", err)
+	}
+	if len(servers.Response) != 2 {
+		t.Fatalf("expected to get 2 servers for Delivery Service: %d, actual: %d", *ds.ID, len(servers.Response))
+	}
+
+	// now assign a capability
+	reqCap := tc.DeliveryServicesRequiredCapability{
+		DeliveryServiceID:  ds.ID,
+		RequiredCapability: util.StrPtr("blah"),
+	}
+	_, _, err = TOSession.CreateDeliveryServicesRequiredCapability(reqCap)
+	// this should fail because the mid doesn't have the reqd capability
+	if err == nil {
+		t.Fatalf("expected error creating DS reqd capability, but got nothing")
+	}
+	ssc := tc.ServerServerCapability{
+		ServerID:         &midID,
+		ServerCapability: util.StrPtr("blah"),
+	}
+	// assign the capability to the mid
+	_, _, err = TOSession.CreateServerServerCapability(ssc)
+	if err != nil {
+		t.Fatalf("couldn't assign server capability to server with ID %d, err: %s", midID, err.Error())
+	}
+	_, _, err = TOSession.CreateDeliveryServicesRequiredCapability(reqCap)
+	// this should pass now because the mid has the reqd capability
+	if err != nil {
+		t.Fatalf("expected no error creating DS reqd capability, but got %s", err.Error())
+	}
+
+	params = url.Values{}
+	params.Add("dsId", strconv.Itoa(*ds.ID))
+	servers, _, err = TOSession.GetServersWithHdr(&params, nil)
+	if err != nil {
+		t.Fatalf("Failed to get server by Delivery Service ID: %v", err)
+	}
+	if len(servers.Response) != 2 {
+		t.Fatalf("expected to get 2 servers for Delivery Service: %d, actual: %d", *ds.ID, len(servers.Response))
+	}
+	_, _, err = TOSession.DeleteDeliveryServiceServer(*ds.ID, edgeID)
+	if err != nil {
+		t.Errorf("error trying to delete delivery service server: %s", err.Error())
+	}
+	_, _, err = TOSession.DeleteDeliveryServiceServer(*ds.ID, midID)
+	if err != nil {
+		t.Errorf("error trying to delete delivery service server: %s", err.Error())
+	}
+}
diff --git a/traffic_ops/testing/api/v3/tc-fixtures.json b/traffic_ops/testing/api/v3/tc-fixtures.json
index 03ce02146..bb24c577e 100644
--- a/traffic_ops/testing/api/v3/tc-fixtures.json
+++ b/traffic_ops/testing/api/v3/tc-fixtures.json
@@ -38,6 +38,13 @@
             "shortName": "pg2",
             "typeName": "MID_LOC"
         },
+        {
+            "latitude": 0,
+            "longitude": 0,
+            "name": "parentCachegroup3",
+            "shortName": "pg3",
+            "typeName": "MID_LOC"
+        },
         {
             "latitude": 0,
             "longitude": 0,
@@ -115,6 +122,19 @@
                 "fallback3"
             ]
         },
+        {
+            "latitude": 0,
+            "longitude": 0,
+            "name": "cachegroup4",
+            "parentCachegroupName": "parentCachegroup3",
+            "shortName": "cg4",
+            "localizationMethods": [
+                "CZ",
+                "DEEP_CZ",
+                "GEO"
+            ],
+            "typeName": "EDGE_LOC"
+        },
         {
             "latitude": 0,
             "longitude": 0,
@@ -1437,6 +1457,75 @@
             "type": "HTTP",
             "xmlId": "test-ds-server-assignments",
             "maxRequestHeaderBytes": 131072
+        },
+        {
+            "active": false,
+            "cdnName": "cdn1",
+            "cacheurl": "cacheUrl3",
+            "ccrDnsTtl": 3600,
+            "checkPath": "",
+            "consistentHashQueryParams": null,
+            "deepCachingType": "NEVER",
+            "displayName": "d s 4",
+            "dnsBypassCname": null,
+            "dnsBypassIp": "",
+            "dnsBypassIp6": "",
+            "dnsBypassTtl": 30,
+            "dscp": 40,
+            "edgeHeaderRewrite": "edgeRewrite1\nedgeHeader2",
+            "exampleURLs": [
+                "http://ccr.ds4.example.net",
+                "https://ccr.ds4x.example.net"
+            ],
+            "fqPacingRate": 0,
+            "geoLimit": 0,
+            "geoLimitCountries": "",
+            "geoLimitRedirectURL": null,
+            "geoProvider": 0,
+            "globalMaxMbps": 0,
+            "globalMaxTps": 0,
+            "httpBypassFqdn": "",
+            "infoUrl": "TBD",
+            "initialDispersion": 1,
+            "ipv6RoutingEnabled": true,
+            "lastUpdated": "2018-04-06 16:48:51+00",
+            "logsEnabled": false,
+            "longDesc": "d s 4",
+            "longDesc1": "ds4",
+            "longDesc2": "ds4",
+            "matchList": [
+                {
+                    "pattern": ".*\\.ds4\\..*",
+                    "setNumber": 0,
+                    "type": "HOST_REGEXP"
+                }
+            ],
+            "maxDnsAnswers": 0,
+            "maxOriginConnections": 0,
+            "midHeaderRewrite": "midHeader1\nmidHeader2",
+            "missLat": 41.881944,
+            "missLong": -87.627778,
+            "multiSiteOrigin": false,
+            "orgServerFqdn": "http://origin.ds4.example.net",
+            "originShield": null,
+            "profileDescription": null,
+            "profileName": null,
+            "protocol": 2,
+            "qstringIgnore": 1,
+            "rangeRequestHandling": 0,
+            "regexRemap": "rr1\nrr2",
+            "regionalGeoBlocking": false,
+            "remapText": "@plugin=tslua.so @pparam=/opt/trafficserver/etc/trafficserver/ds4plugin.lua",
+            "routingName": "ccr-ds4",
+            "signed": false,
+            "signingAlgorithm": "url_sig",
+            "sslKeyVersion": 2,
+            "tenant": "tenant3",
+            "tenantName": "tenant3",
+            "type": "HTTP_LIVE",
+            "xmlId": "ds4",
+            "anonymousBlockingEnabled": true,
+            "maxRequestHeaderBytes": 131072
         }
     ],
     "deliveryServicesRegexes": [
@@ -4420,6 +4509,102 @@
             "tcpPort": 80,
             "type": "EDGE",
             "updPending": false
+        },
+        {
+            "cachegroup": "cachegroup1",
+            "cdnName": "cdn1",
+            "domainName": "ga.atlanta.kabletown.net",
+            "guid": null,
+            "hostName": "atlanta-edge-16",
+            "httpsPort": 443,
+            "iloIpAddress": "",
+            "iloIpGateway": "",
+            "iloIpNetmask": "",
+            "iloPassword": "",
+            "iloUsername": "",
+            "interfaces": [
+                {
+                    "ipAddresses": [
+                        {
+                            "address": "127.1.0.21/30",
+                            "gateway": "127.1.0.21",
+                            "serviceAddress": true
+                        },
+                        {
+                            "address": "2346:1234:12:8::1/64",
+                            "gateway": "2346:1234:12:8::1",
+                            "serviceAddress": false
+                        }
+                    ],
+                    "maxBandwidth": null,
+                    "monitor": true,
+                    "mtu": 9000,
+                    "name": "bond0"
+                }
+            ],
+            "lastUpdated": "2018-03-28T17:30:00.220351+00:00",
+            "mgmtIpAddress": "",
+            "mgmtIpGateway": "",
+            "mgmtIpNetmask": "",
+            "offlineReason": null,
+            "physLocation": "Denver",
+            "profile": "EDGE1",
+            "rack": "RR 119.02",
+            "revalPending": false,
+            "status": "REPORTED",
+            "tcpPort": 80,
+            "type": "EDGE",
+            "updPending": false,
+            "xmppId": "atlanta-edge-16\\\\@ocdn.kabletown.net",
+            "xmppPasswd": "X"
+        },
+        {
+            "cachegroup": "parentCachegroup3",
+            "cdnName": "cdn1",
+            "domainName": "ga.atlanta.kabletown.net",
+            "guid": null,
+            "hostName": "atlanta-mid-02",
+            "httpsPort": 443,
+            "iloIpAddress": "",
+            "iloIpGateway": "",
+            "iloIpNetmask": "",
+            "iloPassword": "",
+            "iloUsername": "",
+            "interfaces": [
+                {
+                    "ipAddresses": [
+                        {
+                            "address": "127.1.0.2/30",
+                            "gateway": "127.1.0.2",
+                            "serviceAddress": true
+                        },
+                        {
+                            "address": "2346:1234:12:9::10/64",
+                            "gateway": "2346:1234:12:9::10",
+                            "serviceAddress": false
+                        }
+                    ],
+                    "maxBandwidth": null,
+                    "monitor": true,
+                    "mtu": 9000,
+                    "name": "bond0"
+                }
+            ],
+            "lastUpdated": "2018-03-28T17:30:00.220351+00:00",
+            "mgmtIpAddress": "",
+            "mgmtIpGateway": "",
+            "mgmtIpNetmask": "",
+            "offlineReason": null,
+            "physLocation": "Denver",
+            "profile": "MID1",
+            "rack": "RR 119.02",
+            "revalPending": false,
+            "status": "REPORTED",
+            "tcpPort": 80,
+            "type": "MID",
+            "updPending": false,
+            "xmppId": "atlanta-mid-02\\\\@ocdn.kabletown.net",
+            "xmppPasswd": "X"
         }
     ],
     "serverCapabilities": [
@@ -4437,6 +4622,9 @@
         },
         {
             "name": "asdf"
+        },
+        {
+            "name": "blah"
         }
     ],
     "serverServerCapabilities": [
@@ -4511,6 +4699,10 @@
         {
             "serverHostName": "dtrc-mid-04",
             "serverCapability": "asdf"
+        },
+        {
+            "serverHostName": "atlanta-edge-16",
+            "serverCapability": "blah"
         }
     ],
     "serviceCategories": [
diff --git a/traffic_ops/traffic_ops_golang/server/servers_test.go b/traffic_ops/traffic_ops_golang/server/servers_test.go
index 11d264742..5a051c045 100644
--- a/traffic_ops/traffic_ops_golang/server/servers_test.go
+++ b/traffic_ops/traffic_ops_golang/server/servers_test.go
@@ -481,7 +481,7 @@ func TestGetMidServers(t *testing.T) {
 
 	mock.ExpectBegin()
 	mock.ExpectQuery("SELECT").WillReturnRows(rows2)
-	mid, userErr, sysErr, errCode := getMidServers(serverIDs, serverMap, 0, 0, db.MustBegin())
+	mid, userErr, sysErr, errCode := getMidServers(serverIDs, serverMap, 0, 0, db.MustBegin(), false)
 
 	if userErr != nil || sysErr != nil {
 		t.Fatalf("getMidServers expected: no errors, actual: %v %v with status: %s", userErr, sysErr, http.StatusText(errCode))
diff --git a/traffic_portal/test/integration/.gitignore b/traffic_portal/test/integration/.gitignore
index d0a9bbcea..9014891ff 100644
--- a/traffic_portal/test/integration/.gitignore
+++ b/traffic_portal/test/integration/.gitignore
@@ -1,21 +1,17 @@
-/*
- * 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.
- */
+#
+#  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.
+#
+#!/bin/bash
 
 node_modules
 GeneratedCode