You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@trafficcontrol.apache.org by GitBox <gi...@apache.org> on 2020/08/06 16:09:50 UTC

[GitHub] [trafficcontrol] mattjackson220 commented on a change in pull request #4916: Multi interface health

mattjackson220 commented on a change in pull request #4916:
URL: https://github.com/apache/trafficcontrol/pull/4916#discussion_r465351050



##########
File path: lib/go-tc/servers_test.go
##########
@@ -56,3 +59,1323 @@ func ExampleLegacyInterfaceDetails_ToInterfaces() {
 	// 	addr=::14/64, gateway=::15, service address=false
 	//
 }
+
+func ExampleLegacyInterfaceDetails_String() {
+	ipv4 := "192.0.2.0"
+	ipv6 := "2001:DB8::/64"
+	name := "test"
+	mtu := 9000
+
+	lid := LegacyInterfaceDetails{
+		InterfaceMtu:  &mtu,
+		InterfaceName: &name,
+		IP6Address:    &ipv6,
+		IP6Gateway:    nil,
+		IPAddress:     &ipv4,
+		IPGateway:     nil,
+		IPNetmask:     nil,
+	}
+
+	fmt.Println(lid.String())
+
+	// Output: LegacyInterfaceDetails(InterfaceMtu=9000, InterfaceName='test', IP6Address='2001:DB8::/64', IP6Gateway=nil, IPAddress='192.0.2.0', IPGateway=nil, IPNetmask=nil)
+}
+
+type interfaceTest struct {
+	ExpectedIPv4        string
+	ExpectedIPv4Gateway string
+	ExpectedIPv6        string
+	ExpectedIPv6Gateway string
+	ExpectedMTU         *uint64
+	ExpectedName        string
+	ExpectedNetmask     string
+	Interfaces          []ServerInterfaceInfo
+}
+
+// tests a set of interfaces' conversion to legacy format against expected
+// values.
+// Note: This doesn't distinguish between nil and pointer-to-empty-string values
+// when a value is not expected. That's because all ATC components treat null
+// and empty-string values the same, so it's not important which is returned by
+// the conversion process (and in fact expecting one or the other could
+// potentially break some applications).
+func testInfs(expected interfaceTest, t *testing.T) {
+	lid, err := InterfaceInfoToLegacyInterfaces(expected.Interfaces)
+	if err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if lid.InterfaceName == nil {
+		t.Error("Unexpectedly nil Interface Name")
+	} else if *lid.InterfaceName != expected.ExpectedName {
+		t.Errorf("Incorrect Interface Name; want: '%s', got: '%s'", expected.ExpectedName, *lid.InterfaceName)
+	}
+
+	if expected.ExpectedMTU != nil {
+		if lid.InterfaceMtu == nil {
+			t.Error("Unexpectedly nil Interface MTU")
+		} else if uint64(*lid.InterfaceMtu) != *expected.ExpectedMTU {
+			t.Errorf("Incorrect Interface MTU; want: %d, got: %d", *expected.ExpectedMTU, *lid.InterfaceMtu)
+		}
+	} else if lid.InterfaceMtu != nil {
+		t.Error("Unexpectedly non-nil Interface MTU")
+	}
+
+	if expected.ExpectedIPv4 != "" {
+		if lid.IPAddress == nil {
+			t.Error("Unexpectedly nil IPv4 Address")
+		} else if *lid.IPAddress != expected.ExpectedIPv4 {
+			t.Errorf("Incorrect IPv4 Address; want: '%s', got: '%s'", expected.ExpectedIPv4, *lid.IPAddress)
+		}
+	} else if lid.IPAddress != nil && *lid.IPAddress != "" {
+		t.Error("Unexpectedly non-empty IPv4 Address")
+	}
+
+	if expected.ExpectedIPv4Gateway != "" {
+		if lid.IPGateway == nil {
+			t.Error("Unexpectedly nil IPv4 Gateway")
+		} else if *lid.IPGateway != expected.ExpectedIPv4Gateway {
+			t.Errorf("Incorrect IPv4 Gateway; want: '%s', got: '%s'", expected.ExpectedIPv4Gateway, *lid.IPGateway)
+		}
+	} else if lid.IPGateway != nil && *lid.IPGateway != "" {
+		t.Error("Unexpectedly non-empty IPv4 Gateway")
+	}
+
+	if expected.ExpectedNetmask != "" {
+		if lid.IPNetmask == nil {
+			t.Error("Unexpectedly nil IPv4 Netmask")
+		} else if *lid.IPNetmask != expected.ExpectedNetmask {
+			t.Errorf("Incorrect IPv4 Netmask; want: '%s', got: '%s'", expected.ExpectedNetmask, *lid.IPNetmask)
+		}
+	} else if lid.IPNetmask != nil && *lid.IPNetmask != "" {
+		t.Error("Unexpectedly non-empty IPv4 Netmask")
+	}
+
+	if expected.ExpectedIPv6 != "" {
+		if lid.IP6Address == nil {
+			t.Error("Unexpectedly nil IPv6 Address")
+		} else if *lid.IP6Address != expected.ExpectedIPv6 {
+			t.Errorf("Incorrect IPv6 Address; want: '%s', got: '%s'", expected.ExpectedIPv6, *lid.IP6Address)
+		}
+	} else if lid.IP6Address != nil && *lid.IP6Address != "" {
+		t.Error("Unexpectedly non-empty IPv6 Address")
+	}
+
+	if expected.ExpectedIPv6Gateway != "" {
+		if lid.IP6Gateway == nil {
+			t.Error("Unexpectedly nil IPv6 Gateway")
+		} else if *lid.IP6Gateway != expected.ExpectedIPv6Gateway {
+			t.Errorf("Incorrect IPv6 Gateway; want: '%s', got: '%s'", expected.ExpectedIPv6Gateway, *lid.IP6Gateway)
+		}
+	} else if lid.IP6Gateway != nil && *lid.IP6Gateway != "" {
+		t.Error("Unexpectedly non-empty IPv6 Gateway")
+	}
+}
+
+func TestInterfaceInfoToLegacyInterfaces(t *testing.T) {
+	var mtu uint64 = 9000
+	ipv4Gateway := "192.0.2.2"
+	ipv6Gateway := "2001:DB8::2"
+
+	cases := map[string]interfaceTest{
+		"single interface, IPv4 only, no gateway, MTU, or netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: "",
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         nil,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, IPv4 only, no gateway or netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: "",
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, IPv4 only, no netmask": interfaceTest{ // Final Destination
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, IPv4 only": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, no gateway, MTU, or netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: "",
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         nil,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: ipv6Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        &ipv6Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, extra IP addresses": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: ipv6Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.1/5",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::2",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        &ipv6Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "192.0.2.2/20",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+				},
+			},
+		},
+		"multiple interfaces, IPv4 only, no netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.1",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			Interfaces: []ServerInterfaceInfo{
+				{
+					IPAddresses: []ServerIPAddress{
+						{
+							Address:        "192.0.2.1",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+					},
+					MaxBandwidth: nil,
+					Monitor:      true,
+					MTU:          &mtu,
+					Name:         "test",
+				},
+				{
+					IPAddresses: []ServerIPAddress{
+						{
+							Address:        "192.0.2.2",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+					MaxBandwidth: nil,
+					Monitor:      false,
+					MTU:          &mtu,
+					Name:         "invalid",
+				},
+			},
+		},
+		"multiple interfaces": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: ipv6Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "invalid1",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.1/5",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::2",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+				},
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        &ipv6Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "invalid2",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.2/7",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::3/12",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for description, test := range cases {
+		t.Run(description, func(t *testing.T) { testInfs(test, t) })
+	}
+}
+
+func TestServer_ToNullable(t *testing.T) {
+	fqdn := "testFQDN"
+	srv := Server{
+		Cachegroup:       "testCachegroup",
+		CachegroupID:     42,
+		CDNID:            43,
+		CDNName:          "testCDNName",
+		DeliveryServices: map[string][]string{"test": []string{"quest"}},
+		DomainName:       "testDomainName",
+		FQDN:             &fqdn,
+		FqdnTime:         time.Now(),
+		GUID:             "testGUID",
+		HostName:         "testHostName",
+		HTTPSPort:        -1,
+		ID:               44,
+		ILOIPAddress:     "testILOIPAddress",
+		ILOIPGateway:     "testILOIPGateway",
+		ILOIPNetmask:     "testILOIPNetmask",
+		ILOPassword:      "testILOPassword",
+		ILOUsername:      "testILOUsername",
+		InterfaceMtu:     -2,
+		InterfaceName:    "testInterfaceName",
+		IP6Address:       "testIP6Address",
+		IP6IsService:     true,
+		IP6Gateway:       "testIP6Gateway",
+		IPAddress:        "testIPAddress",
+		IPIsService:      false,
+		IPGateway:        "testIPGateway",
+		IPNetmask:        "testIPNetmask",
+		LastUpdated:      TimeNoMod(Time{Time: time.Now().Add(time.Minute), Valid: true}),
+		MgmtIPAddress:    "testMgmtIPAddress",
+		MgmtIPGateway:    "testMgmtIPGateway",
+		MgmtIPNetmask:    "testMgmtIPNetmask",
+		OfflineReason:    "testOfflineReason",
+		PhysLocation:     "testPhysLocation",
+		PhysLocationID:   45,
+		Profile:          "testProfile",
+		ProfileDesc:      "testProfileDesc",
+		ProfileID:        46,
+		Rack:             "testRack",
+		RevalPending:     true,
+		RouterHostName:   "testRouterHostName",
+		RouterPortName:   "testRouterPortName",
+		Status:           "testStatus",
+		StatusID:         47,
+		TCPPort:          -3,
+		Type:             "testType",
+		TypeID:           48,
+		UpdPending:       false,
+		XMPPID:           "testXMPPID",
+		XMPPPasswd:       "testXMPPasswd",
+	}
+
+	nullable := srv.ToNullable()
+
+	if nullable.Cachegroup == nil {
+		t.Error("nullable conversion gave nil Cachegroup")
+	} else if *nullable.Cachegroup != srv.Cachegroup {
+		t.Errorf("Incorrect Cachegroup after nullable conversion; want: '%s', got: '%s'", srv.Cachegroup, *nullable.Cachegroup)
+	}
+
+	if nullable.CachegroupID == nil {
+		t.Error("nullable conversion gave nil CachegroupID")
+	} else if *nullable.CachegroupID != srv.CachegroupID {
+		t.Errorf("Incorrect CachegroupID after nullable conversion; want: %d, got: %d", srv.CachegroupID, *nullable.CachegroupID)
+	}
+
+	if nullable.CDNID == nil {
+		t.Error("nullable conversion gave nil CDNID")
+	} else if *nullable.CDNID != srv.CDNID {
+		t.Errorf("Incorrect CDNID after nullable conversion; want: %d, got: %d", srv.CDNID, *nullable.CDNID)
+	}
+
+	if nullable.CDNName == nil {
+		t.Error("nullable conversion gave nil CDNName")
+	} else if *nullable.CDNName != srv.CDNName {
+		t.Errorf("Incorrect CDNName after nullable conversion; want: '%s', got: '%s'", srv.CDNName, *nullable.CDNName)
+	}
+
+	if nullable.DeliveryServices == nil {
+		t.Error("nullable conversion gave nil DeliveryServices")
+	} else if len(*nullable.DeliveryServices) != len(srv.DeliveryServices) {
+		t.Errorf("Incorrect number of DeliveryServices after nullable conversion; want: %d, got: %d", len(srv.DeliveryServices), len(*nullable.DeliveryServices))
+	} else {
+		for k, v := range srv.DeliveryServices {
+			nullableV, ok := (*nullable.DeliveryServices)[k]
+			if !ok {
+				t.Errorf("Missing Delivery Service '%s' after nullable conversion", k)
+				continue
+			}
+			if len(nullableV) != len(v) {
+				t.Errorf("Delivery Service '%s' has incorrect length after nullable conversion; want: %d, got: %d", k, len(v), len(nullableV))
+			}
+			for i, ds := range v {
+				nullableDS := nullableV[i]
+				if nullableDS != ds {
+					t.Errorf("Incorrect value at position %d in Delivery Service '%s' after nullable conversion; want: '%s', got: '%s'", i, k, ds, nullableDS)
+				}
+			}
+		}
+	}
+
+	if nullable.DomainName == nil {
+		t.Error("nullable conversion gave nil DomainName")
+	} else if *nullable.DomainName != srv.DomainName {
+		t.Errorf("Incorrect DomainName after nullable conversion; want: '%s', got: '%s'", srv.DomainName, *nullable.DomainName)
+	}
+
+	if nullable.FQDN == nil {
+		t.Error("nullable conversion gave nil FQDN")
+	} else if *nullable.FQDN != fqdn {
+		t.Errorf("Incorrect FQDN after nullable conversion; want: '%s', got: '%s'", fqdn, *nullable.FQDN)
+	}
+
+	if nullable.FqdnTime != srv.FqdnTime {
+		t.Errorf("Incorrect FqdnTime after nullable conversion; want: '%s', got: '%s'", srv.FqdnTime, nullable.FqdnTime)
+	}
+
+	if nullable.GUID == nil {
+		t.Error("nullable conversion gave nil GUID")
+	} else if *nullable.GUID != srv.GUID {
+		t.Errorf("Incorrect GUID after nullable conversion; want: '%s', got: '%s'", srv.GUID, *nullable.GUID)
+	}
+
+	if nullable.HostName == nil {
+		t.Error("nullable conversion gave nil HostName")
+	} else if *nullable.HostName != srv.HostName {
+		t.Errorf("Incorrect HostName after nullable conversion; want: '%s', got: '%s'", srv.HostName, *nullable.HostName)
+	}
+
+	if nullable.HTTPSPort == nil {
+		t.Error("nullable conversion gave nil HTTPSPort")
+	} else if *nullable.HTTPSPort != srv.HTTPSPort {
+		t.Errorf("Incorrect HTTPSPort after nullable conversion; want: %d, got: %d", srv.HTTPSPort, *nullable.HTTPSPort)
+	}
+
+	if nullable.ID == nil {
+		t.Error("nullable conversion gave nil ID")
+	} else if *nullable.ID != srv.ID {
+		t.Errorf("Incorrect ID after nullable conversion; want: %d, got: %d", srv.ID, *nullable.ID)
+	}
+
+	if nullable.ILOIPAddress == nil {
+		t.Error("nullable conversion gave nil ILOIPAddress")
+	} else if *nullable.ILOIPAddress != srv.ILOIPAddress {
+		t.Errorf("Incorrect ILOIPAddress after nullable conversion; want: '%s', got: '%s'", srv.ILOIPAddress, *nullable.ILOIPAddress)
+	}
+
+	if nullable.ILOIPGateway == nil {
+		t.Error("nullable conversion gave nil ILOIPGateway")
+	} else if *nullable.ILOIPGateway != srv.ILOIPGateway {
+		t.Errorf("Incorrect ILOIPGateway after nullable conversion; want: '%s', got: '%s'", srv.ILOIPGateway, *nullable.ILOIPGateway)
+	}
+
+	if nullable.ILOIPNetmask == nil {
+		t.Error("nullable conversion gave nil ILOIPNetmask")
+	} else if *nullable.ILOIPNetmask != srv.ILOIPNetmask {
+		t.Errorf("Incorrect ILOIPNetmask after nullable conversion; want: '%s', got: '%s'", srv.ILOIPNetmask, *nullable.ILOIPNetmask)
+	}
+
+	if nullable.ILOPassword == nil {
+		t.Error("nullable conversion gave nil ILOPassword")
+	} else if *nullable.ILOPassword != srv.ILOPassword {
+		t.Errorf("Incorrect ILOPassword after nullable conversion; want: '%s', got: '%s'", srv.ILOPassword, *nullable.ILOPassword)
+	}
+
+	if nullable.ILOUsername == nil {
+		t.Error("nullable conversion gave nil ILOUsername")
+	} else if *nullable.ILOUsername != srv.ILOUsername {
+		t.Errorf("Incorrect ILOUsername after nullable conversion; want: '%s', got: '%s'", srv.ILOUsername, *nullable.ILOUsername)
+	}
+
+	if nullable.InterfaceMtu == nil {
+		t.Error("nullable conversion gave nil InterfaceMtu")
+	} else if *nullable.InterfaceMtu != srv.InterfaceMtu {
+		t.Errorf("Incorrect InterfaceMtu after nullable conversion; want: %d, got: %d", srv.InterfaceMtu, *nullable.InterfaceMtu)
+	}
+
+	if nullable.InterfaceName == nil {
+		t.Error("nullable conversion gave nil InterfaceName")
+	} else if *nullable.InterfaceName != srv.InterfaceName {
+		t.Errorf("Incorrect InterfaceName after nullable conversion; want: '%s', got: '%s'", srv.InterfaceName, *nullable.InterfaceName)
+	}
+
+	if nullable.IP6Address == nil {
+		t.Error("nullable conversion gave nil IP6Address")
+	} else if *nullable.IP6Address != srv.IP6Address {
+		t.Errorf("Incorrect IP6Address after nullable conversion; want: '%s', got: '%s'", srv.IP6Address, *nullable.IP6Address)
+	}
+
+	if nullable.IP6IsService == nil {
+		t.Error("nullable conversion gave nil IP6IsService")
+	} else if *nullable.IP6IsService != srv.IP6IsService {
+		t.Errorf("Incorrect IP6IsService after nullable conversion; want: %t, got: %t", srv.IP6IsService, *nullable.IP6IsService)
+	}
+
+	if nullable.IP6Gateway == nil {
+		t.Error("nullable conversion gave nil IP6Gateway")
+	} else if *nullable.IP6Gateway != srv.IP6Gateway {
+		t.Errorf("Incorrect IP6Gateway after nullable conversion; want: '%s', got: '%s'", srv.IP6Gateway, *nullable.IP6Gateway)
+	}
+
+	if nullable.IPAddress == nil {
+		t.Error("nullable conversion gave nil IPAddress")
+	} else if *nullable.IPAddress != srv.IPAddress {
+		t.Errorf("Incorrect IPAddress after nullable conversion; want: '%s', got: '%s'", srv.IPAddress, *nullable.IPAddress)
+	}
+
+	if nullable.IPIsService == nil {
+		t.Error("nullable conversion gave nil IPIsService")
+	} else if *nullable.IPIsService != srv.IPIsService {
+		t.Errorf("Incorrect IPIsService after nullable conversion; want: %t, got: %t", srv.IPIsService, *nullable.IPIsService)
+	}
+
+	if nullable.IPGateway == nil {
+		t.Error("nullable conversion gave nil IPGateway")
+	} else if *nullable.IPGateway != srv.IPGateway {
+		t.Errorf("Incorrect IPGateway after nullable conversion; want: '%s', got: '%s'", srv.IPGateway, *nullable.IPGateway)
+	}
+
+	if nullable.IPNetmask == nil {
+		t.Error("nullable conversion gave nil IPNetmask")
+	} else if *nullable.IPNetmask != srv.IPNetmask {
+		t.Errorf("Incorrect IPNetmask after nullable conversion; want: '%s', got: '%s'", srv.IPNetmask, *nullable.IPNetmask)
+	}
+
+	if nullable.LastUpdated == nil {
+		t.Error("nullable conversion gave nil LastUpdated")
+	} else if *nullable.LastUpdated != srv.LastUpdated {
+		t.Errorf("Incorrect LastUpdated after nullable conversion; want: '%s', got: '%s'", srv.LastUpdated, *nullable.LastUpdated)
+	}
+
+	if nullable.MgmtIPAddress == nil {
+		t.Error("nullable conversion gave nil MgmtIPAddress")
+	} else if *nullable.MgmtIPAddress != srv.MgmtIPAddress {
+		t.Errorf("Incorrect MgmtIPAddress after nullable conversion; want: '%s', got: '%s'", srv.MgmtIPAddress, *nullable.MgmtIPAddress)
+	}
+
+	if nullable.MgmtIPGateway == nil {
+		t.Error("nullable conversion gave nil MgmtIPGateway")
+	} else if *nullable.MgmtIPGateway != srv.MgmtIPGateway {
+		t.Errorf("Incorrect MgmtIPGateway after nullable conversion; want: '%s', got: '%s'", srv.MgmtIPGateway, *nullable.MgmtIPGateway)
+	}
+
+	if nullable.MgmtIPNetmask == nil {
+		t.Error("nullable conversion gave nil MgmtIPNetmask")
+	} else if *nullable.MgmtIPNetmask != srv.MgmtIPNetmask {
+		t.Errorf("Incorrect MgmtIPNetmask after nullable conversion; want: '%s', got: '%s'", srv.MgmtIPNetmask, *nullable.MgmtIPNetmask)
+	}
+
+	if nullable.OfflineReason == nil {
+		t.Error("nullable conversion gave nil OfflineReason")
+	} else if *nullable.OfflineReason != srv.OfflineReason {
+		t.Errorf("Incorrect OfflineReason after nullable conversion; want: '%s', got: '%s'", srv.OfflineReason, *nullable.OfflineReason)
+	}
+
+	if nullable.PhysLocation == nil {
+		t.Error("nullable conversion gave nil PhysLocation")
+	} else if *nullable.PhysLocation != srv.PhysLocation {
+		t.Errorf("Incorrect PhysLocation after nullable conversion; want: '%s', got: '%s'", srv.PhysLocation, *nullable.PhysLocation)
+	}
+
+	if nullable.PhysLocationID == nil {
+		t.Error("nullable conversion gave nil PhysLocationID")
+	} else if *nullable.PhysLocationID != srv.PhysLocationID {
+		t.Errorf("Incorrect PhysLocationID after nullable conversion; want: %d, got: %d", srv.PhysLocationID, *nullable.PhysLocationID)
+	}
+
+	if nullable.Profile == nil {
+		t.Error("nullable conversion gave nil Profile")
+	} else if *nullable.Profile != srv.Profile {
+		t.Errorf("Incorrect Profile after nullable conversion; want: '%s', got: '%s'", srv.Profile, *nullable.Profile)
+	}
+
+	if nullable.ProfileDesc == nil {
+		t.Error("nullable conversion gave nil ProfileDesc")
+	} else if *nullable.ProfileDesc != srv.ProfileDesc {
+		t.Errorf("Incorrect ProfileDesc after nullable conversion; want: '%s', got: '%s'", srv.ProfileDesc, *nullable.ProfileDesc)
+	}
+
+	if nullable.ProfileID == nil {
+		t.Error("nullable conversion gave nil ProfileID")
+	} else if *nullable.ProfileID != srv.ProfileID {
+		t.Errorf("Incorrect ProfileID after nullable conversion; want: %d, got: %d", srv.ProfileID, *nullable.ProfileID)
+	}
+
+	if nullable.Rack == nil {
+		t.Error("nullable conversion gave nil Rack")
+	} else if *nullable.Rack != srv.Rack {
+		t.Errorf("Incorrect Rack after nullable conversion; want: '%s', got: '%s'", srv.Rack, *nullable.Rack)
+	}
+
+	if nullable.RevalPending == nil {
+		t.Error("nullable conversion gave nil RevalPending")
+	} else if *nullable.RevalPending != srv.RevalPending {
+		t.Errorf("Incorrect RevalPending after nullable conversion; want: %t, got: %t", srv.RevalPending, *nullable.RevalPending)
+	}
+
+	if nullable.RouterHostName == nil {
+		t.Error("nullable conversion gave nil RouterHostName")
+	} else if *nullable.RouterHostName != srv.RouterHostName {
+		t.Errorf("Incorrect RouterHostName after nullable conversion; want: '%s', got: '%s'", srv.RouterHostName, *nullable.RouterHostName)
+	}
+
+	if nullable.RouterPortName == nil {
+		t.Error("nullable conversion gave nil RouterPortName")
+	} else if *nullable.RouterPortName != srv.RouterPortName {
+		t.Errorf("Incorrect RouterPortName after nullable conversion; want: '%s', got: '%s'", srv.RouterPortName, *nullable.RouterPortName)
+	}
+
+	if nullable.Status == nil {
+		t.Error("nullable conversion gave nil Status")
+	} else if *nullable.Status != srv.Status {
+		t.Errorf("Incorrect Status after nullable conversion; want: '%s', got: '%s'", srv.Status, *nullable.Status)
+	}
+
+	if nullable.StatusID == nil {
+		t.Error("nullable conversion gave nil StatusID")
+	} else if *nullable.StatusID != srv.StatusID {
+		t.Errorf("Incorrect StatusID after nullable conversion; want: %d, got: %d", srv.StatusID, *nullable.StatusID)
+	}
+
+	if nullable.TCPPort == nil {
+		t.Error("nullable conversion gave nil TCPPort")
+	} else if *nullable.TCPPort != srv.TCPPort {
+		t.Errorf("Incorrect TCPPort after nullable conversion; want: %d, got: %d", srv.TCPPort, *nullable.TCPPort)
+	}
+
+	if nullable.Type != srv.Type {
+		t.Errorf("Incorrect Type after nullable conversion; want: '%s', got: '%s'", srv.Type, nullable.Type)
+	}
+
+	if nullable.TypeID == nil {
+		t.Error("nullable conversion gave nil TypeID")
+	} else if *nullable.TypeID != srv.TypeID {
+		t.Errorf("Incorrect TypeID after nullable conversion; want: %d, got: %d", srv.TypeID, *nullable.TypeID)
+	}
+
+	if nullable.UpdPending == nil {
+		t.Error("nullable conversion gave nil UpdPending")
+	} else if *nullable.UpdPending != srv.UpdPending {
+		t.Errorf("Incorrect UpdPending after nullable conversion; want: %t, got: %t", srv.UpdPending, *nullable.UpdPending)
+	}
+
+	if nullable.XMPPID == nil {
+		t.Error("nullable conversion gave nil XMPPID")
+	} else if *nullable.XMPPID != srv.XMPPID {
+		t.Errorf("Incorrect XMPPID after nullable conversion; want: '%s', got: '%s'", srv.XMPPID, *nullable.XMPPID)
+	}
+
+	if nullable.XMPPPasswd == nil {
+		t.Error("nullable conversion gave nil XMPPPasswd")
+	} else if *nullable.XMPPPasswd != srv.XMPPPasswd {
+		t.Errorf("Incorrect XMPPPasswd after nullable conversion; want: '%s', got: '%s'", srv.XMPPPasswd, *nullable.XMPPPasswd)
+	}
+}
+
+func TestServerNullableV2_Upgrade(t *testing.T) {
+	fqdn := "testFQDN"
+	srv := Server{

Review comment:
       just to save lines should we move this and the one above into just a `getTestServer`? not important at all just an idea. we could even combine these into one test probably and at least get rid of the duplicated nil checks

##########
File path: lib/go-tc/servers.go
##########
@@ -97,6 +99,30 @@ type ServerInterfaceInfo struct {
 	Name         string            `json:"name" db:"name"`
 }
 
+// GetDefaultAddress returns the IPv4 and IPv6 service addresses of the interface.
+func (i *ServerInterfaceInfo) GetDefaultAddress() (string, string) {
+	var ipv4 string
+	var ipv6 string
+	for _, ip := range i.IPAddresses {
+		if ip.ServiceAddress {
+			address, _, err := net.ParseCIDR(ip.Address)
+			if err != nil || address == nil {
+				continue

Review comment:
       should we at least log this error if one occurs?

##########
File path: traffic_monitor/towrap/towrap.go
##########
@@ -365,8 +491,9 @@ func (s TrafficOpsSessionThreadsafe) trafficMonitorConfigMapRaw(cdn string) (*tc
 	return configMap, err
 }
 
-// LegacyTrafficMonitorConfigMap returns the Traffic Monitor config map from the Traffic Ops. This is safe for multiple goroutines.
-func (s TrafficOpsSessionThreadsafe) TrafficMonitorConfigMap(cdn string) (*tc.LegacyTrafficMonitorConfigMap, error) {
+// LegacyTrafficMonitorConfigMap returns the Traffic Monitor config map from the

Review comment:
       Doc function name isnt the same as the function name

##########
File path: lib/go-tc/servers_test.go
##########
@@ -56,3 +59,1323 @@ func ExampleLegacyInterfaceDetails_ToInterfaces() {
 	// 	addr=::14/64, gateway=::15, service address=false
 	//
 }
+
+func ExampleLegacyInterfaceDetails_String() {
+	ipv4 := "192.0.2.0"
+	ipv6 := "2001:DB8::/64"
+	name := "test"
+	mtu := 9000
+
+	lid := LegacyInterfaceDetails{
+		InterfaceMtu:  &mtu,
+		InterfaceName: &name,
+		IP6Address:    &ipv6,
+		IP6Gateway:    nil,
+		IPAddress:     &ipv4,
+		IPGateway:     nil,
+		IPNetmask:     nil,
+	}
+
+	fmt.Println(lid.String())
+
+	// Output: LegacyInterfaceDetails(InterfaceMtu=9000, InterfaceName='test', IP6Address='2001:DB8::/64', IP6Gateway=nil, IPAddress='192.0.2.0', IPGateway=nil, IPNetmask=nil)
+}
+
+type interfaceTest struct {
+	ExpectedIPv4        string
+	ExpectedIPv4Gateway string
+	ExpectedIPv6        string
+	ExpectedIPv6Gateway string
+	ExpectedMTU         *uint64
+	ExpectedName        string
+	ExpectedNetmask     string
+	Interfaces          []ServerInterfaceInfo
+}
+
+// tests a set of interfaces' conversion to legacy format against expected
+// values.
+// Note: This doesn't distinguish between nil and pointer-to-empty-string values
+// when a value is not expected. That's because all ATC components treat null
+// and empty-string values the same, so it's not important which is returned by
+// the conversion process (and in fact expecting one or the other could
+// potentially break some applications).
+func testInfs(expected interfaceTest, t *testing.T) {
+	lid, err := InterfaceInfoToLegacyInterfaces(expected.Interfaces)
+	if err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if lid.InterfaceName == nil {
+		t.Error("Unexpectedly nil Interface Name")
+	} else if *lid.InterfaceName != expected.ExpectedName {
+		t.Errorf("Incorrect Interface Name; want: '%s', got: '%s'", expected.ExpectedName, *lid.InterfaceName)
+	}
+
+	if expected.ExpectedMTU != nil {
+		if lid.InterfaceMtu == nil {
+			t.Error("Unexpectedly nil Interface MTU")
+		} else if uint64(*lid.InterfaceMtu) != *expected.ExpectedMTU {
+			t.Errorf("Incorrect Interface MTU; want: %d, got: %d", *expected.ExpectedMTU, *lid.InterfaceMtu)
+		}
+	} else if lid.InterfaceMtu != nil {
+		t.Error("Unexpectedly non-nil Interface MTU")
+	}
+
+	if expected.ExpectedIPv4 != "" {
+		if lid.IPAddress == nil {
+			t.Error("Unexpectedly nil IPv4 Address")
+		} else if *lid.IPAddress != expected.ExpectedIPv4 {
+			t.Errorf("Incorrect IPv4 Address; want: '%s', got: '%s'", expected.ExpectedIPv4, *lid.IPAddress)
+		}
+	} else if lid.IPAddress != nil && *lid.IPAddress != "" {
+		t.Error("Unexpectedly non-empty IPv4 Address")
+	}
+
+	if expected.ExpectedIPv4Gateway != "" {
+		if lid.IPGateway == nil {
+			t.Error("Unexpectedly nil IPv4 Gateway")
+		} else if *lid.IPGateway != expected.ExpectedIPv4Gateway {
+			t.Errorf("Incorrect IPv4 Gateway; want: '%s', got: '%s'", expected.ExpectedIPv4Gateway, *lid.IPGateway)
+		}
+	} else if lid.IPGateway != nil && *lid.IPGateway != "" {
+		t.Error("Unexpectedly non-empty IPv4 Gateway")
+	}
+
+	if expected.ExpectedNetmask != "" {
+		if lid.IPNetmask == nil {
+			t.Error("Unexpectedly nil IPv4 Netmask")
+		} else if *lid.IPNetmask != expected.ExpectedNetmask {
+			t.Errorf("Incorrect IPv4 Netmask; want: '%s', got: '%s'", expected.ExpectedNetmask, *lid.IPNetmask)
+		}
+	} else if lid.IPNetmask != nil && *lid.IPNetmask != "" {
+		t.Error("Unexpectedly non-empty IPv4 Netmask")
+	}
+
+	if expected.ExpectedIPv6 != "" {
+		if lid.IP6Address == nil {
+			t.Error("Unexpectedly nil IPv6 Address")
+		} else if *lid.IP6Address != expected.ExpectedIPv6 {
+			t.Errorf("Incorrect IPv6 Address; want: '%s', got: '%s'", expected.ExpectedIPv6, *lid.IP6Address)
+		}
+	} else if lid.IP6Address != nil && *lid.IP6Address != "" {
+		t.Error("Unexpectedly non-empty IPv6 Address")
+	}
+
+	if expected.ExpectedIPv6Gateway != "" {
+		if lid.IP6Gateway == nil {
+			t.Error("Unexpectedly nil IPv6 Gateway")
+		} else if *lid.IP6Gateway != expected.ExpectedIPv6Gateway {
+			t.Errorf("Incorrect IPv6 Gateway; want: '%s', got: '%s'", expected.ExpectedIPv6Gateway, *lid.IP6Gateway)
+		}
+	} else if lid.IP6Gateway != nil && *lid.IP6Gateway != "" {
+		t.Error("Unexpectedly non-empty IPv6 Gateway")
+	}
+}
+
+func TestInterfaceInfoToLegacyInterfaces(t *testing.T) {
+	var mtu uint64 = 9000
+	ipv4Gateway := "192.0.2.2"
+	ipv6Gateway := "2001:DB8::2"
+
+	cases := map[string]interfaceTest{
+		"single interface, IPv4 only, no gateway, MTU, or netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: "",
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         nil,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, IPv4 only, no gateway or netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: "",
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, IPv4 only, no netmask": interfaceTest{ // Final Destination
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, IPv4 only": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, no gateway, MTU, or netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: "",
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         nil,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: ipv6Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        &ipv6Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, extra IP addresses": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: ipv6Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.1/5",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::2",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        &ipv6Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "192.0.2.2/20",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+				},
+			},
+		},
+		"multiple interfaces, IPv4 only, no netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.1",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			Interfaces: []ServerInterfaceInfo{
+				{
+					IPAddresses: []ServerIPAddress{
+						{
+							Address:        "192.0.2.1",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+					},
+					MaxBandwidth: nil,
+					Monitor:      true,
+					MTU:          &mtu,
+					Name:         "test",
+				},
+				{
+					IPAddresses: []ServerIPAddress{
+						{
+							Address:        "192.0.2.2",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+					MaxBandwidth: nil,
+					Monitor:      false,
+					MTU:          &mtu,
+					Name:         "invalid",
+				},
+			},
+		},
+		"multiple interfaces": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: ipv6Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "invalid1",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.1/5",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::2",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+				},
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        &ipv6Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "invalid2",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.2/7",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::3/12",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for description, test := range cases {
+		t.Run(description, func(t *testing.T) { testInfs(test, t) })
+	}
+}
+
+func TestServer_ToNullable(t *testing.T) {
+	fqdn := "testFQDN"
+	srv := Server{
+		Cachegroup:       "testCachegroup",
+		CachegroupID:     42,
+		CDNID:            43,
+		CDNName:          "testCDNName",
+		DeliveryServices: map[string][]string{"test": []string{"quest"}},
+		DomainName:       "testDomainName",
+		FQDN:             &fqdn,
+		FqdnTime:         time.Now(),
+		GUID:             "testGUID",
+		HostName:         "testHostName",
+		HTTPSPort:        -1,
+		ID:               44,
+		ILOIPAddress:     "testILOIPAddress",
+		ILOIPGateway:     "testILOIPGateway",
+		ILOIPNetmask:     "testILOIPNetmask",
+		ILOPassword:      "testILOPassword",
+		ILOUsername:      "testILOUsername",
+		InterfaceMtu:     -2,
+		InterfaceName:    "testInterfaceName",
+		IP6Address:       "testIP6Address",
+		IP6IsService:     true,
+		IP6Gateway:       "testIP6Gateway",
+		IPAddress:        "testIPAddress",
+		IPIsService:      false,
+		IPGateway:        "testIPGateway",
+		IPNetmask:        "testIPNetmask",
+		LastUpdated:      TimeNoMod(Time{Time: time.Now().Add(time.Minute), Valid: true}),
+		MgmtIPAddress:    "testMgmtIPAddress",
+		MgmtIPGateway:    "testMgmtIPGateway",
+		MgmtIPNetmask:    "testMgmtIPNetmask",
+		OfflineReason:    "testOfflineReason",
+		PhysLocation:     "testPhysLocation",
+		PhysLocationID:   45,
+		Profile:          "testProfile",
+		ProfileDesc:      "testProfileDesc",
+		ProfileID:        46,
+		Rack:             "testRack",
+		RevalPending:     true,
+		RouterHostName:   "testRouterHostName",
+		RouterPortName:   "testRouterPortName",
+		Status:           "testStatus",
+		StatusID:         47,
+		TCPPort:          -3,
+		Type:             "testType",
+		TypeID:           48,
+		UpdPending:       false,
+		XMPPID:           "testXMPPID",
+		XMPPPasswd:       "testXMPPasswd",
+	}
+
+	nullable := srv.ToNullable()
+
+	if nullable.Cachegroup == nil {
+		t.Error("nullable conversion gave nil Cachegroup")
+	} else if *nullable.Cachegroup != srv.Cachegroup {
+		t.Errorf("Incorrect Cachegroup after nullable conversion; want: '%s', got: '%s'", srv.Cachegroup, *nullable.Cachegroup)
+	}
+
+	if nullable.CachegroupID == nil {
+		t.Error("nullable conversion gave nil CachegroupID")
+	} else if *nullable.CachegroupID != srv.CachegroupID {
+		t.Errorf("Incorrect CachegroupID after nullable conversion; want: %d, got: %d", srv.CachegroupID, *nullable.CachegroupID)
+	}
+
+	if nullable.CDNID == nil {
+		t.Error("nullable conversion gave nil CDNID")
+	} else if *nullable.CDNID != srv.CDNID {
+		t.Errorf("Incorrect CDNID after nullable conversion; want: %d, got: %d", srv.CDNID, *nullable.CDNID)
+	}
+
+	if nullable.CDNName == nil {
+		t.Error("nullable conversion gave nil CDNName")
+	} else if *nullable.CDNName != srv.CDNName {
+		t.Errorf("Incorrect CDNName after nullable conversion; want: '%s', got: '%s'", srv.CDNName, *nullable.CDNName)
+	}
+
+	if nullable.DeliveryServices == nil {
+		t.Error("nullable conversion gave nil DeliveryServices")
+	} else if len(*nullable.DeliveryServices) != len(srv.DeliveryServices) {
+		t.Errorf("Incorrect number of DeliveryServices after nullable conversion; want: %d, got: %d", len(srv.DeliveryServices), len(*nullable.DeliveryServices))
+	} else {
+		for k, v := range srv.DeliveryServices {
+			nullableV, ok := (*nullable.DeliveryServices)[k]
+			if !ok {
+				t.Errorf("Missing Delivery Service '%s' after nullable conversion", k)
+				continue
+			}
+			if len(nullableV) != len(v) {
+				t.Errorf("Delivery Service '%s' has incorrect length after nullable conversion; want: %d, got: %d", k, len(v), len(nullableV))
+			}
+			for i, ds := range v {
+				nullableDS := nullableV[i]
+				if nullableDS != ds {
+					t.Errorf("Incorrect value at position %d in Delivery Service '%s' after nullable conversion; want: '%s', got: '%s'", i, k, ds, nullableDS)
+				}
+			}
+		}
+	}
+
+	if nullable.DomainName == nil {
+		t.Error("nullable conversion gave nil DomainName")
+	} else if *nullable.DomainName != srv.DomainName {
+		t.Errorf("Incorrect DomainName after nullable conversion; want: '%s', got: '%s'", srv.DomainName, *nullable.DomainName)
+	}
+
+	if nullable.FQDN == nil {
+		t.Error("nullable conversion gave nil FQDN")
+	} else if *nullable.FQDN != fqdn {
+		t.Errorf("Incorrect FQDN after nullable conversion; want: '%s', got: '%s'", fqdn, *nullable.FQDN)
+	}
+
+	if nullable.FqdnTime != srv.FqdnTime {
+		t.Errorf("Incorrect FqdnTime after nullable conversion; want: '%s', got: '%s'", srv.FqdnTime, nullable.FqdnTime)
+	}
+
+	if nullable.GUID == nil {
+		t.Error("nullable conversion gave nil GUID")
+	} else if *nullable.GUID != srv.GUID {
+		t.Errorf("Incorrect GUID after nullable conversion; want: '%s', got: '%s'", srv.GUID, *nullable.GUID)
+	}
+
+	if nullable.HostName == nil {
+		t.Error("nullable conversion gave nil HostName")
+	} else if *nullable.HostName != srv.HostName {
+		t.Errorf("Incorrect HostName after nullable conversion; want: '%s', got: '%s'", srv.HostName, *nullable.HostName)
+	}
+
+	if nullable.HTTPSPort == nil {
+		t.Error("nullable conversion gave nil HTTPSPort")
+	} else if *nullable.HTTPSPort != srv.HTTPSPort {
+		t.Errorf("Incorrect HTTPSPort after nullable conversion; want: %d, got: %d", srv.HTTPSPort, *nullable.HTTPSPort)
+	}
+
+	if nullable.ID == nil {
+		t.Error("nullable conversion gave nil ID")
+	} else if *nullable.ID != srv.ID {
+		t.Errorf("Incorrect ID after nullable conversion; want: %d, got: %d", srv.ID, *nullable.ID)
+	}
+
+	if nullable.ILOIPAddress == nil {
+		t.Error("nullable conversion gave nil ILOIPAddress")
+	} else if *nullable.ILOIPAddress != srv.ILOIPAddress {
+		t.Errorf("Incorrect ILOIPAddress after nullable conversion; want: '%s', got: '%s'", srv.ILOIPAddress, *nullable.ILOIPAddress)
+	}
+
+	if nullable.ILOIPGateway == nil {
+		t.Error("nullable conversion gave nil ILOIPGateway")
+	} else if *nullable.ILOIPGateway != srv.ILOIPGateway {
+		t.Errorf("Incorrect ILOIPGateway after nullable conversion; want: '%s', got: '%s'", srv.ILOIPGateway, *nullable.ILOIPGateway)
+	}
+
+	if nullable.ILOIPNetmask == nil {
+		t.Error("nullable conversion gave nil ILOIPNetmask")
+	} else if *nullable.ILOIPNetmask != srv.ILOIPNetmask {
+		t.Errorf("Incorrect ILOIPNetmask after nullable conversion; want: '%s', got: '%s'", srv.ILOIPNetmask, *nullable.ILOIPNetmask)
+	}
+
+	if nullable.ILOPassword == nil {
+		t.Error("nullable conversion gave nil ILOPassword")
+	} else if *nullable.ILOPassword != srv.ILOPassword {
+		t.Errorf("Incorrect ILOPassword after nullable conversion; want: '%s', got: '%s'", srv.ILOPassword, *nullable.ILOPassword)
+	}
+
+	if nullable.ILOUsername == nil {
+		t.Error("nullable conversion gave nil ILOUsername")
+	} else if *nullable.ILOUsername != srv.ILOUsername {
+		t.Errorf("Incorrect ILOUsername after nullable conversion; want: '%s', got: '%s'", srv.ILOUsername, *nullable.ILOUsername)
+	}
+
+	if nullable.InterfaceMtu == nil {
+		t.Error("nullable conversion gave nil InterfaceMtu")
+	} else if *nullable.InterfaceMtu != srv.InterfaceMtu {
+		t.Errorf("Incorrect InterfaceMtu after nullable conversion; want: %d, got: %d", srv.InterfaceMtu, *nullable.InterfaceMtu)
+	}
+
+	if nullable.InterfaceName == nil {
+		t.Error("nullable conversion gave nil InterfaceName")
+	} else if *nullable.InterfaceName != srv.InterfaceName {
+		t.Errorf("Incorrect InterfaceName after nullable conversion; want: '%s', got: '%s'", srv.InterfaceName, *nullable.InterfaceName)
+	}
+
+	if nullable.IP6Address == nil {
+		t.Error("nullable conversion gave nil IP6Address")
+	} else if *nullable.IP6Address != srv.IP6Address {
+		t.Errorf("Incorrect IP6Address after nullable conversion; want: '%s', got: '%s'", srv.IP6Address, *nullable.IP6Address)
+	}
+
+	if nullable.IP6IsService == nil {
+		t.Error("nullable conversion gave nil IP6IsService")
+	} else if *nullable.IP6IsService != srv.IP6IsService {
+		t.Errorf("Incorrect IP6IsService after nullable conversion; want: %t, got: %t", srv.IP6IsService, *nullable.IP6IsService)
+	}
+
+	if nullable.IP6Gateway == nil {
+		t.Error("nullable conversion gave nil IP6Gateway")
+	} else if *nullable.IP6Gateway != srv.IP6Gateway {
+		t.Errorf("Incorrect IP6Gateway after nullable conversion; want: '%s', got: '%s'", srv.IP6Gateway, *nullable.IP6Gateway)
+	}
+
+	if nullable.IPAddress == nil {
+		t.Error("nullable conversion gave nil IPAddress")
+	} else if *nullable.IPAddress != srv.IPAddress {
+		t.Errorf("Incorrect IPAddress after nullable conversion; want: '%s', got: '%s'", srv.IPAddress, *nullable.IPAddress)
+	}
+
+	if nullable.IPIsService == nil {
+		t.Error("nullable conversion gave nil IPIsService")
+	} else if *nullable.IPIsService != srv.IPIsService {
+		t.Errorf("Incorrect IPIsService after nullable conversion; want: %t, got: %t", srv.IPIsService, *nullable.IPIsService)
+	}
+
+	if nullable.IPGateway == nil {
+		t.Error("nullable conversion gave nil IPGateway")
+	} else if *nullable.IPGateway != srv.IPGateway {
+		t.Errorf("Incorrect IPGateway after nullable conversion; want: '%s', got: '%s'", srv.IPGateway, *nullable.IPGateway)
+	}
+
+	if nullable.IPNetmask == nil {
+		t.Error("nullable conversion gave nil IPNetmask")
+	} else if *nullable.IPNetmask != srv.IPNetmask {
+		t.Errorf("Incorrect IPNetmask after nullable conversion; want: '%s', got: '%s'", srv.IPNetmask, *nullable.IPNetmask)
+	}
+
+	if nullable.LastUpdated == nil {
+		t.Error("nullable conversion gave nil LastUpdated")
+	} else if *nullable.LastUpdated != srv.LastUpdated {
+		t.Errorf("Incorrect LastUpdated after nullable conversion; want: '%s', got: '%s'", srv.LastUpdated, *nullable.LastUpdated)
+	}
+
+	if nullable.MgmtIPAddress == nil {
+		t.Error("nullable conversion gave nil MgmtIPAddress")
+	} else if *nullable.MgmtIPAddress != srv.MgmtIPAddress {
+		t.Errorf("Incorrect MgmtIPAddress after nullable conversion; want: '%s', got: '%s'", srv.MgmtIPAddress, *nullable.MgmtIPAddress)
+	}
+
+	if nullable.MgmtIPGateway == nil {
+		t.Error("nullable conversion gave nil MgmtIPGateway")
+	} else if *nullable.MgmtIPGateway != srv.MgmtIPGateway {
+		t.Errorf("Incorrect MgmtIPGateway after nullable conversion; want: '%s', got: '%s'", srv.MgmtIPGateway, *nullable.MgmtIPGateway)
+	}
+
+	if nullable.MgmtIPNetmask == nil {
+		t.Error("nullable conversion gave nil MgmtIPNetmask")
+	} else if *nullable.MgmtIPNetmask != srv.MgmtIPNetmask {
+		t.Errorf("Incorrect MgmtIPNetmask after nullable conversion; want: '%s', got: '%s'", srv.MgmtIPNetmask, *nullable.MgmtIPNetmask)
+	}
+
+	if nullable.OfflineReason == nil {
+		t.Error("nullable conversion gave nil OfflineReason")
+	} else if *nullable.OfflineReason != srv.OfflineReason {
+		t.Errorf("Incorrect OfflineReason after nullable conversion; want: '%s', got: '%s'", srv.OfflineReason, *nullable.OfflineReason)
+	}
+
+	if nullable.PhysLocation == nil {
+		t.Error("nullable conversion gave nil PhysLocation")
+	} else if *nullable.PhysLocation != srv.PhysLocation {
+		t.Errorf("Incorrect PhysLocation after nullable conversion; want: '%s', got: '%s'", srv.PhysLocation, *nullable.PhysLocation)
+	}
+
+	if nullable.PhysLocationID == nil {
+		t.Error("nullable conversion gave nil PhysLocationID")
+	} else if *nullable.PhysLocationID != srv.PhysLocationID {
+		t.Errorf("Incorrect PhysLocationID after nullable conversion; want: %d, got: %d", srv.PhysLocationID, *nullable.PhysLocationID)
+	}
+
+	if nullable.Profile == nil {
+		t.Error("nullable conversion gave nil Profile")
+	} else if *nullable.Profile != srv.Profile {
+		t.Errorf("Incorrect Profile after nullable conversion; want: '%s', got: '%s'", srv.Profile, *nullable.Profile)
+	}
+
+	if nullable.ProfileDesc == nil {
+		t.Error("nullable conversion gave nil ProfileDesc")
+	} else if *nullable.ProfileDesc != srv.ProfileDesc {
+		t.Errorf("Incorrect ProfileDesc after nullable conversion; want: '%s', got: '%s'", srv.ProfileDesc, *nullable.ProfileDesc)
+	}
+
+	if nullable.ProfileID == nil {
+		t.Error("nullable conversion gave nil ProfileID")
+	} else if *nullable.ProfileID != srv.ProfileID {
+		t.Errorf("Incorrect ProfileID after nullable conversion; want: %d, got: %d", srv.ProfileID, *nullable.ProfileID)
+	}
+
+	if nullable.Rack == nil {
+		t.Error("nullable conversion gave nil Rack")
+	} else if *nullable.Rack != srv.Rack {
+		t.Errorf("Incorrect Rack after nullable conversion; want: '%s', got: '%s'", srv.Rack, *nullable.Rack)
+	}
+
+	if nullable.RevalPending == nil {
+		t.Error("nullable conversion gave nil RevalPending")
+	} else if *nullable.RevalPending != srv.RevalPending {
+		t.Errorf("Incorrect RevalPending after nullable conversion; want: %t, got: %t", srv.RevalPending, *nullable.RevalPending)
+	}
+
+	if nullable.RouterHostName == nil {
+		t.Error("nullable conversion gave nil RouterHostName")
+	} else if *nullable.RouterHostName != srv.RouterHostName {
+		t.Errorf("Incorrect RouterHostName after nullable conversion; want: '%s', got: '%s'", srv.RouterHostName, *nullable.RouterHostName)
+	}
+
+	if nullable.RouterPortName == nil {
+		t.Error("nullable conversion gave nil RouterPortName")
+	} else if *nullable.RouterPortName != srv.RouterPortName {
+		t.Errorf("Incorrect RouterPortName after nullable conversion; want: '%s', got: '%s'", srv.RouterPortName, *nullable.RouterPortName)
+	}
+
+	if nullable.Status == nil {
+		t.Error("nullable conversion gave nil Status")
+	} else if *nullable.Status != srv.Status {
+		t.Errorf("Incorrect Status after nullable conversion; want: '%s', got: '%s'", srv.Status, *nullable.Status)
+	}
+
+	if nullable.StatusID == nil {
+		t.Error("nullable conversion gave nil StatusID")
+	} else if *nullable.StatusID != srv.StatusID {
+		t.Errorf("Incorrect StatusID after nullable conversion; want: %d, got: %d", srv.StatusID, *nullable.StatusID)
+	}
+
+	if nullable.TCPPort == nil {
+		t.Error("nullable conversion gave nil TCPPort")
+	} else if *nullable.TCPPort != srv.TCPPort {
+		t.Errorf("Incorrect TCPPort after nullable conversion; want: %d, got: %d", srv.TCPPort, *nullable.TCPPort)
+	}
+
+	if nullable.Type != srv.Type {
+		t.Errorf("Incorrect Type after nullable conversion; want: '%s', got: '%s'", srv.Type, nullable.Type)
+	}
+
+	if nullable.TypeID == nil {
+		t.Error("nullable conversion gave nil TypeID")
+	} else if *nullable.TypeID != srv.TypeID {
+		t.Errorf("Incorrect TypeID after nullable conversion; want: %d, got: %d", srv.TypeID, *nullable.TypeID)
+	}
+
+	if nullable.UpdPending == nil {
+		t.Error("nullable conversion gave nil UpdPending")
+	} else if *nullable.UpdPending != srv.UpdPending {
+		t.Errorf("Incorrect UpdPending after nullable conversion; want: %t, got: %t", srv.UpdPending, *nullable.UpdPending)
+	}
+
+	if nullable.XMPPID == nil {
+		t.Error("nullable conversion gave nil XMPPID")
+	} else if *nullable.XMPPID != srv.XMPPID {
+		t.Errorf("Incorrect XMPPID after nullable conversion; want: '%s', got: '%s'", srv.XMPPID, *nullable.XMPPID)
+	}
+
+	if nullable.XMPPPasswd == nil {
+		t.Error("nullable conversion gave nil XMPPPasswd")
+	} else if *nullable.XMPPPasswd != srv.XMPPPasswd {
+		t.Errorf("Incorrect XMPPPasswd after nullable conversion; want: '%s', got: '%s'", srv.XMPPPasswd, *nullable.XMPPPasswd)
+	}
+}
+
+func TestServerNullableV2_Upgrade(t *testing.T) {
+	fqdn := "testFQDN"
+	srv := Server{
+		Cachegroup:       "testCachegroup",
+		CachegroupID:     42,
+		CDNID:            43,
+		CDNName:          "testCDNName",
+		DeliveryServices: map[string][]string{"test": []string{"quest"}},
+		DomainName:       "testDomainName",
+		FQDN:             &fqdn,
+		FqdnTime:         time.Now(),
+		GUID:             "testGUID",
+		HostName:         "testHostName",
+		HTTPSPort:        -1,
+		ID:               44,
+		ILOIPAddress:     "testILOIPAddress",
+		ILOIPGateway:     "testILOIPGateway",
+		ILOIPNetmask:     "testILOIPNetmask",
+		ILOPassword:      "testILOPassword",
+		ILOUsername:      "testILOUsername",
+		InterfaceMtu:     2,
+		InterfaceName:    "testInterfaceName",
+		IP6Address:       "::1/64",
+		IP6IsService:     true,
+		IP6Gateway:       "::2",
+		IPAddress:        "0.0.0.1",
+		IPIsService:      false,
+		IPGateway:        "0.0.0.2",
+		IPNetmask:        "255.255.255.0",
+		LastUpdated:      TimeNoMod(Time{Time: time.Now().Add(time.Minute), Valid: true}),
+		MgmtIPAddress:    "testMgmtIPAddress",
+		MgmtIPGateway:    "testMgmtIPGateway",
+		MgmtIPNetmask:    "testMgmtIPNetmask",
+		OfflineReason:    "testOfflineReason",
+		PhysLocation:     "testPhysLocation",
+		PhysLocationID:   45,
+		Profile:          "testProfile",
+		ProfileDesc:      "testProfileDesc",
+		ProfileID:        46,
+		Rack:             "testRack",
+		RevalPending:     true,
+		RouterHostName:   "testRouterHostName",
+		RouterPortName:   "testRouterPortName",
+		Status:           "testStatus",
+		StatusID:         47,
+		TCPPort:          -3,
+		Type:             "testType",
+		TypeID:           48,
+		UpdPending:       false,
+		XMPPID:           "testXMPPID",
+		XMPPPasswd:       "testXMPPasswd",
+	}
+
+	// this is so much easier than double the lines to manually construct a
+	// nullable v2 server
+	nullable := srv.ToNullable()
+
+	upgraded, err := nullable.Upgrade()
+	if err != nil {
+		t.Fatalf("Unexpected error upgrading server: %v", err)
+	}
+
+	if nullable.Cachegroup == nil {
+		t.Error("Unexpectedly nil Cachegroup in nullable-converted server")
+	} else if upgraded.Cachegroup == nil {
+		t.Error("upgraded conversion gave nil Cachegroup")
+	} else if *upgraded.Cachegroup != *nullable.Cachegroup {
+		t.Errorf("Incorrect Cachegroup after upgraded conversion; want: '%s', got: '%s'", *nullable.Cachegroup, *upgraded.Cachegroup)
+	}
+
+	if nullable.CachegroupID == nil {
+		t.Error("Unexpectedly nil CachegroupID in nullable-converted server")
+	} else if upgraded.CachegroupID == nil {
+		t.Error("upgraded conversion gave nil CachegroupID")
+	} else if *upgraded.CachegroupID != *nullable.CachegroupID {
+		t.Errorf("Incorrect CachegroupID after upgraded conversion; want: %d, got: %d", *nullable.CachegroupID, *upgraded.CachegroupID)
+	}
+
+	if nullable.CDNID == nil {
+		t.Error("Unexpectedly nil CDNID in nullable-converted server")
+	} else if upgraded.CDNID == nil {
+		t.Error("upgraded conversion gave nil CDNID")
+	} else if *upgraded.CDNID != *nullable.CDNID {
+		t.Errorf("Incorrect CDNID after upgraded conversion; want: %d, got: %d", *nullable.CDNID, *upgraded.CDNID)
+	}
+
+	if nullable.CDNName == nil {
+		t.Error("Unexpectedly nil CDNName in nullable-converted server")
+	} else if upgraded.CDNName == nil {
+		t.Error("upgraded conversion gave nil CDNName")
+	} else if *upgraded.CDNName != *nullable.CDNName {
+		t.Errorf("Incorrect CDNName after upgraded conversion; want: '%s', got: '%s'", *nullable.CDNName, *upgraded.CDNName)
+	}
+
+	if nullable.DeliveryServices == nil {
+		t.Error("Unexpectedly nil DeliveryServices in nullable-converted server")
+	} else if upgraded.DeliveryServices == nil {
+		t.Error("upgraded conversion gave nil DeliveryServices")
+	} else if len(*upgraded.DeliveryServices) != len(*nullable.DeliveryServices) {
+		t.Errorf("Incorrect number of DeliveryServices after upgraded conversion; want: %d, got: %d", len(*nullable.DeliveryServices), len(*upgraded.DeliveryServices))
+	} else {
+		for k, v := range *nullable.DeliveryServices {
+			upgradedV, ok := (*upgraded.DeliveryServices)[k]
+			if !ok {
+				t.Errorf("Missing Delivery Service '%s' after upgraded conversion", k)
+				continue
+			}
+			if len(upgradedV) != len(v) {
+				t.Errorf("Delivery Service '%s' has incorrect length after upgraded conversion; want: %d, got: %d", k, len(v), len(upgradedV))
+			}
+			for i, ds := range v {
+				upgradedDS := upgradedV[i]
+				if upgradedDS != ds {
+					t.Errorf("Incorrect value at position %d in Delivery Service '%s' after upgraded conversion; want: '%s', got: '%s'", i, k, ds, upgradedDS)
+				}
+			}
+		}
+	}
+
+	if nullable.DomainName == nil {
+		t.Error("Unexpectedly nil DomainName in nullable-converted server")
+	} else if upgraded.DomainName == nil {
+		t.Error("upgraded conversion gave nil DomainName")
+	} else if *upgraded.DomainName != *nullable.DomainName {
+		t.Errorf("Incorrect DomainName after upgraded conversion; want: '%s', got: '%s'", *nullable.DomainName, *upgraded.DomainName)
+	}
+
+	if nullable.FQDN == nil {
+		t.Error("Unexpectedly nil FQDN in nullable-converted server")
+	} else if upgraded.FQDN == nil {
+		t.Error("upgraded conversion gave nil FQDN")
+	} else if *upgraded.FQDN != fqdn {
+		t.Errorf("Incorrect FQDN after upgraded conversion; want: '%s', got: '%s'", fqdn, *upgraded.FQDN)
+	}
+
+	if upgraded.FqdnTime != nullable.FqdnTime {
+		t.Errorf("Incorrect FqdnTime after upgraded conversion; want: '%s', got: '%s'", nullable.FqdnTime, upgraded.FqdnTime)
+	}
+
+	if nullable.GUID == nil {
+		t.Error("Unexpectedly nil GUID in nullable-converted server")
+	} else if upgraded.GUID == nil {
+		t.Error("upgraded conversion gave nil GUID")
+	} else if *upgraded.GUID != *nullable.GUID {
+		t.Errorf("Incorrect GUID after upgraded conversion; want: '%s', got: '%s'", *nullable.GUID, *upgraded.GUID)
+	}
+
+	if nullable.HostName == nil {
+		t.Error("Unexpectedly nil HostName in nullable-converted server")
+	} else if upgraded.HostName == nil {
+		t.Error("upgraded conversion gave nil HostName")
+	} else if *upgraded.HostName != *nullable.HostName {
+		t.Errorf("Incorrect HostName after upgraded conversion; want: '%s', got: '%s'", *nullable.HostName, *upgraded.HostName)
+	}
+
+	if nullable.HTTPSPort == nil {
+		t.Error("Unexpectedly nil HTTPSPort in nullable-converted server")
+	} else if upgraded.HTTPSPort == nil {
+		t.Error("upgraded conversion gave nil HTTPSPort")
+	} else if *upgraded.HTTPSPort != *nullable.HTTPSPort {
+		t.Errorf("Incorrect HTTPSPort after upgraded conversion; want: %d, got: %d", *nullable.HTTPSPort, *upgraded.HTTPSPort)
+	}
+
+	if nullable.ID == nil {
+		t.Error("Unexpectedly nil ID in nullable-converted server")
+	} else if upgraded.ID == nil {
+		t.Error("upgraded conversion gave nil ID")
+	} else if *upgraded.ID != *nullable.ID {
+		t.Errorf("Incorrect ID after upgraded conversion; want: %d, got: %d", *nullable.ID, *upgraded.ID)
+	}
+
+	if nullable.ILOIPAddress == nil {
+		t.Error("Unexpectedly nil ILOIPAddress in nullable-converted server")
+	} else if upgraded.ILOIPAddress == nil {
+		t.Error("upgraded conversion gave nil ILOIPAddress")
+	} else if *upgraded.ILOIPAddress != *nullable.ILOIPAddress {
+		t.Errorf("Incorrect ILOIPAddress after upgraded conversion; want: '%s', got: '%s'", *nullable.ILOIPAddress, *upgraded.ILOIPAddress)
+	}
+
+	if nullable.ILOIPGateway == nil {
+		t.Error("Unexpectedly nil ILOIPGateway in nullable-converted server")
+	} else if upgraded.ILOIPGateway == nil {
+		t.Error("upgraded conversion gave nil ILOIPGateway")
+	} else if *upgraded.ILOIPGateway != *nullable.ILOIPGateway {
+		t.Errorf("Incorrect ILOIPGateway after upgraded conversion; want: '%s', got: '%s'", *nullable.ILOIPGateway, *upgraded.ILOIPGateway)
+	}
+
+	if nullable.ILOIPNetmask == nil {
+		t.Error("Unexpectedly nil ILOIPNetmask in nullable-converted server")
+	} else if upgraded.ILOIPNetmask == nil {
+		t.Error("upgraded conversion gave nil ILOIPNetmask")
+	} else if *upgraded.ILOIPNetmask != *nullable.ILOIPNetmask {
+		t.Errorf("Incorrect ILOIPNetmask after upgraded conversion; want: '%s', got: '%s'", *nullable.ILOIPNetmask, *upgraded.ILOIPNetmask)
+	}
+
+	if nullable.ILOPassword == nil {
+		t.Error("Unexpectedly nil ILOPassword in nullable-converted server")
+	} else if upgraded.ILOPassword == nil {
+		t.Error("upgraded conversion gave nil ILOPassword")
+	} else if *upgraded.ILOPassword != *nullable.ILOPassword {
+		t.Errorf("Incorrect ILOPassword after upgraded conversion; want: '%s', got: '%s'", *nullable.ILOPassword, *upgraded.ILOPassword)
+	}
+
+	if nullable.ILOUsername == nil {
+		t.Error("Unexpectedly nil ILOUsername in nullable-converted server")
+	} else if upgraded.ILOUsername == nil {
+		t.Error("upgraded conversion gave nil ILOUsername")
+	} else if *upgraded.ILOUsername != *nullable.ILOUsername {
+		t.Errorf("Incorrect ILOUsername after upgraded conversion; want: '%s', got: '%s'", *nullable.ILOUsername, *upgraded.ILOUsername)
+	}
+
+	checkInterfaces := true
+	if nullable.InterfaceMtu == nil {
+		t.Error("Unexpectedly nil InterfaceMtu in nullable-converted server")
+		checkInterfaces = false
+	}
+	if nullable.InterfaceName == nil {
+		t.Error("Unexpectedly nil InterfaceName in nullable-converted server")
+		checkInterfaces = false
+	}
+	if nullable.IP6Address == nil {
+		t.Error("Unexpectedly nil IP6Address in nullable-converted server")
+		checkInterfaces = false
+	}
+	if nullable.IP6IsService == nil {
+		t.Error("Unexpectedly nil IP6IsService in nullable-converted server")
+		checkInterfaces = false
+	}
+	if nullable.IP6Gateway == nil {
+		t.Error("Unexpectedly nil IP6Gateway in nullable-converted server")
+		checkInterfaces = false
+	}
+	if nullable.IPAddress == nil {
+		t.Error("Unexpectedly nil IPAddress in nullable-converted server")
+		checkInterfaces = false
+	}
+	if nullable.IPIsService == nil {
+		t.Error("Unexpectedly nil IPIsService in nullable-converted server")
+		checkInterfaces = false
+	}
+	if nullable.IPGateway == nil {
+		t.Error("Unexpectedly nil IPGateway in nullable-converted server")
+		checkInterfaces = false
+	}
+	if nullable.IPNetmask == nil {
+		t.Error("Unexpectedly nil IPNetmask in nullable-converted server")
+		checkInterfaces = false
+	}
+
+	if checkInterfaces {
+		infLen := len(upgraded.Interfaces)
+		if infLen < 1 {

Review comment:
       we could combine both of these length checks since we know it should always equal 1

##########
File path: traffic_monitor/threadsafe/resultstathistory.go
##########
@@ -55,55 +64,212 @@ func (h *ResultInfoHistory) Get() cache.ResultInfoHistory {
 	return *h.history
 }
 
-// Set sets the internal ResultInfoHistory. This is only safe for one thread of execution. This MUST NOT be called from multiple threads.
+// Set sets the internal ResultInfoHistory. This is only safe for one thread of
+// execution. This MUST NOT be called from multiple threads.
 func (h *ResultInfoHistory) Set(v cache.ResultInfoHistory) {
 	h.m.Lock()
 	*h.history = v
 	h.m.Unlock()
 }
 
-type ResultStatHistory struct{ *sync.Map } // map[tc.CacheName]map[interfaceName]ResultStatValHistory
+// ResultStatHistory is a thread-safe mapping of cache server hostnames to
+// CacheStatHistory objects containing statistics for those cache servers.
+type ResultStatHistory struct{ *sync.Map } // map[string]CacheStatHistory
 
+// NewResultStatHistory constructs a new, empty ResultStatHistory.
 func NewResultStatHistory() ResultStatHistory {
 	return ResultStatHistory{&sync.Map{}}
 }
 
-func (h ResultStatHistory) LoadOrStore(cache tc.CacheName) map[string]ResultStatValHistory {
+// LoadOrStore returns the stored CacheStatHistory for the given cache server
+// hostname if it has already been stored. If it has not already been stored, a
+// new, empty CacheStatHistory object is created, stored under the given
+// hostname, and returned.
+func (h ResultStatHistory) LoadOrStore(hostname string) CacheStatHistory {
 	// TODO change to use sync.Pool?
-	v, loaded := h.Map.LoadOrStore(cache, NewResultStatValHistory())
-	if !loaded {
-		v = map[string]ResultStatValHistory{}
-	}
-	if rv, ok := v.(ResultStatValHistory); ok {
-		v = map[string]ResultStatValHistory{tc.CacheInterfacesAggregate: rv}
+	v, _ := h.Map.LoadOrStore(hostname, NewCacheStatHistory())
+	rv, ok := v.(CacheStatHistory)
+	if !ok {
+		log.Errorf("Failed to load or store stat history for '%s': invalid stored type.", hostname)
+		return NewCacheStatHistory()
 	}
-	return v.(map[string]ResultStatValHistory)
+
+	return rv
 }
 
-// Range behaves like sync.Map.Range. It calls f for every value in the map; if f returns false, the iteration is stopped.
-func (h ResultStatHistory) Range(f func(cache tc.CacheName, interfaceName string, val ResultStatValHistory) bool) {
+// Range behaves like sync.Map.Range. It calls f for every value in the map; if
+// f returns false, the iteration is stopped.
+func (h ResultStatHistory) Range(f func(cacheName string, val CacheStatHistory) bool) {
 	h.Map.Range(func(k, v interface{}) bool {
-		i, ok := v.(map[string]ResultStatValHistory)
+		i, ok := v.(CacheStatHistory)
 		if !ok {
-			log.Warnln("Cannot umarshal result stat val history")
+			log.Warnf("Non-CacheStatHistory object (%T) found in ResultStatHistory during Range.", v)
 			return true
 		}
-		for a, b := range i {
-			if !f(k.(tc.CacheName), a, b) {
-				return false
-			}
+		cacheName, ok := k.(string)
+		if !ok {
+			log.Warnf("Non-string object (%T) found as key in ResultStatHistory during Range.", k)
+			return true
 		}
-		return true
+		return f(cacheName, i)
 	})
 }
 
-// ResultStatValHistory is threadsafe for one writer. Specifically, because a CompareAndSwap is not provided, it's not possible to Load and Store without a race condition.
-// If multiple writers were necessary, it wouldn't be difficult to add a CompareAndSwap, internally storing an atomically-accessed pointer to the slice.
+// This is just a convenience structure used only for passing data about a
+// single statistic for a network interface into
+// compareAndAppendStatForInterface.
+type interfaceStat struct {
+	InterfaceName string
+	Stat          interface{}
+	StatName      string
+	Time          time.Time
+}
+
+// This is a little helper function used to compare a single stat for a single

Review comment:
       start doc with function name

##########
File path: lib/go-tc/traffic_monitor_test.go
##########
@@ -0,0 +1,442 @@
+package tc
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import "encoding/json"
+import "fmt"
+import "testing"
+
+func ExampleHealthThreshold_String() {
+	ht := HealthThreshold{Comparator: ">=", Val: 500}
+	fmt.Println(ht)
+	// Output: >=500.000000
+}
+
+func ExampleTMParameters_UnmarshalJSON() {
+	const data = `{
+		"health.connection.timeout": 5,
+		"health.polling.url": "https://example.com/",
+		"health.polling.format": "stats_over_http",
+		"history.count": 1,
+		"health.threshold.bandwidth": ">50",
+		"health.threshold.foo": "<=500"
+	}`
+
+	var params TMParameters
+	if err := json.Unmarshal([]byte(data), &params); err != nil {
+		fmt.Printf("Failed to unmarshal: %v\n", err)
+		return
+	}
+	fmt.Printf("timeout: %d\n", params.HealthConnectionTimeout)
+	fmt.Printf("url: %s\n", params.HealthPollingURL)
+	fmt.Printf("format: %s\n", params.HealthPollingFormat)
+	fmt.Printf("history: %d\n", params.HistoryCount)
+	fmt.Printf("# of Thresholds: %d - foo: %s, bandwidth: %s\n", len(params.Thresholds), params.Thresholds["foo"], params.Thresholds["bandwidth"])
+
+	// Output: timeout: 5
+	// url: https://example.com/
+	// format: stats_over_http
+	// history: 1
+	// # of Thresholds: 2 - foo: <=500.000000, bandwidth: >50.000000
+}
+
+func ExampleTrafficMonitorConfigMap_Valid() {
+	mc := &TrafficMonitorConfigMap{
+		CacheGroup: map[string]TMCacheGroup{"a": {}},
+		Config: map[string]interface{}{
+			"peers.polling.interval":  0.0,
+			"health.polling.interval": 0.0,
+		},
+		DeliveryService: map[string]TMDeliveryService{"a": {}},
+		Profile:         map[string]TMProfile{"a": {}},
+		TrafficMonitor:  map[string]TrafficMonitor{"a": {}},
+		TrafficServer:   map[string]TrafficServer{"a": {}},
+	}
+
+	fmt.Printf("Validity error: %v", mc.Valid())
+
+	// Output: Validity error: <nil>
+}
+
+func ExampleLegacyTrafficMonitorConfigMap_Upgrade() {
+	lcm := LegacyTrafficMonitorConfigMap{
+		CacheGroup: map[string]TMCacheGroup{
+			"test": {
+				Name: "test",
+				Coordinates: MonitoringCoordinates{
+					Latitude:  0,
+					Longitude: 0,
+				},
+			},
+		},
+		Config: map[string]interface{}{
+			"foo": "bar",
+		},
+		DeliveryService: map[string]TMDeliveryService{
+			"test": {
+				XMLID:              "test",
+				TotalTPSThreshold:  -1,
+				ServerStatus:       "testStatus",
+				TotalKbpsThreshold: -1,
+			},
+		},
+		Profile: map[string]TMProfile{
+			"test": {
+				Parameters: TMParameters{
+					HealthConnectionTimeout: -1,
+					HealthPollingURL:        "testURL",
+					HealthPollingFormat:     "astats",
+					HealthPollingType:       "http",
+					HistoryCount:            -1,
+					MinFreeKbps:             -1,
+					Thresholds: map[string]HealthThreshold{
+						"availableBandwidthInKbps": {
+							Comparator: "<",
+							Val:        -1,
+						},
+					},
+				},
+				Name: "test",
+				Type: "testType",
+			},
+		},
+		TrafficMonitor: map[string]TrafficMonitor{
+			"test": {
+				Port:         -1,
+				IP6:          "::1",
+				IP:           "0.0.0.0",
+				HostName:     "test",
+				FQDN:         "test.quest",
+				Profile:      "test",
+				Location:     "test",
+				ServerStatus: "testStatus",
+			},
+		},
+		TrafficServer: map[string]LegacyTrafficServer{
+			"test": {
+				CacheGroup:       "test",
+				DeliveryServices: []tsdeliveryService{},
+				FQDN:             "test.quest",
+				HashID:           "test",
+				HostName:         "test",
+				HTTPSPort:        -1,
+				InterfaceName:    "testInterface",
+				IP:               "0.0.0.1",
+				IP6:              "::2",
+				Port:             -1,
+				Profile:          "test",
+				ServerStatus:     "testStatus",
+				Type:             "testType",
+			},
+		},
+	}
+
+	cm := lcm.Upgrade()
+	fmt.Println("# of Cachegroups:", len(cm.CacheGroup))
+	fmt.Println("Cachegroup Name:", cm.CacheGroup["test"].Name)
+	fmt.Printf("Cachegroup Coordinates: (%v,%v)\n", cm.CacheGroup["test"].Coordinates.Latitude, cm.CacheGroup["test"].Coordinates.Longitude)
+	fmt.Println("# of Config parameters:", len(cm.Config))
+	fmt.Println(`Config["foo"]:`, cm.Config["foo"])
+	fmt.Println("# of DeliveryServices:", len(cm.DeliveryService))
+	fmt.Println("DeliveryService XMLID:", cm.DeliveryService["test"].XMLID)
+	fmt.Println("DeliveryService TotalTPSThreshold:", cm.DeliveryService["test"].TotalTPSThreshold)
+	fmt.Println("DeliveryService ServerStatus:", cm.DeliveryService["test"].ServerStatus)
+	fmt.Println("DeliveryService TotalKbpsThreshold:", cm.DeliveryService["test"].TotalKbpsThreshold)
+	fmt.Println("# of Profiles:", len(cm.Profile))
+	fmt.Println("Profile Name:", cm.Profile["test"].Name)
+	fmt.Println("Profile Type:", cm.Profile["test"].Type)
+	fmt.Println("Profile HealthConnectionTimeout:", cm.Profile["test"].Parameters.HealthConnectionTimeout)
+	fmt.Println("Profile HealthPollingURL:", cm.Profile["test"].Parameters.HealthPollingURL)
+	fmt.Println("Profile HealthPollingFormat:", cm.Profile["test"].Parameters.HealthPollingFormat)
+	fmt.Println("Profile HealthPollingType:", cm.Profile["test"].Parameters.HealthPollingType)
+	fmt.Println("Profile HistoryCount:", cm.Profile["test"].Parameters.HistoryCount)
+	fmt.Println("Profile MinFreeKbps:", cm.Profile["test"].Parameters.MinFreeKbps)
+	fmt.Println("# of Profile Thresholds:", len(cm.Profile["test"].Parameters.Thresholds))
+	fmt.Println("Profile availableBandwidthInKbps Threshold:", cm.Profile["test"].Parameters.Thresholds["availableBandwidthInKbps"])
+	fmt.Println("# of TrafficMonitors:", len(cm.TrafficMonitor))
+	fmt.Println("TrafficMonitor Port:", cm.TrafficMonitor["test"].Port)
+	fmt.Println("TrafficMonitor IP6:", cm.TrafficMonitor["test"].IP6)
+	fmt.Println("TrafficMonitor IP:", cm.TrafficMonitor["test"].IP)
+	fmt.Println("TrafficMonitor HostName:", cm.TrafficMonitor["test"].HostName)
+	fmt.Println("TrafficMonitor FQDN:", cm.TrafficMonitor["test"].FQDN)
+	fmt.Println("TrafficMonitor Profile:", cm.TrafficMonitor["test"].Profile)
+	fmt.Println("TrafficMonitor Location:", cm.TrafficMonitor["test"].Location)
+	fmt.Println("TrafficMonitor ServerStatus:", cm.TrafficMonitor["test"].ServerStatus)
+	fmt.Println("# of TrafficServers:", len(cm.TrafficServer))
+	fmt.Println("TrafficServer CacheGroup:", cm.TrafficServer["test"].CacheGroup)
+	fmt.Println("TrafficServer # of DeliveryServices:", len(cm.TrafficServer["test"].DeliveryServices))
+	fmt.Println("TrafficServer FQDN:", cm.TrafficServer["test"].FQDN)
+	fmt.Println("TrafficServer HashID:", cm.TrafficServer["test"].HashID)
+	fmt.Println("TrafficServer HostName:", cm.TrafficServer["test"].HostName)
+	fmt.Println("TrafficServer HTTPSPort:", cm.TrafficServer["test"].HTTPSPort)
+	fmt.Println("TrafficServer # of Interfaces:", len(cm.TrafficServer["test"].Interfaces))
+	fmt.Println("TrafficServer Interface Name:", cm.TrafficServer["test"].Interfaces[0].Name)
+	fmt.Println("TrafficServer # of Interface IP Addresses:", len(cm.TrafficServer["test"].Interfaces[0].IPAddresses))
+	fmt.Println("TrafficServer first IP Address:", cm.TrafficServer["test"].Interfaces[0].IPAddresses[0].Address)
+	fmt.Println("TrafficServer second IP Address:", cm.TrafficServer["test"].Interfaces[0].IPAddresses[1].Address)
+	fmt.Println("TrafficServer Port:", cm.TrafficServer["test"].Port)
+	fmt.Println("TrafficServer Profile:", cm.TrafficServer["test"].Profile)
+	fmt.Println("TrafficServer ServerStatus:", cm.TrafficServer["test"].ServerStatus)
+	fmt.Println("TrafficServer Type:", cm.TrafficServer["test"].Type)
+
+	// Output: # of Cachegroups: 1
+	// Cachegroup Name: test
+	// Cachegroup Coordinates: (0,0)
+	// # of Config parameters: 1
+	// Config["foo"]: bar
+	// # of DeliveryServices: 1
+	// DeliveryService XMLID: test
+	// DeliveryService TotalTPSThreshold: -1
+	// DeliveryService ServerStatus: testStatus
+	// DeliveryService TotalKbpsThreshold: -1
+	// # of Profiles: 1
+	// Profile Name: test
+	// Profile Type: testType
+	// Profile HealthConnectionTimeout: -1
+	// Profile HealthPollingURL: testURL
+	// Profile HealthPollingFormat: astats
+	// Profile HealthPollingType: http
+	// Profile HistoryCount: -1
+	// Profile MinFreeKbps: -1
+	// # of Profile Thresholds: 1
+	// Profile availableBandwidthInKbps Threshold: <-1.000000
+	// # of TrafficMonitors: 1
+	// TrafficMonitor Port: -1
+	// TrafficMonitor IP6: ::1
+	// TrafficMonitor IP: 0.0.0.0
+	// TrafficMonitor HostName: test
+	// TrafficMonitor FQDN: test.quest
+	// TrafficMonitor Profile: test
+	// TrafficMonitor Location: test
+	// TrafficMonitor ServerStatus: testStatus
+	// # of TrafficServers: 1
+	// TrafficServer CacheGroup: test
+	// TrafficServer # of DeliveryServices: 0
+	// TrafficServer FQDN: test.quest
+	// TrafficServer HashID: test
+	// TrafficServer HostName: test
+	// TrafficServer HTTPSPort: -1
+	// TrafficServer # of Interfaces: 1
+	// TrafficServer Interface Name: testInterface
+	// TrafficServer # of Interface IP Addresses: 2
+	// TrafficServer first IP Address: 0.0.0.1
+	// TrafficServer second IP Address: ::2
+	// TrafficServer Port: -1
+	// TrafficServer Profile: test
+	// TrafficServer ServerStatus: testStatus
+	// TrafficServer Type: testType
+}
+
+func TestTrafficMonitorConfigMap_Valid(t *testing.T) {
+	var mc *TrafficMonitorConfigMap = nil
+	err := mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of nil config map")
+	} else {
+		t.Logf("Got expected error: checking validity of nil config map: %v", err)
+	}
+	mc = &TrafficMonitorConfigMap{
+		CacheGroup: nil,
+		Config: map[string]interface{}{
+			"peers.polling.interval":  42.0,
+			"health.polling.interval": 24.0,
+		},
+		DeliveryService: map[string]TMDeliveryService{"a": {}},
+		Profile:         map[string]TMProfile{"a": {}},
+		TrafficMonitor:  map[string]TrafficMonitor{"a": {}},
+		TrafficServer:   map[string]TrafficServer{"a": {}},
+	}
+
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with nil CacheGroup")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with nil CacheGroup: %v", err)
+	}
+
+	mc.CacheGroup = map[string]TMCacheGroup{}
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with no CacheGroups")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with no CacheGroups: %v", err)
+	}
+
+	mc.CacheGroup["a"] = TMCacheGroup{}
+	mc.Config = nil
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with nil Config")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with nil Config: %v", err)
+	}
+
+	mc.Config = map[string]interface{}{}
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with empty Config")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with empty Config: %v", err)
+	}
+
+	mc.Config["peers.polling.interval"] = 42.0
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map without health.polling.interval")
+	} else {
+		t.Logf("Got expected error: checking validity of config map without health.polling.interval: %v", err)
+	}
+
+	delete(mc.Config, "peers.polling.interval")
+	mc.Config["health.polling.interval"] = 42.0
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map without peers.polling.interval")
+	} else {
+		t.Logf("Got expected error: checking validity of config map without peers.polling.interval: %v", err)
+	}
+
+	mc.Config["peers.polling.interval"] = 42.0
+	// TODO: uncomment these tests when #3528 is resolved
+	// mc.DeliveryService = nil
+	// err = mc.Valid()
+	// if err == nil {
+	// 	t.Error("Didn't get expected error checking validity of config map with nil DeliveryService")
+	// } else {
+	// 	t.Logf("Got expected error: checking validity of config map with nil DeliveryService: %v", err)
+	// }
+
+	// mc.DeliveryService = map[string]TMDeliveryService{}
+	// err = mc.Valid()
+	// if err == nil {
+	// 	t.Error("Didn't get expected error checking validity of config map with no DeliveryServices")
+	// } else {
+	// 	t.Logf("Got expected error: checking validity of config map with no DeliveryServices: %v", err)
+	// }
+
+	// mc.DeliveryService["a"] = TMDeliveryService{}
+	mc.Profile = nil
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with nil Profile")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with nil Profile: %v", err)
+	}
+
+	mc.Profile = map[string]TMProfile{}
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with no Profiles")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with no Profiles: %v", err)
+	}
+
+	mc.Profile["a"] = TMProfile{}
+	mc.TrafficMonitor = nil
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with nil TrafficMonitor")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with nil TrafficMonitor: %v", err)
+	}
+
+	mc.TrafficMonitor = map[string]TrafficMonitor{}
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with no TrafficMonitors")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with no TrafficMonitors: %v", err)
+	}
+
+	mc.TrafficMonitor["a"] = TrafficMonitor{}
+	mc.TrafficServer = nil
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with nil TrafficServer")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with nil TrafficServer: %v", err)
+	}
+
+	mc.TrafficServer = map[string]TrafficServer{}
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with no TrafficServers")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with no TrafficServers: %v", err)
+	}

Review comment:
       maybe add a valid case check?

##########
File path: traffic_monitor/towrap/towrap.go
##########
@@ -324,17 +417,50 @@ func (s TrafficOpsSessionThreadsafe) LastCRConfig(cdn string) ([]byte, time.Time
 	return crConfig, crConfigTime, nil
 }
 
-// TrafficMonitorConfigMapRaw returns the Traffic Monitor config map from the Traffic Ops, directly from the monitoring.json endpoint. This is not usually what is needed, rather monitoring needs the snapshotted CRConfig data, which is filled in by `LegacyTrafficMonitorConfigMap`. This is safe for multiple goroutines.
-func (s TrafficOpsSessionThreadsafe) trafficMonitorConfigMapRaw(cdn string) (*tc.LegacyTrafficMonitorConfigMap, error) {
+func (s TrafficOpsSessionThreadsafe) fetchTMConfigMap(cdn string) (*tc.TrafficMonitorConfigMap, error) {
 	ss := s.get()
 	if ss == nil {
 		return nil, ErrNilSession
 	}
 
-	configMap, _, err := ss.GetTrafficMonitorConfigMap(cdn)
+	m, _, e := ss.GetTrafficMonitorConfigMap(cdn)
+	return m, e
+}
+
+func (s TrafficOpsSessionThreadsafe) fetchLegacyTMConfigMap(cdn string) (*tc.TrafficMonitorConfigMap, error) {
+	ss := s.getLegacy()
+	if ss == nil {
+		return nil, ErrNilSession
+	}
+
+	m, _, e := ss.GetTrafficMonitorConfigMap(cdn)
+	return m.Upgrade(), e
+}
+
+// TrafficMonitorConfigMapRaw returns the Traffic Monitor config map from the

Review comment:
       nit but doc starts with capital and function starts with lower case

##########
File path: docs/source/api/v3/cdns_name_configs_monitoring.rst
##########
@@ -76,7 +76,7 @@ Response Structure
 
 		:health.connection.timeout:                 A timeout value, in milliseconds, to wait before giving up on a health check request
 		:health.polling.url:                        A URL to request for polling health. Substitutions can be made in a shell-like syntax using the properties of an object from the ``"trafficServers"`` array
-		:health.threshold.availableBandwidthInKbps: The total amount of bandwidth that servers using this profile are allowed, in Kilobits per second. This is a string and using comparison operators to specify ranges, e.g. ">10" means "more than 10 kbps"
+		:health.threshold.availableBandwidthInKbps: The total amount of bandwidth that servers using this profile are allowed - across all network interfaces -, in Kilobits per second. This is a string and using comparison operators to specify ranges, e.g. ">10" means "more than 10 kbps"

Review comment:
       remove comma after - at `interfaces -, in Kilobits`




----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org