package f5 import ( "encoding/json" "flag" "fmt" "net/http" "net/http/httptest" "net/url" "os/exec" "reflect" "strings" "testing" "github.com/gorilla/mux" "github.com/openshift/origin/pkg/cmd/util" kapi "k8s.io/kubernetes/pkg/api" "k8s.io/kubernetes/pkg/watch" routeapi "github.com/openshift/origin/pkg/route/api" ) type ( // mockF5State stores the state necessary to mock the functionality of an F5 // BIG-IP host that the F5 router uses. mockF5State struct { // policies is the set of policies that exist in the mock F5 host. policies map[string]map[string]policyRule // vserverPolicies represents the associations between vservers and policies // in the mock F5 host. vserverPolicies map[string]map[string]bool // certs represents the set of certificates that have been installed into // the mock F5 host. certs map[string]bool // keys represents the set of certificates that have been installed into // the mock F5 host. keys map[string]bool // serverSslProfiles represents the set of server-ssl profiles that exist in // the mock F5 host. serverSslProfiles map[string]bool // clientSslProfiles represents the set of client-ssl profiles that exist in // the mock F5 host. clientSslProfiles map[string]bool // vserverProfiles represents the associations between vservers and // client-ssl and server-ssl profiles in the mock F5 host. // // Note that although the F5 management console displays client and server // profiles separately, the F5 iControl REST interface puts these // associations under a single REST endpoint. vserverProfiles map[string]map[string]bool // datagroups represents the iRules data-groups in the F5 host. For our // purposes, we assume that every data-group maps strings to strings. datagroups map[string]datagroup // iRules represents the iRules that exist in the F5 host. iRules map[string]iRule // vserverIRules represents the associations between vservers and iRules in // the mock F5 host. vserverIRules map[string][]string // partitionPaths represents the partitions that exist in // the mock F5 host. partitionPaths map[string]string // pools represents the pools that exist on the mock F5 host. pools map[string]pool } mockF5 struct { // state is the internal state of the mock F5 BIG-IP host. state mockF5State // server is the mock F5 BIG-IP host, which accepts HTTPS connections and // behaves as an actual F5 BIG-IP host for testing purposes. server *httptest.Server } // A policyCondition describes a single condition for a policy rule to match. policyCondition struct { HttpHost bool `json:"httpHost,omitempty"` HttpUri bool `json:"httpUri,omitempty"` PathSegment bool `json:"pathSegment,omitempty"` Index int `json:"index"` Host bool `json:"host,omitempty"` Values []string `json:"values"` } // A policyRule has a name and comprises a list of conditions and a list of // actions. policyRule struct { conditions []policyCondition } // A datagroup is an associative array. For our purposes, a datagroup maps // strings to strings. datagroup map[string]string // An iRule comprises a string of TCL code. iRule string // A pool comprises a set of strings of the form addr:port. pool map[string]bool ) const ( httpVserverName = "ose-vserver" httpsVserverName = "https-ose-vserver" insecureRoutesPolicyName = "openshift_insecure_routes" secureRoutesPolicyName = "openshift_secure_routes" passthroughIRuleName = "openshift_passthrough_irule" passthroughIRuleDatagroupName = "ssl_passthrough_servername_dg" ) func mockExecCommand(command string, args ...string) *exec.Cmd { // TODO: Parse ssh and scp commands in order to keep track of what files are // being uploaded so that we can perform more validations in HTTP handlers in // the mock F5 host that use files uploaded via SSH. return exec.Command("true") } type Route struct { Name string Method string Pattern string HandlerFunc func(mockF5State) http.HandlerFunc } var f5Routes = []Route{ {"getPolicy", "GET", "/mgmt/tm/ltm/policy/{policyName}", getPolicyHandler}, {"postPolicy", "POST", "/mgmt/tm/ltm/policy", postPolicyHandler}, {"postRule", "POST", "/mgmt/tm/ltm/policy/{policyName}/rules", postRuleHandler}, {"getPolicies", "GET", "/mgmt/tm/ltm/virtual/{vserverName}/policies", getPoliciesHandler}, {"associatePolicyWithVserver", "POST", "/mgmt/tm/ltm/virtual/{vserverName}/policies", associatePolicyWithVserverHandler}, {"getDatagroup", "GET", "/mgmt/tm/ltm/data-group/internal/{datagroupName}", getDatagroupHandler}, {"patchDatagroup", "PATCH", "/mgmt/tm/ltm/data-group/internal/{datagroupName}", patchDatagroupHandler}, {"postDatagroup", "POST", "/mgmt/tm/ltm/data-group/internal", postDatagroupHandler}, {"getIRule", "GET", "/mgmt/tm/ltm/rule/{iRuleName}", getIRuleHandler}, {"postIRule", "POST", "/mgmt/tm/ltm/rule", postIRuleHandler}, {"getVserver", "GET", "/mgmt/tm/ltm/virtual/{vserverName}", getVserverHandler}, {"patchVserver", "PATCH", "/mgmt/tm/ltm/virtual/{vserverName}", patchVserverHandler}, {"getPartition", "GET", "/mgmt/tm/sys/folder/{partitionPath}", getPartitionPath}, {"postPartition", "POST", "/mgmt/tm/sys/folder", postPartitionPathHandler}, {"postPool", "POST", "/mgmt/tm/ltm/pool", postPoolHandler}, {"deletePool", "DELETE", "/mgmt/tm/ltm/pool/{poolName}", deletePoolHandler}, {"getPoolMembers", "GET", "/mgmt/tm/ltm/pool/{poolName}/members", getPoolMembersHandler}, {"postPoolMember", "POST", "/mgmt/tm/ltm/pool/{poolName}/members", postPoolMemberHandler}, {"deletePoolMember", "DELETE", "/mgmt/tm/ltm/pool/{poolName}/members/{memberName}", deletePoolMemberHandler}, {"getRules", "GET", "/mgmt/tm/ltm/policy/{policyName}/rules", getRulesHandler}, {"postCondition", "POST", "/mgmt/tm/ltm/policy/{policyName}/rules/{ruleName}/conditions", postConditionHandler}, {"postAction", "POST", "/mgmt/tm/ltm/policy/{policyName}/rules/{ruleName}/actions", postActionHandler}, {"deleteRule", "DELETE", "/mgmt/tm/ltm/policy/{policyName}/rules/{ruleName}", deleteRuleHandler}, {"postSslCert", "POST", "/mgmt/tm/sys/crypto/cert", postSslCertHandler}, {"postSslKey", "POST", "/mgmt/tm/sys/crypto/key", postSslKeyHandler}, {"postClientSslProfile", "POST", "/mgmt/tm/ltm/profile/client-ssl", postClientSslProfileHandler}, {"deleteClientSslProfile", "DELETE", "/mgmt/tm/ltm/profile/client-ssl/{profileName}", deleteClientSslProfileHandler}, {"postServerSslProfile", "POST", "/mgmt/tm/ltm/profile/server-ssl", postServerSslProfileHandler}, {"deleteServerSslProfile", "DELETE", "/mgmt/tm/ltm/profile/server-ssl/{profileName}", deleteServerSslProfileHandler}, {"associateProfileWithVserver", "POST", "/mgmt/tm/ltm/virtual/{vserverName}/profiles", associateProfileWithVserver}, {"deleteSslVserverProfile", "DELETE", "/mgmt/tm/ltm/virtual/{vserverName}/profiles/{profileName}", deleteSslVserverProfileHandler}, {"deleteSslKey", "DELETE", "/mgmt/tm/sys/file/ssl-key/{keyName}", deleteSslKeyHandler}, {"deleteSslCert", "DELETE", "/mgmt/tm/sys/file/ssl-cert/{certName}", deleteSslCertHandler}, } func newF5Routes(mockF5State mockF5State) *mux.Router { mockF5 := mux.NewRouter().StrictSlash(true) for _, route := range f5Routes { mockF5. Methods(route.Method). Path(route.Pattern). Name(route.Name). Handler(route.HandlerFunc(mockF5State)) } return mockF5 } // newTestRouterWithState creates a new F5 plugin with a mock F5 BIG-IP server // initialized from the given mock F5 state and returns pointers to the plugin // and mock server. Note that these pointers will be nil if an error is // returned. func newTestRouterWithState(state mockF5State, partitionPath string) (*F5Plugin, *mockF5, error) { routerLogLevel := util.Env("TEST_ROUTER_LOGLEVEL", "") if routerLogLevel != "" { flag.Set("v", routerLogLevel) } execCommand = mockExecCommand server := httptest.NewTLSServer(newF5Routes(state)) url, err := url.Parse(server.URL) if err != nil { return nil, nil, fmt.Errorf("Failed to parse URL of mock F5 host; URL: %s, error: %v", url, err) } f5PluginTestCfg := F5PluginConfig{ Host: url.Host, Username: "admin", Password: "password", HttpVserver: httpVserverName, HttpsVserver: httpsVserverName, PrivateKey: "/dev/null", Insecure: true, PartitionPath: partitionPath, } router, err := NewF5Plugin(f5PluginTestCfg) if err != nil { return nil, nil, fmt.Errorf("Failed to create new F5 router: %v", err) } mockF5 := &mockF5{state: state, server: server} return router, mockF5, nil } // newTestRouter creates a new F5 plugin with a mock F5 BIG-IP server and // returns pointers to the plugin and mock server. Note that these pointers // will be nil if an error is returned. func newTestRouter(partitionPath string) (*F5Plugin, *mockF5, error) { pathKey := strings.Replace(partitionPath, "/", "~", -1) state := mockF5State{ policies: map[string]map[string]policyRule{}, vserverPolicies: map[string]map[string]bool{ httpVserverName: {}, httpsVserverName: {}, }, certs: map[string]bool{}, keys: map[string]bool{}, serverSslProfiles: map[string]bool{}, clientSslProfiles: map[string]bool{}, vserverProfiles: map[string]map[string]bool{ httpsVserverName: {}, }, datagroups: map[string]datagroup{}, iRules: map[string]iRule{}, vserverIRules: map[string][]string{}, // Add the default /Common partition path. partitionPaths: map[string]string{pathKey: partitionPath}, pools: map[string]pool{}, } return newTestRouterWithState(state, partitionPath) } func (f5 *mockF5) close() { f5.server.Close() } func validatePolicyName(response http.ResponseWriter, request *http.Request, f5state mockF5State, policyName string) bool { _, ok := f5state.policies[policyName] if !ok { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":404,"errorStack":[],"message":"01020036:3: The requested Policy (/Common/%s) was not found."}`, policyName) return false } return true } func getPolicyHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) policyName := vars["policyName"] if !validatePolicyName(response, request, f5state, policyName) { return } fmt.Fprintf(response, `{"controls":["forwarding"],"fullPath":"%s","generation":1,"kind":"tm:ltm:policy:policystate","name":"%s","requires":["http"],"rulesReference":{"isSubcollection":true,"link":"https://localhost/mgmt/tm/ltm/policy/~Common~%s/rules?ver=11.6.0"},"selfLink":"https://localhost/mgmt/tm/ltm/policy/%s?ver=11.6.0","strategy":"/Common/best-match"}`, policyName, policyName, policyName, policyName) } } func OK(response http.ResponseWriter) { fmt.Fprint(response, `{"code":200,"message":"OK"}`) } func postPolicyHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { payload := struct { Name string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) policyName := payload.Name f5state.policies[policyName] = map[string]policyRule{} OK(response) } } func postRuleHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) policyName := vars["policyName"] if !validatePolicyName(response, request, f5state, policyName) { return } payload := struct { Name string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) ruleName := payload.Name newRule := policyRule{[]policyCondition{}} f5state.policies[policyName][ruleName] = newRule OK(response) } } func validateVserverName(response http.ResponseWriter, request *http.Request, f5state mockF5State, vserverName string) bool { if !recogniseVserver(vserverName) { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":404,"errorStack":[],"message":"01020036:3: The requested Virtual Server (/Common/%s) was not found."}`, vserverName) return false } return true } func getPoliciesHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) vserverName := vars["vserverName"] if !validateVserverName(response, request, f5state, vserverName) { return } fmt.Fprint(response, `{"items":[{"controls":["classification"],"fullPath":"/Common/_sys_CEC_SSL_client_policy","generation":1,"hints":["no-write","no-delete","no-exclusion"],"kind":"tm:ltm:policy:policystate","name":"_sys_CEC_SSL_client_policy","partition":"Common","requires":["ssl-persistence"],"rulesReference":{"isSubcollection":true,"link":"https://localhost/mgmt/tm/ltm/policy/~Common~_sys_CEC_SSL_client_policy/rules?ver=11.6.0"},"selfLink":"https://localhost/mgmt/tm/ltm/policy/~Common~_sys_CEC_SSL_client_policy?ver=11.6.0","strategy":"/Common/first-match"},{"controls":["classification"],"fullPath":"/Common/_sys_CEC_SSL_server_policy","generation":1,"hints":["no-write","no-delete","no-exclusion"],"kind":"tm:ltm:policy:policystate","name":"_sys_CEC_SSL_server_policy","partition":"Common","requires":["ssl-persistence"],"rulesReference":{"isSubcollection":true,"link":"https://localhost/mgmt/tm/ltm/policy/~Common~_sys_CEC_SSL_server_policy/rules?ver=11.6.0"},"selfLink":"https://localhost/mgmt/tm/ltm/policy/~Common~_sys_CEC_SSL_server_policy?ver=11.6.0","strategy":"/Common/first-match"},{"controls":["classification"],"fullPath":"/Common/_sys_CEC_video_policy","generation":1,"hints":["no-write","no-delete","no-exclusion"],"kind":"tm:ltm:policy:policystate","name":"_sys_CEC_video_policy","partition":"Common","requires":["http"],"rulesReference":{"isSubcollection":true,"link":"https://localhost/mgmt/tm/ltm/policy/~Common~_sys_CEC_video_policy/rules?ver=11.6.0"},"selfLink":"https://localhost/mgmt/tm/ltm/policy/~Common~_sys_CEC_video_policy?ver=11.6.0","strategy":"/Common/first-match"}`) for policyName := range f5state.vserverPolicies[vserverName] { fmt.Fprintf(response, `,{"controls":["forwarding"],"fullPath":"/Common/%s","generation":1,"kind":"tm:ltm:policy:policystate","name":"%s","partition":"Common","requires":["http"],"rulesReference":{"isSubcollection":true,"link":"https://localhost/mgmt/tm/ltm/policy/~Common~%s/rules?ver=11.6.0"},"selfLink":"https://localhost/mgmt/tm/ltm/policy/~Common~%s?ver=11.6.0","strategy":"/Common/best-match"}`, policyName, policyName, policyName, policyName) } fmt.Fprintf(response, `],"kind":"tm:ltm:policy:policycollectionstate","selfLink":"https://localhost/mgmt/tm/ltm/policy?ver=11.6.0"}`) } } func recogniseVserver(vserverName string) bool { return vserverName == httpVserverName || vserverName == httpsVserverName } func associatePolicyWithVserverHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) vserverName := vars["vserverName"] payload := struct { Name string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) policyName := payload.Name validVserver := recogniseVserver(vserverName) _, validPolicy := f5state.policies[policyName] if !validVserver || !validPolicy { response.WriteHeader(http.StatusNotFound) } if !validVserver && !validPolicy { fmt.Fprintf(response, `{"code":400,"errorStack":[],"message":"01070712:3: Values (%s) specified for virtual server policy (/Common/%s %s): foreign key index (policy_FK) do not point at an item that exists in the database."}`, policyName, vserverName, policyName) return } if !validVserver { fmt.Fprintf(response, `{"code":400,"errorStack":[],"message":"01070712:3: Values (/Common/%s) specified for virtual server policy (/Common/%s /Common/%s): foreign key index (vs_FK) do not point at an item that exists in the database."}`, vserverName, vserverName, policyName) return } if !validPolicy { fmt.Fprintf(response, `{"code":404,"errorStack":[],"message":"01020036:3: The requested policy (%s) was not found."}`, policyName) return } f5state.vserverPolicies[vserverName][policyName] = true OK(response) } } func validateDatagroupName(response http.ResponseWriter, request *http.Request, f5state mockF5State, datagroupName string) bool { _, ok := f5state.datagroups[datagroupName] if !ok { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":404,"errorStack":[],"message":"01020036:3: The requested value list (/Common/%s) was not found."}`, datagroupName) return false } return true } func getDatagroupHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) datagroupName := vars["datagroupName"] if !validateDatagroupName(response, request, f5state, datagroupName) { return } datagroup := f5state.datagroups[datagroupName] fmt.Fprintf(response, `{"fullPath":"%s","generation":1556,"kind":"tm:ltm:data-group:internal:internalstate","name":"%s","records":[`, datagroupName, datagroupName) first := true for key, value := range datagroup { if first { first = false } else { fmt.Fprintf(response, ",") } fmt.Fprintf(response, `{"data":"%s","name":"%s"}`, value, key) } fmt.Fprintf(response, `],"selfLink":"https://localhost/mgmt/tm/ltm/data-group/internal/%s?ver=11.6.0","type":"string"}`, datagroupName) } } func patchDatagroupHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) datagroupName := vars["datagroupName"] if !validateDatagroupName(response, request, f5state, datagroupName) { return } type record struct { Key string `json:"name"` Value string `json:"data"` } payload := struct { Records []record `json:"records"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) dg := datagroup{} for _, record := range payload.Records { dg[record.Key] = record.Value } f5state.datagroups[datagroupName] = dg OK(response) } } func postDatagroupHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { payload := struct { Name string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) datagroupName := payload.Name _, datagroupAlreadyExists := f5state.datagroups[datagroupName] if datagroupAlreadyExists { response.WriteHeader(http.StatusConflict) fmt.Fprintf(response, `{"code":409,"errorStack":[],"message":"01020066:3: The requested value list (/Common/%s) already exists in partition Common."}`, datagroupName) return } f5state.datagroups[datagroupName] = map[string]string{} OK(response) } } func getIRuleHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) iRuleName := vars["iRuleName"] iRuleCode, ok := f5state.iRules[iRuleName] if !ok { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":404,"errorStack":[],"message":"01020036:3: The requested iRule (/Common/%s) was not found."}`, iRuleName) return } fmt.Fprintf(response, `{"apiAnonymous":"%s","fullPath":"%s","generation":386,"kind":"tm:ltm:rule:rulestate","name":"%s","selfLink":"https://localhost/mgmt/tm/ltm/rule/%s?ver=11.6.0"}`, iRuleCode, iRuleName, iRuleName, iRuleName) } } func postIRuleHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { payload := struct { Name string `json:"name"` Code string `json:"apiAnonymous"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) iRuleName := payload.Name iRuleCode := payload.Code _, iRuleAlreadyExists := f5state.iRules[iRuleName] if iRuleAlreadyExists { response.WriteHeader(http.StatusConflict) fmt.Fprintf(response, `{"code":409,"errorStack":[],"message":"01020066:3: The requested iRule (/Common/%s) already exists in partition Common."}`, iRuleName) return } // F5 iControl REST does not know how to parse \u escapes. if badCharIdx := strings.Index(string(iRuleCode), `\u`); badCharIdx != -1 { response.WriteHeader(http.StatusBadRequest) truncateAt := badCharIdx if truncateAt > 86 { truncateAt = 86 } fmt.Fprintf(response, `{"code":400,"message":"can't parse TCL script beginning with\n%.*s\n","errorStack":[]}`, truncateAt, iRuleCode) return } f5state.iRules[iRuleName] = iRule(iRuleCode) OK(response) } } func getVserverHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) vserverName := vars["vserverName"] if !validateVserverName(response, request, f5state, vserverName) { return } description := "OpenShift Enterprise Virtual Server for HTTPS connections" destination := "10.1.1.1:443" if vserverName == httpVserverName { description = "OpenShift Enterprise Virtual Server for HTTP connections" destination = "10.1.1.2:80" } fmt.Fprintf(response, `{"addressStatus":"yes","autoLasthop":"default","cmpEnabled":"yes","connectionLimit":0,"description":"%s","destination":"/Common/%s","enabled":true,"fullPath":"%s","generation":387,"gtmScore":0,"ipProtocol":"tcp","kind":"tm:ltm:virtual:virtualstate","mask":"255.255.255.255","mirror":"disabled","mobileAppTunnel":"disabled","name":"%s","nat64":"disabled","policiesReference":{"isSubcollection":true,"link":"https://localhost/mgmt/tm/ltm/virtual/~Common~%s/policies?ver=11.6.0"},"profilesReference":{"isSubcollection":true,"link":"https://localhost/mgmt/tm/ltm/virtual/~Common~%s/profiles?ver=11.6.0"},"rateLimit":"disabled","rateLimitDstMask":0,"rateLimitMode":"object","rateLimitSrcMask":0,"rules":[`, description, destination, vserverName, vserverName, vserverName, vserverName) first := true for _, ruleName := range f5state.vserverIRules[vserverName] { if first { first = false } else { fmt.Fprintf(response, ",") } fmt.Fprintf(response, `"/Common/%s"`, ruleName) } fmt.Fprintf(response, `],"selfLink":"https://localhost/mgmt/tm/ltm/virtual/%s?ver=11.6.0","source":"0.0.0.0/0","sourceAddressTranslation":{"type":"none"},"sourcePort":"preserve","synCookieStatus":"not-activated","translateAddress":"enabled","translatePort":"enabled","vlansDisabled":true,"vsIndex":11}`, vserverName) } } func patchVserverHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) vserverName := vars["vserverName"] if !validateVserverName(response, request, f5state, vserverName) { return } payload := struct { Rules []string `json:"rules"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) iRules := []string(payload.Rules) f5state.vserverIRules[vserverName] = iRules OK(response) } } func validatePartitionPath(response http.ResponseWriter, request *http.Request, f5state mockF5State, partitionPath string) bool { _, ok := f5state.partitionPaths[partitionPath] if !ok { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":404,"errorStack":[],"message":"01020036:3: The requested folder (%s) was not found."}`, partitionPath) return false } return true } func getPartitionPath(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) partitionPath := vars["partitionPath"] if !validatePartitionPath(response, request, f5state, partitionPath) { return } fullPath := f5state.partitionPaths[partitionPath] parts := strings.Split(fullPath, "/") partitionName := parts[0] if len(parts) > 1 { partitionName = parts[1] } fmt.Fprintf(response, `{"deviceGroup":"%s/ose-sync-failover","fullPath":"%s","generation":580,"hidden":"false","inheritedDevicegroup":"true","inheritedTrafficGroup":"true","kind":"tm:sys:folder:folderstate","name":"%s","noRefCheck":"false","selfLink":"https://localhost/mgmt/tm/sys/folder/%s?ver=11.6.0","subPath":"/"}`, fullPath, fullPath, partitionName, partitionPath) } } func postPartitionPathHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { payload := struct { Name string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) partitionPath := payload.Name // Convert / form to ~ form and add it to the map. This // makes the GETs simpler: check/get the key in the map. pathKey := strings.Replace(partitionPath, "/", "~", -1) f5state.partitionPaths[pathKey] = partitionPath OK(response) } } func postPoolHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { payload := struct { Name string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) poolName := payload.Name _, poolAlreadyExists := f5state.pools[poolName] if poolAlreadyExists { response.WriteHeader(http.StatusConflict) fmt.Fprintf(response, `{"code":409,"errorStack":[],"message":"01020066:3: The requested Pool (/Common/%s) already exists in partition Common."}`, poolName) return } f5state.pools[poolName] = pool{} OK(response) } } func validatePoolName(response http.ResponseWriter, request *http.Request, f5state mockF5State, poolName string) bool { _, ok := f5state.pools[poolName] if !ok { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":404,"errorStack":[],"message":"01020036:3:The requested Pool (/Common/%s) was not found."}`, poolName) return false } return true } func deletePoolHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) poolName := vars["poolName"] if !validatePoolName(response, request, f5state, poolName) { return } // TODO: Validate that no rule references the pool. delete(f5state.pools, poolName) OK(response) } } func getPoolMembersHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) poolName := vars["poolName"] if !validatePoolName(response, request, f5state, poolName) { return } fmt.Fprint(response, `{"items":[`) first := true for member := range f5state.pools[poolName] { if first { first = false } else { fmt.Fprintf(response, ",") } addr := strings.Split(member, ":")[0] fmt.Fprintf(response, `{"address":"%s","connectionLimit":0,"dynamicRatio":1,"ephemeral":"false","fqdn":{"autopopulate":"disabled"},"fullPath":"/Common/%s","generation":1190,"inheritProfile":"enabled","kind":"tm:ltm:pool:members:membersstate","logging":"disabled","monitor":"default","name":"%s","partition":"Common","priorityGroup":0,"rateLimit":"disabled","ratio":1,"selfLink":"https://localhost/mgmt/tm/ltm/pool/%s/members/~Common~%s?ver=11.6.0","session":"monitor-enabled","state":"up"}`, addr, member, member, member, member) } fmt.Fprintf(response, `],"kind":"tm:ltm:pool:members:memberscollectionstate","selfLink":"https://localhost/mgmt/tm/ltm/pool/%s/members?ver=11.6.0"}`, poolName) } } func postPoolMemberHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) poolName := vars["poolName"] if !validatePoolName(response, request, f5state, poolName) { return } payload := struct { Member string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) memberName := payload.Member _, memberAlreadyExists := f5state.pools[poolName][memberName] if memberAlreadyExists { response.WriteHeader(http.StatusConflict) fmt.Fprintf(response, `{"code":409,"message":"01020066:3: The requested Pool Member (/Common/%s /Common/%s) already exists in partition Common.","errorStack":[]}`, poolName, strings.Replace(memberName, ":", " ", 1)) return } f5state.pools[poolName][memberName] = true OK(response) } } func deletePoolMemberHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) poolName := vars["poolName"] memberName := vars["memberName"] if !validatePoolName(response, request, f5state, poolName) { return } _, foundMember := f5state.pools[poolName][memberName] if !foundMember { fmt.Fprintf(response, `{"code":404,"message":"01020036:3: The requested Pool Member (/Common/%s /Common/%s) was not found.","errorStack":[]}`, poolName, strings.Replace(memberName, ":", " ", 1)) } delete(f5state.pools[poolName], memberName) OK(response) } } func getRulesHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) policyName := vars["policyName"] if !validatePolicyName(response, request, f5state, policyName) { return } fmt.Fprint(response, `{"items": [`) first := true for ruleName := range f5state.policies[policyName] { if first { first = false } else { fmt.Fprintf(response, ",") } fmt.Fprintf(response, `{"actionsReference":{"isSubcollection":true,"link":"https://localhost/mgmt/tm/ltm/policy/%s/rules/%s/actions?ver=11.6.0"},"conditionsReference":{"isSubcollection":true,"link":"https://localhost/mgmt/tm/ltm/policy/%s/rules/%s/conditions?ver=11.6.0"},"fullPath":"%s","generation":1218,"kind":"tm:ltm:policy:rules:rulesstate","name":"%s","ordinal":0,"selfLink":"https://localhost/mgmt/tm/ltm/policy/%s/rules/%s?ver=11.6.0"}`, policyName, ruleName, policyName, ruleName, ruleName, ruleName, policyName, ruleName) } fmt.Fprintf(response, `],"kind":"tm:ltm:policy:rules:rulescollectionstate","selfLink":"https://localhost/mgmt/tm/ltm/policy/%s/rules?ver=11.6.0"}`, policyName) } } func validateRuleName(response http.ResponseWriter, request *http.Request, f5state mockF5State, policyName, ruleName string) bool { for rule := range f5state.policies[policyName] { if rule == ruleName { return true } } response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":404,"errorStack":[],"message":"01020036:3: The requested policy rule (/Common/%s %s) was not found."}`, policyName, ruleName) return false } func postConditionHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) policyName := vars["policyName"] ruleName := vars["ruleName"] if !validatePolicyName(response, request, f5state, policyName) { return } foundRule := validateRuleName(response, request, f5state, policyName, ruleName) if !foundRule { return } payload := policyCondition{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) // TODO: Validate more fields in the payload: equals, request, maybe others. conditions := f5state.policies[policyName][ruleName].conditions conditions = append(conditions, payload) f5state.policies[policyName][ruleName] = policyRule{conditions} OK(response) } } func postActionHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) policyName := vars["policyName"] ruleName := vars["ruleName"] if !validatePolicyName(response, request, f5state, policyName) { return } foundRule := validateRuleName(response, request, f5state, policyName, ruleName) if !foundRule { return } // TODO: Validate payload. OK(response) } } func deleteRuleHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) policyName := vars["policyName"] ruleName := vars["ruleName"] if !validatePolicyName(response, request, f5state, policyName) { return } foundRule := validateRuleName(response, request, f5state, policyName, ruleName) if !foundRule { return } delete(f5state.policies[policyName], ruleName) OK(response) } } func postSslCertHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { payload := struct { Name string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) certName := payload.Name // TODO: Validate filename (which would require more elaborate mocking of // the ssh and scp commands in mockExecCommand). // F5 adds the extension to the filename specified in the payload, and this // extension must be included in subsequent REST calls that reference the // file. f5state.certs[fmt.Sprintf("%s.crt", certName)] = true OK(response) } } func postSslKeyHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { payload := struct { Name string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) keyName := payload.Name // TODO: Validate filename (which would require more elaborate mocking of // the ssh and scp commands in mockExecCommand). f5state.keys[fmt.Sprintf("%s.key", keyName)] = true OK(response) } } func validateClientKey(response http.ResponseWriter, request *http.Request, f5state mockF5State, keyName string) bool { _, ok := f5state.keys[keyName] if !ok { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":400,"message":"010717e3:3: Client SSL profile must have RSA certificate/key pair.","errorStack":[]}`) return false } return true } func validateCert(response http.ResponseWriter, request *http.Request, f5state mockF5State, certName string) bool { _, ok := f5state.certs[certName] if !ok { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":400,"message":"0107134a:3: File object by name (%s) is missing.","errorStack":[]}`, certName) return false } return true } func postClientSslProfileHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { payload := struct { CertificateName string `json:"cert"` KeyName string `json:"key"` Name string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) keyName := payload.KeyName certificateName := payload.CertificateName clientSslProfileName := payload.Name // Complain about name collision first because F5 does. _, clientSslProfileAlreadyExists := f5state.clientSslProfiles[clientSslProfileName] if clientSslProfileAlreadyExists { response.WriteHeader(http.StatusConflict) fmt.Fprintf(response, `{"code":409,"message":"01020066:3: The requested ClientSSL Profile (/Common/%s) already exists in partition Common.","errorStack":[]`, clientSslProfileName) return } // Check key before certificate because if both are missing, F5 returns the // same error message as if only the key were missing. if !validateClientKey(response, request, f5state, keyName) { return } if !validateCert(response, request, f5state, certificateName) { return } // The name for a client-ssl profile cannot collide with the name of any // server-ssl profile either, but F5 complains about name collisions with // server-ssl profiles only if the above checks pass. _, serverSslProfileAlreadyExists := f5state.serverSslProfiles[clientSslProfileName] if serverSslProfileAlreadyExists { response.WriteHeader(http.StatusConflict) fmt.Fprintf(response, `{"code":400,"message":"01070293:3: The profile name (/Common/%s) is already assigned to another profile.","errorStack":[]}`, clientSslProfileName) return } f5state.clientSslProfiles[clientSslProfileName] = true OK(response) } } func deleteClientSslProfileHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) clientSslProfileName := vars["profileName"] _, clientSslProfileFound := f5state.clientSslProfiles[clientSslProfileName] if !clientSslProfileFound { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":404,"message":"01020036:3: The requested ClientSSL Profile (/Common/%s) was not found.","errorStack":[]}`, clientSslProfileName) return } delete(f5state.clientSslProfiles, clientSslProfileName) OK(response) } } func validateServerKey(response http.ResponseWriter, request *http.Request, f5state mockF5State, keyName string) bool { _, ok := f5state.keys[keyName] if !ok { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":400,"message":"0107134a:3: File object by name (%s) is missing.","errorStack":[]}`, keyName) return false } return true } func postServerSslProfileHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { payload := struct { CertificateName string `json:"chain"` Name string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) certificateName := payload.CertificateName serverSslProfileName := payload.Name // Complain about name collision first because F5 does. _, serverSslProfileAlreadyExists := f5state.serverSslProfiles[serverSslProfileName] if serverSslProfileAlreadyExists { response.WriteHeader(http.StatusConflict) fmt.Fprintf(response, `{"code":409,"message":"01020066:3: The requested ServerSSL Profile (/Common/%s) already exists in partition Common.","errorStack":[]}`, serverSslProfileName) return } _, clientSslProfileAlreadyExists := f5state.clientSslProfiles[serverSslProfileName] if clientSslProfileAlreadyExists { response.WriteHeader(http.StatusConflict) fmt.Fprintf(response, `{"code":400,"message":"01070293:3: The profile name (/Common/%s) is already assigned to another profile.","errorStack":[]}`, serverSslProfileName) return } if !validateCert(response, request, f5state, certificateName) { return } f5state.serverSslProfiles[serverSslProfileName] = true OK(response) } } func deleteServerSslProfileHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) serverSslProfileName := vars["profileName"] _, serverSslProfileFound := f5state.serverSslProfiles[serverSslProfileName] if !serverSslProfileFound { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":404,"message":"01020036:3: The requested ServerSSL Profile (/Common/%s) was not found.","errorStack":[]}`, serverSslProfileName) return } delete(f5state.serverSslProfiles, serverSslProfileName) OK(response) } } func associateProfileWithVserver(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) vserverName := vars["vserverName"] if !validateVserverName(response, request, f5state, vserverName) { return } payload := struct { Name string `json:"name"` }{} decoder := json.NewDecoder(request.Body) decoder.Decode(&payload) profileName := payload.Name f5state.vserverProfiles[vserverName][profileName] = true OK(response) } } func deleteSslVserverProfileHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) vserverName := vars["vserverName"] profileName := vars["profileName"] if !validateVserverName(response, request, f5state, vserverName) { return } delete(f5state.vserverProfiles[vserverName], profileName) OK(response) } } func deleteSslKeyHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) keyName := vars["keyName"] _, keyFound := f5state.keys[keyName] if !keyFound { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":404,"message":"01020036:3: The requested Certificate Key File (/Common/%s) was not found.","errorStack":[]}`, keyName) return } // TODO: Validate that the key is not in use (which will require keeping // track of more state). //{"code":400,"message":"01071349:3: File object by name (/Common/%s) is in use.","errorStack":[]} delete(f5state.keys, keyName) OK(response) } } func deleteSslCertHandler(f5state mockF5State) http.HandlerFunc { return func(response http.ResponseWriter, request *http.Request) { vars := mux.Vars(request) certName := vars["certName"] _, certFound := f5state.certs[certName] if !certFound { response.WriteHeader(http.StatusNotFound) fmt.Fprintf(response, `{"code":404,"message":"01020036:3: The requested Certificate File (/Common/%s) was not found.","errorStack":[]}`, certName) return } // TODO: Validate that the key is not in use (which will require keeping // track of more state). //{"code":400,"message":"01071349:3: File object by name (/Common/openshift_route_default_route-reencrypt-https-cert.crt) is in use.","errorStack":[]} delete(f5state.certs, certName) OK(response) } } // TestInitializeF5Plugin initializes the F5 plug-in with a mock unconfigured F5 // BIG-IP host and validates the configuration of the F5 BIG-IP host after the // plug-in has performed its initialization. func TestInitializeF5Plugin(t *testing.T) { router, mockF5, err := newTestRouter(F5DefaultPartitionPath) if err != nil { t.Fatalf("Failed to initialize test router: %v", err) } defer mockF5.close() // The policy for secure routes and the policy for insecure routes should // exist. expectedPolicies := []string{insecureRoutesPolicyName, secureRoutesPolicyName} for _, policyName := range expectedPolicies { _, ok := mockF5.state.policies[policyName] if !ok { t.Errorf("%s policy was not created; policies map: %v", policyName, mockF5.state.policies) } } // The HTTPS vserver should have the policy for secure routes associated. foundSecureRoutesPolicy := false for policyName := range mockF5.state.vserverPolicies[httpsVserverName] { if policyName == secureRoutesPolicyName { foundSecureRoutesPolicy = true } else { t.Errorf("Encountered unexpected policy associated to vserver %s: %s", httpsVserverName, policyName) } } if !foundSecureRoutesPolicy { t.Errorf("%s policy was not associated with vserver %s.", secureRoutesPolicyName, httpsVserverName) } // The HTTP vserver should have the policy for insecure routes associated. foundInsecureRoutesPolicy := false for policyName := range mockF5.state.vserverPolicies[httpVserverName] { if policyName == insecureRoutesPolicyName { foundInsecureRoutesPolicy = true } else { t.Errorf("Encountered unexpected policy associated to vserver %s: %s", httpVserverName, policyName) } } if !foundInsecureRoutesPolicy { t.Errorf("%s policy was not associated with vserver %s.", insecureRoutesPolicyName, httpVserverName) } // The datagroup for passthrough routes should exist. foundPassthroughIRuleDatagroup := false for datagroupName := range mockF5.state.datagroups { if datagroupName == passthroughIRuleDatagroupName { foundPassthroughIRuleDatagroup = true } } if !foundPassthroughIRuleDatagroup { t.Errorf("%s datagroup was not created.", passthroughIRuleDatagroupName) } // The passthrough iRule should exist and should reference the datagroup for // passthrough routes. foundPassthroughIRule := false for iRuleName, iRuleCode := range mockF5.state.iRules { if iRuleName == passthroughIRuleName { foundPassthroughIRule = true if !strings.Contains(string(iRuleCode), passthroughIRuleDatagroupName) { t.Errorf("iRule for passthrough routes exists, but its body does not"+ " reference the datagroup for passthrough routes.\n"+ "iRule name: %s\nDatagroup name: %s\niRule code: %s", iRuleName, passthroughIRuleDatagroupName, iRuleCode) } } else { t.Errorf("Encountered unexpected iRule: %s", iRuleName) } } if !foundPassthroughIRule { t.Errorf("%s iRule was not created.", passthroughIRuleName) } // The HTTPS vserver should have the passthrough iRule associated. foundPassthroughIRuleUnderVserver := false for _, iRuleName := range mockF5.state.vserverIRules[httpsVserverName] { if iRuleName == passthroughIRuleName { foundPassthroughIRuleUnderVserver = true } else { t.Errorf("Encountered unexpected iRule associated with vserver %s: %s", httpsVserverName, iRuleName) } } if !foundPassthroughIRuleUnderVserver { t.Errorf("%s iRule was not associated with vserver %s.", passthroughIRuleName, httpsVserverName) } // The HTTP vserver should have no iRules associated. if len(mockF5.state.vserverIRules[httpVserverName]) != 0 { t.Errorf("Vserver %s has iRules associated: %v", httpVserverName, mockF5.state.vserverIRules[httpVserverName]) } // Initialization should be idempotent. // Warning: This is off-label use of DeepCopy! savedMockF5State, err := kapi.Scheme.DeepCopy(mockF5.state) if err != nil { t.Errorf("Failed to deepcopy mock F5 state for idempotency check: %v", err) } router.F5Client.Initialize() if !reflect.DeepEqual(savedMockF5State, mockF5.state) { t.Errorf("Initialize method should be idempotent but it is not.\n"+ "State after first initialization: %v\n"+ "State after second initialization: %v\n", savedMockF5State, mockF5.state) } } // TestF5PartitionPath creates an F5 router instance with a specific partition. func TestF5RouterPartition(t *testing.T) { testCases := []struct { // name of the test name string // partition path. partition string }{ { name: "Default/Common partition", partition: "/Common", }, { name: "Custom partition", partition: "/OSPartA", }, { name: "Sub partition", partition: "/OSPartA/ShardOne", }, { name: "Layered sub partition", partition: "/OSPartA/region1/zone4/Shard-7", }, } for _, tc := range testCases { _, mockF5, err := newTestRouter(tc.partition) if err != nil { t.Fatalf("Test case %q failed to initialize test router: %v", tc.name, err) } name := strings.Replace(tc.partition, "/", "~", -1) _, ok := mockF5.state.partitionPaths[name] if !ok { t.Fatalf("Test case %q missing partition key %s", tc.name, name) } mockF5.close() } } // TestHandleEndpoints tests endpoint watch events and validates that the state // of the F5 client object is as expected after each event. func TestHandleEndpoints(t *testing.T) { router, mockF5, err := newTestRouter(F5DefaultPartitionPath) if err != nil { t.Fatalf("Failed to initialize test router: %v", err) } defer mockF5.close() testCases := []struct { // name is a human readable name for the test case. name string // type is the type to be passed to the HandleEndpoints method. eventType watch.EventType // endpoints is the set of endpoints to be passed to the HandleEndpoints // method. endpoints *kapi.Endpoints // validate checks the state of the F5Plugin object and returns a Boolean // indicating whether the state is as expected. validate func() error }{ { name: "Endpoint add", eventType: watch.Added, endpoints: &kapi.Endpoints{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "test", }, Subsets: []kapi.EndpointSubset{{ Addresses: []kapi.EndpointAddress{{IP: "1.1.1.1"}}, Ports: []kapi.EndpointPort{{Port: 345}}, }}, // Specifying no port implies port 80. }, validate: func() error { poolName := "openshift_foo_test" poolExists, err := router.F5Client.PoolExists(poolName) if err != nil { return fmt.Errorf("PoolExists(%s) failed: %v", poolName, err) } if poolExists != true { return fmt.Errorf("PoolExists(%s) returned %v instead of true", poolName, poolExists) } memberName := "1.1.1.1:345" poolHasMember, err := router.F5Client.PoolHasMember(poolName, memberName) if err != nil { return fmt.Errorf("PoolHasMember(%s, %s) failed: %v", poolName, memberName, err) } if poolHasMember != true { return fmt.Errorf("PoolHasMember(%s, %s) returned %v instead of true", poolName, memberName, poolHasMember) } return nil }, }, { name: "Endpoint modify", eventType: watch.Modified, endpoints: &kapi.Endpoints{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "test", }, Subsets: []kapi.EndpointSubset{{ Addresses: []kapi.EndpointAddress{{IP: "2.2.2.2"}}, Ports: []kapi.EndpointPort{{Port: 8080}}, }}, }, validate: func() error { poolName := "openshift_foo_test" poolExists, err := router.F5Client.PoolExists(poolName) if err != nil { return fmt.Errorf("PoolExists(%s) failed: %v", poolName, err) } if poolExists != true { return fmt.Errorf("PoolExists(%s) returned %v instead of true", poolName, poolExists) } memberName := "2.2.2.2:8080" poolHasMember, err := router.F5Client.PoolHasMember(poolName, memberName) if err != nil { return fmt.Errorf("PoolHasMember(%s, %s) failed: %v", poolName, memberName, err) } if poolHasMember != true { return fmt.Errorf("PoolHasMember(%s, %s) returned %v instead of true", poolName, memberName, poolHasMember) } return nil }, }, { name: "Endpoint delete", eventType: watch.Modified, endpoints: &kapi.Endpoints{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "test", }, Subsets: []kapi.EndpointSubset{}, }, validate: func() error { poolName := "openshift_foo_test" poolExists, err := router.F5Client.PoolExists(poolName) if err != nil { return fmt.Errorf("PoolExists(%s) failed: %v", poolName, err) } if poolExists != false { return fmt.Errorf("PoolExists(%s) returned %v instead of false", poolName, poolExists) } return nil }, }, } for _, tc := range testCases { router.HandleEndpoints(tc.eventType, tc.endpoints) err := tc.validate() if err != nil { t.Errorf("Test case %s failed: %v", tc.name, err) } } } // TestHandleRoute test route watch events and validates that the state of the // F5 client object is as expected after each event. func TestHandleRoute(t *testing.T) { router, mockF5, err := newTestRouter(F5DefaultPartitionPath) if err != nil { t.Fatalf("Failed to initialize test router: %v", err) } defer mockF5.close() type testCase struct { // name is a human readable name for the test case. name string // type is the type to be passed to the HandleRoute method. eventType watch.EventType // route specifies the route object to be passed to the // HandleRoute method. route *routeapi.Route // validate checks the state of the F5Plugin // object and returns a Boolean // indicating whether the state is as // expected. validate func(tc testCase) error } testCases := []testCase{ { name: "Unsecure route add", eventType: watch.Added, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "unsecuretest", }, Spec: routeapi.RouteSpec{ Host: "www.example.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, }, }, validate: func(tc testCase) error { rulename := routeName(*tc.route) rule, ok := mockF5.state.policies[insecureRoutesPolicyName][rulename] if !ok { return fmt.Errorf("Policy %s should have rule %s,"+ " but no rule was found: %v", insecureRoutesPolicyName, rulename, mockF5.state.policies[insecureRoutesPolicyName]) } if len(rule.conditions) != 1 { return fmt.Errorf("Insecure route should have rule with 1 condition,"+ " but rule has %d conditions: %v", len(rule.conditions), rule.conditions) } condition := rule.conditions[0] if !(condition.HttpHost && condition.Host && !condition.HttpUri && !condition.PathSegment) { return fmt.Errorf("Insecure route should have rule that matches on"+ " hostname but found this instead: %v", rule.conditions) } if len(condition.Values) != 1 { return fmt.Errorf("Insecure route rule condition should have 1 value"+ " but has %d values: %v", len(condition.Values), condition) } if condition.Values[0] != tc.route.Spec.Host { return fmt.Errorf("Insecure route rule condition should match on"+ " hostname %s, but it has a different value: %v", tc.route.Spec.Host, condition) } return nil }, }, { name: "Unsecure route modify", eventType: watch.Modified, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "unsecuretest", }, Spec: routeapi.RouteSpec{ Host: "www.example2.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, Path: "/foo/bar", }, }, validate: func(tc testCase) error { rulename := routeName(*tc.route) rule, ok := mockF5.state.policies[insecureRoutesPolicyName][rulename] if !ok { return fmt.Errorf("Policy %s should have rule %s,"+ " but no rule was found: %v", insecureRoutesPolicyName, rulename, mockF5.state.policies[insecureRoutesPolicyName]) } if len(rule.conditions) != 3 { return fmt.Errorf("Insecure route with pathname should have rule"+ " with 3 conditions, but rule has %d conditions: %v", len(rule.conditions), rule.conditions) } pathSegments := strings.Split(tc.route.Spec.Path, "/") for _, condition := range rule.conditions { if !(condition.PathSegment && condition.HttpUri) { continue } expectedValue := pathSegments[condition.Index] foundValue := condition.Values[0] if foundValue != expectedValue { return fmt.Errorf("Rule condition with index %d for insecure route"+ " with pathname %s should have value \"%s\" but has value"+ " \"%s\": %v", condition.Index, tc.route.Spec.Path, expectedValue, foundValue, rule) } } return nil }, }, { name: "Unsecure route delete", eventType: watch.Deleted, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "unsecuretest", }, Spec: routeapi.RouteSpec{ Host: "www.example2.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, }, }, validate: func(tc testCase) error { rulename := routeName(*tc.route) _, found := mockF5.state.policies[secureRoutesPolicyName][rulename] if found { return fmt.Errorf("Rule %s should have been deleted from policy %s"+ " when the corresponding route was deleted, but it remains yet: %v", rulename, secureRoutesPolicyName, mockF5.state.policies[secureRoutesPolicyName]) } return nil }, }, { name: "Edge route add", eventType: watch.Added, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "edgetest", }, Spec: routeapi.RouteSpec{ Host: "www.example.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, TLS: &routeapi.TLSConfig{ Termination: routeapi.TLSTerminationEdge, Certificate: "abc", Key: "def", }, }, }, validate: func(tc testCase) error { rulename := routeName(*tc.route) _, found := mockF5.state.policies[secureRoutesPolicyName][rulename] if !found { return fmt.Errorf("Policy %s should have rule %s,"+ " but no such rule was found: %v", secureRoutesPolicyName, rulename, mockF5.state.policies[secureRoutesPolicyName]) } certfname := fmt.Sprintf("%s-https-cert.crt", rulename) _, found = mockF5.state.certs[certfname] if !found { return fmt.Errorf("Certificate file %s should have been created but"+ " does not exist: %v", certfname, mockF5.state.certs) } keyfname := fmt.Sprintf("%s-https-key.key", rulename) _, found = mockF5.state.keys[keyfname] if !found { return fmt.Errorf("Key file %s should have been created but"+ " does not exist: %v", keyfname, mockF5.state.keys) } clientSslProfileName := fmt.Sprintf("%s-client-ssl-profile", rulename) _, found = mockF5.state.clientSslProfiles[clientSslProfileName] if !found { return fmt.Errorf("client-ssl profile %s should have been created"+ " but does not exist: %v", clientSslProfileName, mockF5.state.clientSslProfiles) } _, found = mockF5.state.vserverProfiles[httpsVserverName][clientSslProfileName] if !found { return fmt.Errorf("client-ssl profile %s should have been"+ " associated with the vserver but was not: %v", clientSslProfileName, mockF5.state.vserverProfiles[httpsVserverName]) } return nil }, }, { name: "Edge route delete", eventType: watch.Deleted, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "edgetest", }, Spec: routeapi.RouteSpec{ Host: "www.example.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, TLS: &routeapi.TLSConfig{ Termination: routeapi.TLSTerminationEdge, Certificate: "abc", Key: "def", }, }, }, validate: func(tc testCase) error { rulename := routeName(*tc.route) _, found := mockF5.state.policies[secureRoutesPolicyName][rulename] if found { return fmt.Errorf("Rule %s should have been deleted from policy %s"+ " when the corresponding route was deleted, but it remains yet: %v", rulename, secureRoutesPolicyName, mockF5.state.policies[secureRoutesPolicyName]) } certfname := fmt.Sprintf("%s-https-cert.crt", rulename) _, found = mockF5.state.certs[certfname] if found { return fmt.Errorf("Certificate file %s should have been deleted with"+ " the route but remains yet: %v", certfname, mockF5.state.certs) } keyfname := fmt.Sprintf("%s-https-key.key", rulename) _, found = mockF5.state.keys[keyfname] if found { return fmt.Errorf("Key file %s should have been deleted with the"+ " route but remains yet: %v", keyfname, mockF5.state.keys) } clientSslProfileName := fmt.Sprintf("%s-client-ssl-profile", rulename) _, found = mockF5.state.vserverProfiles[httpsVserverName][clientSslProfileName] if found { return fmt.Errorf("client-ssl profile %s should have been deleted"+ " from the vserver when the route was deleted but remains yet: %v", clientSslProfileName, mockF5.state.vserverProfiles[httpsVserverName]) } _, found = mockF5.state.clientSslProfiles[clientSslProfileName] if found { return fmt.Errorf("client-ssl profile %s should have been deleted"+ " with the route but remains yet: %v", clientSslProfileName, mockF5.state.clientSslProfiles) } return nil }, }, { name: "Passthrough route add", eventType: watch.Added, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "passthroughtest", }, Spec: routeapi.RouteSpec{ Host: "www.example3.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, TLS: &routeapi.TLSConfig{ Termination: routeapi.TLSTerminationPassthrough, }, }, }, validate: func(tc testCase) error { _, found := mockF5.state.datagroups[passthroughIRuleDatagroupName][tc.route.Spec.Host] if !found { return fmt.Errorf("Datagroup entry for %s should have been created"+ " in the %s datagroup for the passthrough route but cannot be"+ " found: %v", tc.route.Spec.Host, passthroughIRuleDatagroupName, mockF5.state.datagroups[passthroughIRuleDatagroupName]) } return nil }, }, { name: "Add route with same hostname as passthrough route", eventType: watch.Added, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "conflictingroutetest", }, Spec: routeapi.RouteSpec{ Host: "www.example3.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, TLS: &routeapi.TLSConfig{ Termination: routeapi.TLSTerminationEdge, Certificate: "abc", Key: "def", }, }, }, validate: func(tc testCase) error { _, found := mockF5.state.datagroups[passthroughIRuleDatagroupName][tc.route.Spec.Host] if !found { return fmt.Errorf("Datagroup entry for %s should still exist"+ " in the %s datagroup after a secure route with the same hostname"+ " was created, but the datagroup entry cannot be found: %v", tc.route.Spec.Host, passthroughIRuleDatagroupName, mockF5.state.datagroups[passthroughIRuleDatagroupName]) } return nil }, }, { name: "Modify route with same hostname as passthrough route", eventType: watch.Modified, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "conflictingroutetest", }, Spec: routeapi.RouteSpec{ Host: "www.example3.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, }, }, validate: func(tc testCase) error { _, found := mockF5.state.datagroups[passthroughIRuleDatagroupName][tc.route.Spec.Host] if !found { return fmt.Errorf("Datagroup entry for %s should still exist"+ " in the %s datagroup after a secure route with the same hostname"+ " was updated, but the datagroup entry cannot be found: %v", tc.route.Spec.Host, passthroughIRuleDatagroupName, mockF5.state.datagroups[passthroughIRuleDatagroupName]) } return nil }, }, { name: "Delete route with same hostname as passthrough route", eventType: watch.Deleted, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "conflictingroutetest", }, Spec: routeapi.RouteSpec{ Host: "www.example3.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, }, }, validate: func(tc testCase) error { _, found := mockF5.state.datagroups[passthroughIRuleDatagroupName][tc.route.Spec.Host] if !found { return fmt.Errorf("Datagroup entry for %s should still exist"+ " in the %s datagroup after a secure route with the same hostname"+ " was deleted, but the datagroup entry cannot be found: %v", tc.route.Spec.Host, passthroughIRuleDatagroupName, mockF5.state.datagroups[passthroughIRuleDatagroupName]) } return nil }, }, { name: "Passthrough route delete", eventType: watch.Deleted, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "passthroughtest", }, Spec: routeapi.RouteSpec{ Host: "www.example3.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, TLS: &routeapi.TLSConfig{ Termination: routeapi.TLSTerminationPassthrough, }, }, }, validate: func(tc testCase) error { _, found := mockF5.state.datagroups[passthroughIRuleDatagroupName][tc.route.Spec.Host] if found { return fmt.Errorf("Datagroup entry for %s should have been deleted"+ " from the %s datagroup for the passthrough route but remains"+ " yet: %v", tc.route.Spec.Host, passthroughIRuleDatagroupName, mockF5.state.datagroups[passthroughIRuleDatagroupName]) } return nil }, }, { name: "Reencrypted route add", eventType: watch.Added, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "reencryptedtest", }, Spec: routeapi.RouteSpec{ Host: "www.example4.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, TLS: &routeapi.TLSConfig{ Termination: routeapi.TLSTerminationReencrypt, Certificate: "abc", Key: "def", CACertificate: "ghi", DestinationCACertificate: "jkl", }, }, }, validate: func(tc testCase) error { rulename := routeName(*tc.route) _, found := mockF5.state.policies[secureRoutesPolicyName][rulename] if !found { return fmt.Errorf("Policy %s should have rule %s for secure route,"+ " but no rule was found: %v", secureRoutesPolicyName, rulename, mockF5.state.policies[secureRoutesPolicyName]) } certcafname := fmt.Sprintf("%s-https-chain.crt", rulename) _, found = mockF5.state.certs[certcafname] if !found { return fmt.Errorf("Certificate chain file %s should have been"+ " created but does not exist: %v", certcafname, mockF5.state.certs) } keyfname := fmt.Sprintf("%s-https-key.key", rulename) _, found = mockF5.state.keys[keyfname] if !found { return fmt.Errorf("Key file %s should have been created but"+ " does not exist: %v", keyfname, mockF5.state.keys) } clientSslProfileName := fmt.Sprintf("%s-client-ssl-profile", rulename) _, found = mockF5.state.clientSslProfiles[clientSslProfileName] if !found { return fmt.Errorf("client-ssl profile %s should have been created"+ " but does not exist: %v", clientSslProfileName, mockF5.state.clientSslProfiles) } _, found = mockF5.state.vserverProfiles[httpsVserverName][clientSslProfileName] if !found { return fmt.Errorf("client-ssl profile %s should have been"+ " associated with the vserver but was not: %v", clientSslProfileName, mockF5.state.vserverProfiles[httpsVserverName]) } serverSslProfileName := fmt.Sprintf("%s-server-ssl-profile", rulename) _, found = mockF5.state.serverSslProfiles[serverSslProfileName] if !found { return fmt.Errorf("server-ssl profile %s should have been created"+ " but does not exist: %v", serverSslProfileName, mockF5.state.serverSslProfiles) } _, found = mockF5.state.vserverProfiles[httpsVserverName][serverSslProfileName] if !found { return fmt.Errorf("server-ssl profile %s should have been"+ " associated with the vserver but was not: %v", serverSslProfileName, mockF5.state.vserverProfiles[httpsVserverName]) } return nil }, }, { name: "Reencrypted route delete", eventType: watch.Deleted, route: &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "reencryptedtest", }, Spec: routeapi.RouteSpec{ Host: "www.example4.com", To: routeapi.RouteTargetReference{ Name: "TestService", }, TLS: &routeapi.TLSConfig{ Termination: routeapi.TLSTerminationReencrypt, Certificate: "abc", Key: "def", CACertificate: "ghi", DestinationCACertificate: "jkl", }, }, }, validate: func(tc testCase) error { rulename := routeName(*tc.route) _, found := mockF5.state.policies[secureRoutesPolicyName][rulename] if found { return fmt.Errorf("Rule %s should have been deleted from policy %s"+ " when the corresponding route was deleted, but it remains yet: %v", rulename, secureRoutesPolicyName, mockF5.state.policies[secureRoutesPolicyName]) } certcafname := fmt.Sprintf("%s-https-chain.crt", rulename) _, found = mockF5.state.certs[certcafname] if found { return fmt.Errorf("Certificate chain file %s should have been"+ " deleted with the route but remains yet: %v", certcafname, mockF5.state.certs) } keyfname := fmt.Sprintf("%s-https-key.key", rulename) _, found = mockF5.state.keys[keyfname] if found { return fmt.Errorf("Key file %s should have been deleted with the"+ " route but remains yet: %v", keyfname, mockF5.state.keys) } clientSslProfileName := fmt.Sprintf("%s-client-ssl-profile", rulename) _, found = mockF5.state.vserverProfiles[httpsVserverName][clientSslProfileName] if found { return fmt.Errorf("client-ssl profile %s should have been deleted"+ " from the vserver when the route was deleted but remains yet: %v", clientSslProfileName, mockF5.state.vserverProfiles[httpsVserverName]) } serverSslProfileName := fmt.Sprintf("%s-server-ssl-profile", rulename) _, found = mockF5.state.vserverProfiles[httpsVserverName][clientSslProfileName] if found { return fmt.Errorf("server-ssl profile %s should have been deleted"+ " from the vserver when the route was deleted but remains yet: %v", serverSslProfileName, mockF5.state.vserverProfiles[httpsVserverName]) } _, found = mockF5.state.serverSslProfiles[serverSslProfileName] if found { return fmt.Errorf("server-ssl profile %s should have been deleted"+ " with the route but remains yet: %v", serverSslProfileName, mockF5.state.serverSslProfiles) } return nil }, }, } for _, tc := range testCases { router.HandleRoute(tc.eventType, tc.route) err := tc.validate(tc) if err != nil { t.Errorf("Test case %s failed: %v", tc.name, err) } } } // TestHandleRouteModifications creates an F5 router instance, creates // a service and a route, modifies the route in several ways, and verifies that // the router correctly updates the route. func TestHandleRouteModifications(t *testing.T) { router, mockF5, err := newTestRouter(F5DefaultPartitionPath) if err != nil { t.Fatalf("Failed to initialize test router: %v", err) } defer mockF5.close() testRoute := &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "foo", Name: "mutatingroute", }, Spec: routeapi.RouteSpec{ Host: "www.example.com", To: routeapi.RouteTargetReference{ Name: "testendpoint", }, }, } err = router.HandleRoute(watch.Added, testRoute) if err != nil { t.Fatalf("HandleRoute failed on adding test route: %v", err) } // Verify that modifying the route into a secure route works. testRoute.Spec.TLS = &routeapi.TLSConfig{ Termination: routeapi.TLSTerminationReencrypt, Certificate: "abc", Key: "def", CACertificate: "ghi", DestinationCACertificate: "jkl", } err = router.HandleRoute(watch.Modified, testRoute) if err != nil { t.Fatalf("HandleRoute failed on modifying test route: %v", err) } // Verify that updating the hostname of the route succeeds. testRoute.Spec.Host = "www.example2.com" err = router.HandleRoute(watch.Modified, testRoute) if err != nil { t.Fatalf("HandleRoute failed on modifying test route: %v", err) } // Verify that modifying the route into a passthrough route works. testRoute.Spec.TLS = &routeapi.TLSConfig{ Termination: routeapi.TLSTerminationPassthrough, } err = router.HandleRoute(watch.Modified, testRoute) if err != nil { t.Fatalf("HandleRoute failed on modifying test route: %v", err) } // Verify that updating the hostname of the passthrough route succeeds. testRoute.Spec.Host = "www.example3.com" err = router.HandleRoute(watch.Modified, testRoute) if err != nil { t.Fatalf("HandleRoute failed on modifying test route: %v", err) } } // TestF5RouterSuccessiveInstances creates an F5 router instance, creates // a service and a route, creates a new F5 router instance, and verifies that // the new instance behaves correctly picking up the state from the first // instance. func TestF5RouterSuccessiveInstances(t *testing.T) { router, mockF5, err := newTestRouter(F5DefaultPartitionPath) if err != nil { t.Fatalf("Failed to initialize test router: %v", err) } testRoute := &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "xyzzy", Name: "testroute", }, Spec: routeapi.RouteSpec{ Host: "www.example.com", To: routeapi.RouteTargetReference{ Name: "testendpoint", }, TLS: &routeapi.TLSConfig{ Termination: routeapi.TLSTerminationReencrypt, Certificate: "abc", Key: "def", CACertificate: "ghi", DestinationCACertificate: "jkl", }, }, } testPassthroughRoute := &routeapi.Route{ ObjectMeta: kapi.ObjectMeta{ Namespace: "quux", Name: "testpassthroughroute", }, Spec: routeapi.RouteSpec{ Host: "www.example2.com", To: routeapi.RouteTargetReference{ Name: "testhttpsendpoint", }, TLS: &routeapi.TLSConfig{ Termination: routeapi.TLSTerminationPassthrough, }, }, } testHttpEndpoint := &kapi.Endpoints{ ObjectMeta: kapi.ObjectMeta{ Namespace: "xyzzy", Name: "testhttpendpoint", }, Subsets: []kapi.EndpointSubset{{ Addresses: []kapi.EndpointAddress{{IP: "10.1.1.1"}}, Ports: []kapi.EndpointPort{{Port: 8080}}, }}, } testHttpsEndpoint := &kapi.Endpoints{ ObjectMeta: kapi.ObjectMeta{ Namespace: "quux", Name: "testhttpsendpoint", }, Subsets: []kapi.EndpointSubset{{ Addresses: []kapi.EndpointAddress{{IP: "10.1.2.1"}}, Ports: []kapi.EndpointPort{{Port: 8443}}, }}, } // Add routes. err = router.HandleRoute(watch.Added, testRoute) if err != nil { t.Fatalf("HandleRoute failed on adding test route: %v", err) } err = router.HandleRoute(watch.Added, testPassthroughRoute) if err != nil { t.Fatalf("HandleRoute failed on adding test passthrough route: %v", err) } err = router.HandleEndpoints(watch.Added, testHttpEndpoint) if err != nil { t.Fatalf("HandleEndpoints failed on adding test HTTP endpoint subset: %v", err) } err = router.HandleEndpoints(watch.Added, testHttpsEndpoint) if err != nil { t.Fatalf("HandleEndpoints failed on adding test HTTPS endpoint subset: %v", err) } // Initialize a new router, but retain the mock F5 host. mockF5.close() router, mockF5, err = newTestRouterWithState(mockF5.state, F5DefaultPartitionPath) if err != nil { t.Fatalf("Failed to initialize test router: %v", err) } defer mockF5.close() // Have the new router delete the routes that the old router created. err = router.HandleRoute(watch.Deleted, testRoute) if err != nil { t.Fatalf("HandleRoute failed on deleting test route: %v", err) } err = router.HandleRoute(watch.Deleted, testPassthroughRoute) if err != nil { t.Fatalf("HandleRoute failed on deleting test passthrough route: %v", err) } err = router.HandleEndpoints(watch.Deleted, testHttpEndpoint) if err != nil { t.Fatalf("HandleEndpoints failed on deleting test HTTP endpoint subset: %v", err) } err = router.HandleEndpoints(watch.Deleted, testHttpsEndpoint) if err != nil { t.Fatalf("HandleEndpoints failed on deleting test HTTPS endpoint subset: %v", err) } }