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

ocket8888 pushed a commit to branch 5.0.x
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git

commit 3d0a90f9918dfa1a0158a3e04da3a2440197555c
Author: Rawlin Peters <[email protected]>
AuthorDate: Wed Nov 18 06:47:14 2020 -0700

    Add validation to topology updates and server updates/deletions (#5299)
    
    Topologies must contain at least 1 server per cachegroup in each of the
    CDNs of any assigned delivery services. Otherwise, a delivery service's
    topology is not truly representative of reality.
    
    (cherry picked from commit be43e76bef2e60b2a5df2049ab8633f035ecd995)
---
 CHANGELOG.md                                       |   1 +
 traffic_ops/testing/api/v3/servers_test.go         |  63 +++-
 traffic_ops/testing/api/v3/tc-fixtures.json        | 335 +++++++++++++++++++++
 traffic_ops/testing/api/v3/topologies_test.go      |  66 +++-
 .../traffic_ops_golang/dbhelpers/db_helpers.go     |  45 +++
 traffic_ops/traffic_ops_golang/server/servers.go   |  64 ++--
 .../traffic_ops_golang/topology/topologies.go      |  95 ++++--
 7 files changed, 602 insertions(+), 67 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07861c4..1d6f591 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -67,6 +67,7 @@ The format is based on [Keep a 
Changelog](http://keepachangelog.com/en/1.0.0/).
 - Added default sort logic to GET API calls using Read()
 - Traffic Ops: added validation for assigning ORG servers to topology-based 
delivery services
 - Added locationByDeepCoverageZone to the `crs/stats/ip/{ip}` endpoint in the 
Traffic Router API
+- Traffic Ops: added validation for topology updates and server 
updates/deletions to ensure that topologies have at least one server per 
cachegroup in each CDN of any assigned delivery services
 
 ### Fixed
 - Fixed #5188 - DSR (delivery service request) incorrectly marked as complete 
and error message not displaying when DSR fulfilled and DS update fails in 
Traffic Portal. [Related Github 
issue](https://github.com/apache/trafficcontrol/issues/5188)
diff --git a/traffic_ops/testing/api/v3/servers_test.go 
b/traffic_ops/testing/api/v3/servers_test.go
index 1af5300..9d0d4d1 100644
--- a/traffic_ops/testing/api/v3/servers_test.go
+++ b/traffic_ops/testing/api/v3/servers_test.go
@@ -60,16 +60,23 @@ func LastServerInTopologyCacheGroup(t *testing.T) {
        const cacheGroupName = "topology-mid-cg-01"
        const moveToCacheGroup = "topology-mid-cg-02"
        const topologyName = "forked-topology"
+       const cdnName = "cdn2"
        const expectedLength = 1
+       cdns, _, err := TOSession.GetCDNByNameWithHdr(cdnName, nil)
+       if err != nil {
+               t.Fatalf("unable to GET CDN: %v", err)
+       }
+       cdnID := cdns[0].ID
        params := url.Values{}
        params.Add("cachegroupName", cacheGroupName)
        params.Add("topology", topologyName)
+       params.Add("cdn", strconv.Itoa(cdnID))
        servers, _, err := TOSession.GetServersWithHdr(&params, nil)
        if err != nil {
-               t.Fatalf("getting server from cachegroup %s in topology %s: 
%s", cacheGroupName, topologyName, err.Error())
+               t.Fatalf("getting server from cdn %s from cachegroup %s in 
topology %s: %s", cdnName, cacheGroupName, topologyName, err.Error())
        }
        if len(servers.Response) != expectedLength {
-               t.Fatalf("expected to get %d server from cachegroup %s in 
topology %s, got %d servers", expectedLength, cacheGroupName, topologyName, 
len(servers.Response))
+               t.Fatalf("expected to get %d server from cdn %s from cachegroup 
%s in topology %s, got %d servers", expectedLength, cdnName, cacheGroupName, 
topologyName, len(servers.Response))
        }
        server := servers.Response[0]
        _, reqInf, err := TOSession.DeleteServerByID(*server.ID)
@@ -80,6 +87,28 @@ func LastServerInTopologyCacheGroup(t *testing.T) {
                t.Fatalf("expected a 400-level error deleting server with id 
%d, got status code %d: %s", *server.ID, reqInf.StatusCode, err.Error())
        }
 
+       // attempt to move it to another CDN while it's the last server in the 
cachegroup in its CDN
+       cdns, _, err = TOSession.GetCDNByNameWithHdr("cdn1", nil)
+       if err != nil {
+               t.Fatalf("unable to GET CDN: %v", err)
+       }
+       newCDNID := cdns[0].ID
+       oldCDNID := *server.CDNID
+       server.CDNID = &newCDNID
+       profiles, _, err := TOSession.GetProfileByNameWithHdr("MID1", nil)
+       if err != nil {
+               t.Fatalf("unable to GET profile: %v", err)
+       }
+       newProfile := profiles[0].ID
+       oldProfile := *server.ProfileID
+       server.ProfileID = &newProfile
+       _, _, err = TOSession.UpdateServerByIDWithHdr(*server.ID, server, nil)
+       if err == nil {
+               t.Fatalf("changing the CDN of the last server (%s) in a CDN in 
a cachegroup used by a topology assigned to a delivery service(s) in that CDN - 
expected: error, actual: nil", *server.HostName)
+       }
+       server.CDNID = &oldCDNID
+       server.ProfileID = &oldProfile
+
        params = url.Values{}
        params.Add("name", moveToCacheGroup)
        cgs, _, err := TOSession.GetCacheGroupsByQueryParamsWithHdr(params, nil)
@@ -548,10 +577,11 @@ func GetTestServersQueryParameters(t *testing.T) {
 
        params.Set("dsId", strconv.Itoa(*ds.ID))
        expectedHostnames := map[string]bool{
-               "edge1-cdn1-cg3":    false,
-               "edge2-cdn1-cg3":    false,
-               "atlanta-mid-16":    false,
-               "edgeInCachegroup3": false,
+               "edge1-cdn1-cg3":                 false,
+               "edge2-cdn1-cg3":                 false,
+               "atlanta-mid-16":                 false,
+               "edgeInCachegroup3":              false,
+               "midInSecondaryCachegroupInCDN1": false,
        }
        response, _, err := TOSession.GetServersWithHdr(&params, nil)
        if err != nil {
@@ -562,7 +592,7 @@ func GetTestServersQueryParameters(t *testing.T) {
        }
        for _, server := range response.Response {
                if _, exists := expectedHostnames[*server.HostName]; !exists {
-                       t.Fatalf("expected hostnames %v, actual %s actual: ", 
expectedHostnames, *server.HostName)
+                       t.Fatalf("expected hostnames %v, actual %s", 
expectedHostnames, *server.HostName)
                }
                expectedHostnames[*server.HostName] = true
        }
@@ -600,14 +630,15 @@ func GetTestServersQueryParameters(t *testing.T) {
        params.Del("dsId")
        params.Add("topology", topology)
        expectedHostnames = map[string]bool{
-               originHostname:             false,
-               "edge1-cdn1-cg3":           false,
-               "edge2-cdn1-cg3":           false,
-               "atlanta-mid-16":           false,
-               "atlanta-mid-17":           false,
-               "edgeInCachegroup3":        false,
-               "midInParentCachegroup":    false,
-               "midInSecondaryCachegroup": false,
+               originHostname:                   false,
+               "edge1-cdn1-cg3":                 false,
+               "edge2-cdn1-cg3":                 false,
+               "atlanta-mid-16":                 false,
+               "atlanta-mid-17":                 false,
+               "edgeInCachegroup3":              false,
+               "midInParentCachegroup":          false,
+               "midInSecondaryCachegroup":       false,
+               "midInSecondaryCachegroupInCDN1": false,
        }
        response, _, err = TOSession.GetServersWithHdr(&params, nil)
        if err != nil {
@@ -618,7 +649,7 @@ func GetTestServersQueryParameters(t *testing.T) {
        }
        for _, server := range response.Response {
                if _, exists := expectedHostnames[*server.HostName]; !exists {
-                       t.Fatalf("expected hostnames %v, actual %s actual: ", 
expectedHostnames, *server.HostName)
+                       t.Fatalf("expected hostnames %v, actual %s", 
expectedHostnames, *server.HostName)
                }
                expectedHostnames[*server.HostName] = true
        }
diff --git a/traffic_ops/testing/api/v3/tc-fixtures.json 
b/traffic_ops/testing/api/v3/tc-fixtures.json
index 30c811f..796c630 100644
--- a/traffic_ops/testing/api/v3/tc-fixtures.json
+++ b/traffic_ops/testing/api/v3/tc-fixtures.json
@@ -222,6 +222,13 @@
             "name": "dtrc3",
             "shortName": "dtrc3",
             "typeName": "EDGE_LOC"
+        },
+        {
+            "latitude": 0,
+            "longitude": 0,
+            "name": "cdn1-only",
+            "shortName": "cdn1-only",
+            "typeName": "EDGE_LOC"
         }
     ],
     "cdns": [
@@ -1066,6 +1073,198 @@
             "type": "CLIENT_STEERING",
             "xmlId": "ds-client-steering",
             "anonymousBlockingEnabled": false
+        },
+        {
+            "active": true,
+            "cdnName": "cdn2",
+            "cacheurl": "",
+            "ccrDnsTtl": 3600,
+            "checkPath": "",
+            "consistentHashQueryParams": [],
+            "deepCachingType": "NEVER",
+            "displayName": "ds-forked-topology",
+            "dnsBypassCname": null,
+            "dnsBypassIp": "",
+            "dnsBypassIp6": "",
+            "dnsBypassTtl": 30,
+            "dscp": 40,
+            "edgeHeaderRewrite": null,
+            "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": "",
+            "longDesc1": "",
+            "longDesc2": "",
+            "matchList": [
+                {
+                    "pattern": ".*\\.ds-forked-topology\\..*",
+                    "setNumber": 0,
+                    "type": "HOST_REGEXP"
+                }
+            ],
+            "maxDnsAnswers": 0,
+            "midHeaderRewrite": null,
+            "missLat": 41.881944,
+            "missLong": -87.627778,
+            "multiSiteOrigin": false,
+            "orgServerFqdn": "http://example.org";,
+            "originShield": null,
+            "profileDescription": null,
+            "profileName": null,
+            "protocol": 0,
+            "qstringIgnore": 0,
+            "rangeRequestHandling": 0,
+            "regexRemap": null,
+            "regionalGeoBlocking": false,
+            "remapText": null,
+            "routingName": "cdn",
+            "signed": false,
+            "signingAlgorithm": null,
+            "sslKeyVersion": 0,
+            "tenant": "tenant1",
+            "tenantName": "tenant1",
+            "topology": "forked-topology",
+            "type": "HTTP",
+            "xmlId": "ds-forked-topology",
+            "anonymousBlockingEnabled": false
+        },
+        {
+            "active": true,
+            "cdnName": "cdn1",
+            "cacheurl": "",
+            "ccrDnsTtl": 3600,
+            "checkPath": "",
+            "consistentHashQueryParams": [],
+            "deepCachingType": "NEVER",
+            "displayName": "top-ds-in-cdn1",
+            "dnsBypassCname": null,
+            "dnsBypassIp": "",
+            "dnsBypassIp6": "",
+            "dnsBypassTtl": 30,
+            "dscp": 40,
+            "edgeHeaderRewrite": null,
+            "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": "",
+            "longDesc1": "",
+            "longDesc2": "",
+            "matchList": [
+                {
+                    "pattern": ".*\\.top-ds-in-cdn1\\..*",
+                    "setNumber": 0,
+                    "type": "HOST_REGEXP"
+                }
+            ],
+            "maxDnsAnswers": 0,
+            "midHeaderRewrite": null,
+            "missLat": 41.881944,
+            "missLong": -87.627778,
+            "multiSiteOrigin": false,
+            "orgServerFqdn": "http://example.org";,
+            "originShield": null,
+            "profileDescription": null,
+            "profileName": null,
+            "protocol": 0,
+            "qstringIgnore": 0,
+            "rangeRequestHandling": 0,
+            "regexRemap": null,
+            "regionalGeoBlocking": false,
+            "remapText": null,
+            "routingName": "cdn",
+            "signed": false,
+            "signingAlgorithm": null,
+            "sslKeyVersion": 0,
+            "tenant": "tenant1",
+            "tenantName": "tenant1",
+            "topology": "top-used-by-cdn1-and-cdn2",
+            "type": "HTTP",
+            "xmlId": "top-ds-in-cdn1",
+            "anonymousBlockingEnabled": false
+        },
+        {
+            "active": true,
+            "cdnName": "cdn2",
+            "cacheurl": "",
+            "ccrDnsTtl": 3600,
+            "checkPath": "",
+            "consistentHashQueryParams": [],
+            "deepCachingType": "NEVER",
+            "displayName": "top-ds-in-cdn2",
+            "dnsBypassCname": null,
+            "dnsBypassIp": "",
+            "dnsBypassIp6": "",
+            "dnsBypassTtl": 30,
+            "dscp": 40,
+            "edgeHeaderRewrite": null,
+            "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": "",
+            "longDesc1": "",
+            "longDesc2": "",
+            "matchList": [
+                {
+                    "pattern": ".*\\.top-ds-in-cdn2\\..*",
+                    "setNumber": 0,
+                    "type": "HOST_REGEXP"
+                }
+            ],
+            "maxDnsAnswers": 0,
+            "midHeaderRewrite": null,
+            "missLat": 41.881944,
+            "missLong": -87.627778,
+            "multiSiteOrigin": false,
+            "orgServerFqdn": "http://example.org";,
+            "originShield": null,
+            "profileDescription": null,
+            "profileName": null,
+            "protocol": 0,
+            "qstringIgnore": 0,
+            "rangeRequestHandling": 0,
+            "regexRemap": null,
+            "regionalGeoBlocking": false,
+            "remapText": null,
+            "routingName": "cdn",
+            "signed": false,
+            "signingAlgorithm": null,
+            "sslKeyVersion": 0,
+            "tenant": "tenant1",
+            "tenantName": "tenant1",
+            "topology": "top-used-by-cdn1-and-cdn2",
+            "type": "HTTP",
+            "xmlId": "top-ds-in-cdn2",
+            "anonymousBlockingEnabled": false
         }
     ],
     "deliveryServicesRegexes": [
@@ -3426,6 +3625,90 @@
             "updPending": false
         },
         {
+            "cachegroup": "secondaryCachegroup",
+            "cdnName": "cdn1",
+            "domainName": "kabletown.net",
+            "hostName": "midInSecondaryCachegroupInCDN1",
+            "httpsPort": 443,
+            "interfaces": [
+                {
+                    "ipAddresses": [
+                        {
+                            "address": "2001:db8:deed:beef::12/64",
+                            "gateway": "2001:db8:deed:beef::1",
+                            "serviceAddress": false
+                        },
+                        {
+                            "address": "192.0.7.15/24",
+                            "gateway": "192.0.7.1",
+                            "serviceAddress": true
+                        }
+                    ],
+                    "monitor": true,
+                    "mtu": 9000,
+                    "name": "bond0"
+                }
+            ],
+            "physLocation": "Denver",
+            "profile": "MID1",
+            "rack": "RR 119.02",
+            "revalPending": false,
+            "status": "REPORTED",
+            "tcpPort": 80,
+            "type": "MID",
+            "updPending": false
+        },
+        {
+            "cachegroup": "cdn1-only",
+            "cdnName": "cdn1",
+            "domainName": "foo.kabletown.net",
+            "guid": null,
+            "hostName": "edge-in-cdn1-only",
+            "httpsPort": 443,
+            "iloIpAddress": "",
+            "iloIpGateway": "",
+            "iloIpNetmask": "",
+            "iloPassword": "",
+            "iloUsername": "",
+            "interfaces": [
+                {
+                    "ipAddresses": [
+                        {
+                            "address": "192.0.2.88/24",
+                            "gateway": "192.0.2.1",
+                            "serviceAddress": true
+                        },
+                        {
+                            "address": "2001:db8:f33d:beef::2/64",
+                            "gateway": "2001:db8:f33d:beef::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,
+            "routerHostName": "",
+            "routerPortName": "",
+            "status": "REPORTED",
+            "tcpPort": 80,
+            "type": "EDGE",
+            "updPending": false,
+            "xmppId": "",
+            "xmppPasswd": ""
+        },
+        {
             "cachegroup": "fallback1",
             "cdnName": "cdn2",
             "domainName": "kabletown.net",
@@ -3630,6 +3913,40 @@
             "updPending": false
         },
         {
+            "cachegroup": "topology-mid-cg-01",
+            "cdnName": "cdn1",
+            "domainName": "kabletown.net",
+            "hostName": "midInTopologyMidCg01InCDN1",
+            "httpsPort": 443,
+            "interfaces": [
+                {
+                    "ipAddresses": [
+                        {
+                            "address": "2001:db8:de4d:beef::12/64",
+                            "gateway": "2001:db8:de4d:beef::1",
+                            "serviceAddress": false
+                        },
+                        {
+                            "address": "192.0.12.21/24",
+                            "gateway": "192.0.12.1",
+                            "serviceAddress": true
+                        }
+                    ],
+                    "monitor": true,
+                    "mtu": 9000,
+                    "name": "bond0"
+                }
+            ],
+            "physLocation": "Denver",
+            "profile": "MID1",
+            "rack": "RR 119.02",
+            "revalPending": false,
+            "status": "REPORTED",
+            "tcpPort": 80,
+            "type": "MID",
+            "updPending": false
+        },
+        {
             "cachegroup": "topology-mid-cg-02",
             "cdnName": "cdn2",
             "domainName": "kabletown.net",
@@ -4230,6 +4547,24 @@
                     "parents": [1]
                 }
             ]
+        },
+        {
+            "name": "top-used-by-cdn1-and-cdn2",
+            "description": "a topology",
+            "nodes": [
+                {
+                    "cachegroup": "dtrc1",
+                    "parents": []
+                },
+                {
+                    "cachegroup": "dtrc2",
+                    "parents": [0]
+                },
+                {
+                    "cachegroup": "dtrc3",
+                    "parents": [0]
+                }
+            ]
         }
     ],
     "types": [
diff --git a/traffic_ops/testing/api/v3/topologies_test.go 
b/traffic_ops/testing/api/v3/topologies_test.go
index 2b43a31..4350c8e 100644
--- a/traffic_ops/testing/api/v3/topologies_test.go
+++ b/traffic_ops/testing/api/v3/topologies_test.go
@@ -21,7 +21,9 @@ package v3
 
 import (
        "fmt"
+       "net/url"
        "reflect"
+       "strconv"
        "strings"
        "testing"
 
@@ -164,14 +166,6 @@ func updateSingleTopology(topology tc.Topology) error {
 }
 
 func UpdateTestTopologies(t *testing.T) {
-       firstTopName := testData.Topologies[0].Name
-       for _, top := range testData.Topologies {
-               top.Name = firstTopName
-               if err := updateSingleTopology(top); err != nil {
-                       t.Fatalf(err.Error())
-               }
-       }
-       // Revert test topologies
        for _, topology := range testData.Topologies {
                if err := updateSingleTopology(topology); err != nil {
                        t.Fatalf(err.Error())
@@ -188,6 +182,62 @@ func UpdateTestTopologies(t *testing.T) {
        if err == nil {
                t.Errorf("making invalid update to topology - expected: error, 
actual: nil")
        }
+
+       // attempt to add a cachegroup that only has caches in one CDN while 
the topology is assigned to DSes from multiple CDNs
+       top, _, err = TOSession.GetTopologyWithHdr("top-used-by-cdn1-and-cdn2", 
nil)
+       if err != nil {
+               t.Fatalf("cannot GET topology: %v", err)
+       }
+       params := url.Values{}
+       params.Add("topology", "top-used-by-cdn1-and-cdn2")
+       dses, _, err := TOSession.GetDeliveryServicesV30WithHdr(nil, params)
+       if err != nil {
+               t.Fatalf("cannot GET delivery services: %v", err)
+       }
+       if len(dses) < 2 {
+               t.Fatalf("expected at least 2 delivery services assigned to 
topology top-used-by-cdn1-and-cdn2, actual: %d", len(dses))
+       }
+       foundCDN1 := false
+       foundCDN2 := false
+       for _, ds := range dses {
+               if *ds.CDNName == "cdn1" {
+                       foundCDN1 = true
+               } else if *ds.CDNName == "cdn2" {
+                       foundCDN2 = true
+               }
+       }
+       if !foundCDN1 || !foundCDN2 {
+               t.Fatalf("expected delivery services assigned to topology 
top-used-by-cdn1-and-cdn2 to be assigned to cdn1 and cdn2")
+       }
+       cgs, _, err := 
TOSession.GetCacheGroupNullableByNameWithHdr("cdn1-only", nil)
+       if err != nil {
+               t.Fatalf("unable to GET cachegroup by name: %v", err)
+       }
+       if len(cgs) != 1 {
+               t.Fatalf("expected: to get 1 cachegroup named 'cdn1-only', 
actual: got %d", len(cgs))
+       }
+       params = url.Values{}
+       params.Add("cachegroup", strconv.Itoa(*cgs[0].ID))
+       servers, _, err := TOSession.GetServersWithHdr(&params, nil)
+       if err != nil {
+               t.Fatalf("unable to GET servers by cachegroup: %v", err)
+       }
+       for _, s := range servers.Response {
+               if *s.Cachegroup != "cdn1-only" {
+                       t.Fatalf("GET servers by cachegroup 'cdn1-only' - 
expected: only servers in cachegroup 'cdn1-only', actual: got server in %s", 
*s.Cachegroup)
+               }
+               if *s.CDNName != "cdn1" {
+                       t.Fatalf("expected: servers in cachegroup 'cdn1-only' 
to only be in cdn1, actual: servers in cdn %s", *s.CDNName)
+               }
+       }
+       top.Nodes = append(top.Nodes, tc.TopologyNode{
+               Cachegroup: "cdn1-only",
+               Parents:    []int{0},
+       })
+       _, _, err = TOSession.UpdateTopology(top.Name, *top)
+       if err == nil {
+               t.Errorf("making invalid update to topology (cachegroup 
contains only servers from cdn1 while the topology is assigned to delivery 
services in cdn1 and cdn2) - expected: error, actual: nil")
+       }
 }
 
 func DeleteTestTopologies(t *testing.T) {
diff --git a/traffic_ops/traffic_ops_golang/dbhelpers/db_helpers.go 
b/traffic_ops/traffic_ops_golang/dbhelpers/db_helpers.go
index 03ac0a8..53ce367 100644
--- a/traffic_ops/traffic_ops_golang/dbhelpers/db_helpers.go
+++ b/traffic_ops/traffic_ops_golang/dbhelpers/db_helpers.go
@@ -881,6 +881,51 @@ WHERE
        return dses, nil
 }
 
+// GetDeliveryServiceCDNsByTopology returns a slice of CDN IDs for all 
delivery services
+// assigned to the given topology.
+func GetDeliveryServiceCDNsByTopology(tx *sql.Tx, topology string) ([]int, 
error) {
+       q := `
+SELECT
+  COALESCE(ARRAY_AGG(DISTINCT d.cdn_id), '{}'::BIGINT[])
+FROM
+  deliveryservice d
+WHERE
+  d.topology = $1
+`
+       cdnIDs := []int64{}
+       if err := tx.QueryRow(q, topology).Scan(pq.Array(&cdnIDs)); err != nil {
+               return nil, fmt.Errorf("in GetDeliveryServiceCDNsByTopology: 
querying deliveryservices by topology '%s': %v", topology, err)
+       }
+       res := make([]int, len(cdnIDs))
+       for i, id := range cdnIDs {
+               res[i] = int(id)
+       }
+       return res, nil
+}
+
+// CheckCachegroupHasTopologyBasedDeliveryServicesOnCDN returns true if the 
given cachegroup is assigned to
+// any topologies with delivery services assigned on the given CDN.
+func CachegroupHasTopologyBasedDeliveryServicesOnCDN(tx *sql.Tx, cachegroupID 
int, CDNID int) (bool, error) {
+       q := `
+SELECT EXISTS(
+  SELECT
+    1
+  FROM cachegroup c
+  JOIN topology_cachegroup tc on c.name = tc.cachegroup
+  JOIN topology t ON tc.topology = t.name
+  JOIN deliveryservice d on t.name = d.topology
+  WHERE
+    c.id = $1
+    AND d.cdn_id = $2
+)
+`
+       res := false
+       if err := tx.QueryRow(q, cachegroupID, CDNID).Scan(&res); err != nil {
+               return false, fmt.Errorf("in 
CachegroupHasTopologyBasedDeliveryServicesOnCDN: %v", err)
+       }
+       return res, nil
+}
+
 // GetFederationIDForUserIDByXMLID retrieves the ID of the Federation assigned 
to the user defined by
 // userID on the Delivery Service identified by xmlid. If no such federation 
exists, the boolean
 // returned will be 'false', while the error indicates unexpected errors that 
occurred when querying.
diff --git a/traffic_ops/traffic_ops_golang/server/servers.go 
b/traffic_ops/traffic_ops_golang/server/servers.go
index 8a0a2b1..369dee9 100644
--- a/traffic_ops/traffic_ops_golang/server/servers.go
+++ b/traffic_ops/traffic_ops_golang/server/servers.go
@@ -26,7 +26,6 @@ import (
        "encoding/json"
        "errors"
        "fmt"
-       
"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/topology"
        "net"
        "net/http"
        "strconv"
@@ -43,6 +42,7 @@ import (
        
"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/deliveryservice"
        
"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/routing/middleware"
        "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/tenant"
+       
"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/topology"
        
"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/util/ims"
 
        validation "github.com/go-ozzo/ozzo-validation"
@@ -1194,14 +1194,15 @@ func Update(w http.ResponseWriter, r *http.Request) {
                api.HandleErr(w, r, tx, http.StatusNotFound, errors.New("the 
server doesn't exist, cannot update"), nil)
                return
        }
+       origServer := origSer[0]
        originalXMPPID := ""
        originalStatusID := 0
        changeXMPPID := false
-       if origSer[0].XMPPID != nil {
-               originalXMPPID = *origSer[0].XMPPID
+       if origServer.XMPPID != nil {
+               originalXMPPID = *origServer.XMPPID
        }
-       if origSer[0].Status != nil {
-               originalStatusID = *origSer[0].StatusID
+       if origServer.Status != nil {
+               originalStatusID = *origServer.StatusID
        }
 
        var server tc.ServerNullableV2
@@ -1220,7 +1221,7 @@ func Update(w http.ResponseWriter, r *http.Request) {
                if newServer.StatusID != nil && *newServer.StatusID != 
originalStatusID {
                        newServer.StatusLastUpdated = &currentTime
                } else {
-                       newServer.StatusLastUpdated = 
origSer[0].StatusLastUpdated
+                       newServer.StatusLastUpdated = 
origServer.StatusLastUpdated
                }
                serviceInterface, err := validateV3(&newServer, tx)
                if err != nil {
@@ -1228,15 +1229,6 @@ func Update(w http.ResponseWriter, r *http.Request) {
                        return
                }
 
-               cacheGroupIds := []int{*origSer[0].CachegroupID}
-               serverIds := []int{*origSer[0].ID}
-               if *origSer[0].CachegroupID != *newServer.CachegroupID {
-                       if err = topology.CheckForEmptyCacheGroups(inf.Tx, 
cacheGroupIds, true, serverIds); err != nil {
-                               api.HandleErr(w, r, tx, http.StatusBadRequest, 
errors.New("server is the last one in its cachegroup, which is used by a 
topology, so it cannot be moved to another cachegroup: "+err.Error()), nil)
-                               return
-                       }
-               }
-
                server, err = newServer.ToServerV2()
                if err != nil {
                        api.HandleErr(w, r, tx, http.StatusInternalServerError, 
nil, fmt.Errorf("converting v3 server to v2 for update: %v", err))
@@ -1291,6 +1283,24 @@ func Update(w http.ResponseWriter, r *http.Request) {
                }
        }
 
+       if *origServer.CachegroupID != *server.CachegroupID || 
*origServer.CDNID != *server.CDNID {
+               hasDSOnCDN, err := 
dbhelpers.CachegroupHasTopologyBasedDeliveryServicesOnCDN(inf.Tx.Tx, 
*origServer.CachegroupID, *origServer.CDNID)
+               if err != nil {
+                       api.HandleErr(w, r, tx, http.StatusInternalServerError, 
nil, err)
+                       return
+               }
+               CDNIDs := []int{}
+               if hasDSOnCDN {
+                       CDNIDs = append(CDNIDs, *origServer.CDNID)
+               }
+               cacheGroupIds := []int{*origServer.CachegroupID}
+               serverIds := []int{*origServer.ID}
+               if err = topology.CheckForEmptyCacheGroups(inf.Tx, 
cacheGroupIds, CDNIDs, true, serverIds); err != nil {
+                       api.HandleErr(w, r, tx, http.StatusBadRequest, 
errors.New("server is the last one in its cachegroup, which is used by a 
topology, so it cannot be moved to another cachegroup: "+err.Error()), nil)
+                       return
+               }
+       }
+
        server.ID = new(int)
        *server.ID = inf.IntParams["id"]
 
@@ -1624,13 +1634,21 @@ func Delete(w http.ResponseWriter, r *http.Request) {
                api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, 
fmt.Errorf("there are somehow two servers with id %d - cannot delete", id))
                return
        }
-       if version.Major >= 3 {
-               cacheGroupIds := []int{*servers[0].CachegroupID}
-               serverIds := []int{*servers[0].ID}
-               if err := topology.CheckForEmptyCacheGroups(inf.Tx, 
cacheGroupIds, true, serverIds); err != nil {
-                       api.HandleErr(w, r, tx, http.StatusBadRequest, 
errors.New("server is the last one in its cachegroup, which is used by a 
topology: "+err.Error()), nil)
-                       return
-               }
+       server := servers[0]
+       cacheGroupIds := []int{*server.CachegroupID}
+       serverIds := []int{*server.ID}
+       hasDSOnCDN, err := 
dbhelpers.CachegroupHasTopologyBasedDeliveryServicesOnCDN(inf.Tx.Tx, 
*server.CachegroupID, *server.CDNID)
+       if err != nil {
+               api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, 
err)
+               return
+       }
+       CDNIDs := []int{}
+       if hasDSOnCDN {
+               CDNIDs = append(CDNIDs, *server.CDNID)
+       }
+       if err := topology.CheckForEmptyCacheGroups(inf.Tx, cacheGroupIds, 
CDNIDs, true, serverIds); err != nil {
+               api.HandleErr(w, r, tx, http.StatusBadRequest, 
errors.New("server is the last one in its cachegroup, which is used by a 
topology: "+err.Error()), nil)
+               return
        }
 
        userErr, sysErr, errCode = deleteInterfaces(id, tx)
@@ -1652,8 +1670,6 @@ func Delete(w http.ResponseWriter, r *http.Request) {
                return
        }
 
-       server := servers[0]
-
        if inf.Version.Major >= 3 {
                api.WriteRespAlertObj(w, r, tc.SuccessLevel, "Server deleted", 
server)
        } else {
diff --git a/traffic_ops/traffic_ops_golang/topology/topologies.go 
b/traffic_ops/traffic_ops_golang/topology/topologies.go
index bf77b01..57a9745 100644
--- a/traffic_ops/traffic_ops_golang/topology/topologies.go
+++ b/traffic_ops/traffic_ops_golang/topology/topologies.go
@@ -142,7 +142,12 @@ func (topology *TOTopology) Validate() error {
        for index, cacheGroup := range cacheGroups {
                cacheGroupIds[index] = *cacheGroup.ID
        }
-       rules["empty cachegroups"] = 
CheckForEmptyCacheGroups(topology.ReqInfo.Tx, cacheGroupIds, false, nil)
+       dsCDNs, err := 
dbhelpers.GetDeliveryServiceCDNsByTopology(topology.ReqInfo.Tx.Tx, 
topology.Name)
+       if err != nil {
+               log.Errorf("validating topology: %v", err)
+               return errors.New("unable to validate topology")
+       }
+       rules["empty cachegroups"] = 
CheckForEmptyCacheGroups(topology.ReqInfo.Tx, cacheGroupIds, dsCDNs, false, nil)
        rules["required capabilities"] = 
topology.validateDSRequiredCapabilities()
 
        /* Only perform further checks if everything so far is valid */
@@ -159,7 +164,10 @@ func (topology *TOTopology) Validate() error {
        return util.JoinErrs(tovalidate.ToErrors(rules))
 }
 
-func CheckForEmptyCacheGroups(tx *sqlx.Tx, cacheGroupIds []int, 
cachegroupsInTopology bool, excludeServerIds []int) error {
+// CheckForEmptyCacheGroups checks if the cachegroups are empty (altogether) 
or empty in any of the given CDN IDs.
+// If cachegroupsInTopology is true, it will only check cachegroups that are 
used in a topology. Any server IDs in
+// excludeServerIds will not be counted.
+func CheckForEmptyCacheGroups(tx *sqlx.Tx, cacheGroupIds []int, CDNIDs []int, 
cachegroupsInTopology bool, excludeServerIds []int) error {
        if excludeServerIds == nil {
                excludeServerIds = []int{}
        }
@@ -180,14 +188,16 @@ func CheckForEmptyCacheGroups(tx *sqlx.Tx, cacheGroupIds 
[]int, cachegroupsInTop
        }
 
        var (
-               serverCount int
-               cacheGroup  string
-               cacheGroups []string
-               topologies  []string
+               serverCountByCDN int
+               cacheGroup       string
+               cdnID            *int
        )
+       cgServerCountsByCDN := make(map[int]map[string]int)
+       cgServerCounts := make(map[string]int)
+       topologySetByCachegroup := make(map[string]map[string]struct{})
        defer log.Close(rows, "unable to close DB connection when checking for 
cachegroups with no servers")
        for rows.Next() {
-               var scanTo = []interface{}{&cacheGroup, &serverCount}
+               var scanTo = []interface{}{&cacheGroup, &cdnID, 
&serverCountByCDN}
                var topologiesForRow []string
                if cachegroupsInTopology {
                        scanTo = append(scanTo, pq.Array(&topologiesForRow))
@@ -196,23 +206,70 @@ func CheckForEmptyCacheGroups(tx *sqlx.Tx, cacheGroupIds 
[]int, cachegroupsInTop
                        log.Errorf(systemError, err.Error())
                        return baseError
                }
-               if serverCount != 0 {
-                       break
+               if cdnID != nil {
+                       if _, ok := cgServerCountsByCDN[*cdnID]; !ok {
+                               cgServerCountsByCDN[*cdnID] = 
make(map[string]int)
+                       }
+                       cgServerCountsByCDN[*cdnID][cacheGroup] = 
serverCountByCDN
                }
-               cacheGroups = append(cacheGroups, cacheGroup)
+               cgServerCounts[cacheGroup] += serverCountByCDN
+
                if cachegroupsInTopology {
-                       topologies = append(topologies, topologiesForRow...)
+                       if _, ok := topologySetByCachegroup[cacheGroup]; !ok {
+                               topologySetByCachegroup[cacheGroup] = 
make(map[string]struct{})
+                       }
+                       for _, topology := range topologiesForRow {
+                               topologySetByCachegroup[cacheGroup][topology] = 
struct{}{}
+                       }
+               }
+       }
+       topologiesByCachegroup := make(map[string][]string, 
len(topologySetByCachegroup))
+       for cg, topologySet := range topologySetByCachegroup {
+               for topology := range topologySet {
+                       topologiesByCachegroup[cg] = 
append(topologiesByCachegroup[cg], topology)
+               }
+       }
+       emptyCachegroups := []string{}
+       for cg, count := range cgServerCounts {
+               if count == 0 {
+                       messageEntry := cg
+                       if cachegroupsInTopology {
+                               messageEntry += " (in topologies: " + 
strings.Join(topologiesByCachegroup[cg], ", ") + ")"
+                       }
+                       emptyCachegroups = append(emptyCachegroups, 
messageEntry)
                }
        }
 
-       if len(cacheGroups) > 0 {
-               errMessage := "cachegroups with no servers in them: " + 
strings.Join(cacheGroups, ", ")
-               if cachegroupsInTopology {
-                       errMessage += " in topologies: " + 
strings.Join(topologies, ", ")
+       if len(emptyCachegroups) > 0 {
+               errMessage := "cachegroups with no servers in them: " + 
strings.Join(emptyCachegroups, ", ")
+               return errors.New(errMessage)
+       }
+
+       errMessage := []string{}
+       for _, cdnID := range CDNIDs {
+               if _, ok := cgServerCountsByCDN[cdnID]; !ok {
+                       return fmt.Errorf("topology is assigned to delivery 
service on CDN %d, but that CDN has no servers", cdnID)
+               }
+               emptyCachegroupsByCDN := []string{}
+               for cg, serverCount := range cgServerCountsByCDN[cdnID] {
+                       if serverCount == 0 {
+                               emptyCachegroupsByCDN = 
append(emptyCachegroupsByCDN, cg)
+                       }
+               }
+               // check that this CDN has a count for all given cachegroups
+               for cg := range cgServerCounts {
+                       if _, ok := cgServerCountsByCDN[cdnID][cg]; !ok {
+                               emptyCachegroupsByCDN = 
append(emptyCachegroupsByCDN, cg)
+                       }
+               }
+               if len(emptyCachegroupsByCDN) > 0 {
+                       errMessage = append(errMessage, fmt.Sprintf("topology 
is assigned to delivery service(s) on CDN %d, but the following cachegroups 
have no servers in CDN %d: %s", cdnID, cdnID, 
strings.Join(emptyCachegroupsByCDN, ", ")))
                }
-               err = errors.New(errMessage)
        }
-       return err
+       if len(errMessage) > 0 {
+               return errors.New(strings.Join(errMessage, "; "))
+       }
+       return nil
 }
 
 func (topology *TOTopology) nodesInOtherTopologies() ([]tc.TopologyNode, 
map[string][]string, error) {
@@ -769,6 +826,7 @@ func selectEmptyCacheGroupsQuery(cachegroupsInTopology 
bool) string {
        query := fmt.Sprintf(`
                SELECT
                        c."name",
+                       s.cdn_id,
                        COUNT(*) FILTER (
                            WHERE s.id IS NOT NULL
                            AND NOT(s."id" = ANY(CAST(:exclude_server_ids AS 
INT[])))
@@ -777,8 +835,7 @@ func selectEmptyCacheGroupsQuery(cachegroupsInTopology 
bool) string {
                %s
                LEFT JOIN "server" s ON c.id = s.cachegroup
                WHERE c."id" = ANY(CAST(:cachegroup_ids AS BIGINT[]))
-               GROUP BY c."name"
-               ORDER BY server_count
+               GROUP BY c."name", s.cdn_id
        `, topologyNames, joinTopologyCachegroups)
        return query
 }

Reply via email to