diff --git a/test/images/agnhost/BUILD b/test/images/agnhost/BUILD index f9faaaeeaaa..d8fd2c4dc72 100644 --- a/test/images/agnhost/BUILD +++ b/test/images/agnhost/BUILD @@ -22,6 +22,7 @@ go_library( "//test/images/agnhost/dns:go_default_library", "//test/images/agnhost/entrypoint-tester:go_default_library", "//test/images/agnhost/fakegitserver:go_default_library", + "//test/images/agnhost/guestbook:go_default_library", "//test/images/agnhost/inclusterclient:go_default_library", "//test/images/agnhost/liveness:go_default_library", "//test/images/agnhost/logs-generator:go_default_library", @@ -57,6 +58,7 @@ filegroup( "//test/images/agnhost/dns:all-srcs", "//test/images/agnhost/entrypoint-tester:all-srcs", "//test/images/agnhost/fakegitserver:all-srcs", + "//test/images/agnhost/guestbook:all-srcs", "//test/images/agnhost/inclusterclient:all-srcs", "//test/images/agnhost/liveness:all-srcs", "//test/images/agnhost/logs-generator:all-srcs", diff --git a/test/images/agnhost/README.md b/test/images/agnhost/README.md index 4e91d3cbf78..29687939fe1 100644 --- a/test/images/agnhost/README.md +++ b/test/images/agnhost/README.md @@ -40,7 +40,7 @@ For example, let's consider the following `pod.yaml` file: containers: - args: - dns-suffix - image: gcr.io/kubernetes-e2e-test-images/agnhost:2.7 + image: gcr.io/kubernetes-e2e-test-images/agnhost:2.8 name: agnhost dnsConfig: nameservers: @@ -189,6 +189,38 @@ Usage: ``` +### guestbook + +Starts a HTTP server on the given `--http-port` (default: 80), serving various endpoints representing a +guestbook app. The endpoints and their purpose are: + +- `/register`: A guestbook slave will subscribe to a master, to its given `--slaveof` endpoint. The master + will then push any updates it receives to its registered slaves through the `--backend-port` (default: 6379). +- `/get`: Returns `{"data": value}`, where the `value` is the stored value for the given `key` if non-empty, + or the entire store. +- `/set`: Will set the given key-value pair in its own store and propagate it to its slaves, if any. + Will return `{"data": "Updated"}` to the caller on success. +- `/guestbook`: Will proxy the request to `agnhost-master` if the given `cmd` is `set`, or `agnhost-slave` + if the given `cmd` is `get`. + +Usage: + +```console +guestbook="test/e2e/testing-manifests/guestbook" +sed_expr="s|{{.AgnhostImage}}|gcr.io/kubernetes-e2e-test-images/agnhost:2.8|" + +# create the services. +kubectl create -f ${guestbook}/frontend-service.yaml +kubectl create -f ${guestbook}/agnhost-master-service.yaml +kubectl create -f ${guestbook}/agnhost-slave-service.yaml + +# create the deployments. +cat ${guestbook}/frontend-deployment.yaml.in | sed ${sed_expr} | kubectl create -f - +cat ${guestbook}/agnhost-master-deployment.yaml.in | sed ${sed_expr} | kubectl create -f - +cat ${guestbook}/agnhost-slave-deployment.yaml.in | sed ${sed_expr} | kubectl create -f - +``` + + ### help Prints the binary's help menu. Additionally, it can be followed by another subcommand @@ -258,14 +290,14 @@ Examples: ```console docker run -i \ - gcr.io/kubernetes-e2e-test-images/agnhost:2.7 \ + gcr.io/kubernetes-e2e-test-images/agnhost:2.8 \ logs-generator --log-lines-total 10 --run-duration 1s ``` ```console kubectl run logs-generator \ --generator=run-pod/v1 \ - --image=gcr.io/kubernetes-e2e-test-images/agnhost:2.7 \ + --image=gcr.io/kubernetes-e2e-test-images/agnhost:2.8 \ --restart=Never \ -- logs-generator -t 10 -d 1s ``` @@ -392,7 +424,7 @@ Usage: ```console kubectl run test-agnhost \ --generator=run-pod/v1 \ - --image=gcr.io/kubernetes-e2e-test-images/agnhost:2.7 \ + --image=gcr.io/kubernetes-e2e-test-images/agnhost:2.8 \ --restart=Never \ --env "POD_IP=" \ --env "NODE_IP=" \ @@ -447,7 +479,7 @@ Usage: ```console kubectl run test-agnhost \ --generator=run-pod/v1 \ - --image=gcr.io/kubernetes-e2e-test-images/agnhost:2.7 \ + --image=gcr.io/kubernetes-e2e-test-images/agnhost:2.8 \ --restart=Never \ --env "BIND_ADDRESS=localhost" \ --env "BIND_PORT=8080" \ @@ -534,6 +566,6 @@ The image contains `iperf`. ## Image -The image can be found at `gcr.io/kubernetes-e2e-test-images/agnhost:2.7` for Linux -containers, and `e2eteam/agnhost:2.7` for Windows containers. In the future, the same +The image can be found at `gcr.io/kubernetes-e2e-test-images/agnhost:2.8` for Linux +containers, and `e2eteam/agnhost:2.8` for Windows containers. In the future, the same repository can be used for both OSes. diff --git a/test/images/agnhost/VERSION b/test/images/agnhost/VERSION index 1effb003408..a4412fa745d 100644 --- a/test/images/agnhost/VERSION +++ b/test/images/agnhost/VERSION @@ -1 +1 @@ -2.7 +2.8 diff --git a/test/images/agnhost/agnhost.go b/test/images/agnhost/agnhost.go index 01a45660eba..edd80a2e52e 100644 --- a/test/images/agnhost/agnhost.go +++ b/test/images/agnhost/agnhost.go @@ -28,6 +28,7 @@ import ( "k8s.io/kubernetes/test/images/agnhost/dns" "k8s.io/kubernetes/test/images/agnhost/entrypoint-tester" "k8s.io/kubernetes/test/images/agnhost/fakegitserver" + "k8s.io/kubernetes/test/images/agnhost/guestbook" "k8s.io/kubernetes/test/images/agnhost/inclusterclient" "k8s.io/kubernetes/test/images/agnhost/liveness" "k8s.io/kubernetes/test/images/agnhost/logs-generator" @@ -44,7 +45,7 @@ import ( ) func main() { - rootCmd := &cobra.Command{Use: "app", Version: "2.7"} + rootCmd := &cobra.Command{Use: "app", Version: "2.8"} rootCmd.AddCommand(auditproxy.CmdAuditProxy) rootCmd.AddCommand(connect.CmdConnect) @@ -54,6 +55,7 @@ func main() { rootCmd.AddCommand(dns.CmdEtcHosts) rootCmd.AddCommand(entrypoint.CmdEntrypointTester) rootCmd.AddCommand(fakegitserver.CmdFakeGitServer) + rootCmd.AddCommand(guestbook.CmdGuestbook) rootCmd.AddCommand(inclusterclient.CmdInClusterClient) rootCmd.AddCommand(liveness.CmdLiveness) rootCmd.AddCommand(logsgen.CmdLogsGenerator) diff --git a/test/images/agnhost/guestbook/BUILD b/test/images/agnhost/guestbook/BUILD new file mode 100644 index 00000000000..9cbe9f97c2a --- /dev/null +++ b/test/images/agnhost/guestbook/BUILD @@ -0,0 +1,29 @@ +package(default_visibility = ["//visibility:public"]) + +load( + "@io_bazel_rules_go//go:def.bzl", + "go_library", +) + +go_library( + name = "go_default_library", + srcs = ["guestbook.go"], + importpath = "k8s.io/kubernetes/test/images/agnhost/guestbook", + deps = [ + "//staging/src/k8s.io/apimachinery/pkg/util/net:go_default_library", + "//vendor/github.com/spf13/cobra:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], +) diff --git a/test/images/agnhost/guestbook/guestbook.go b/test/images/agnhost/guestbook/guestbook.go new file mode 100644 index 00000000000..d4c0161f971 --- /dev/null +++ b/test/images/agnhost/guestbook/guestbook.go @@ -0,0 +1,283 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package guestbook + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/spf13/cobra" + + utilnet "k8s.io/apimachinery/pkg/util/net" +) + +// CmdGuestbook is used by agnhost Cobra. +var CmdGuestbook = &cobra.Command{ + Use: "guestbook", + Short: "Creates a HTTP server with various endpoints representing a guestbook app", + Long: `Starts a HTTP server on the given --http-port (default: 80), serving various endpoints representing a guestbook app. The endpoints and their purpose are: + +- /register: A guestbook slave will subscribe to a master, to its given --slaveof endpoint. The master will then push any updates it receives to its registered slaves through the --backend-port. +- /get: Returns '{"data": value}', where the value is the stored value for the given key if non-empty, or the entire store. +- /set: Will set the given key-value pair in its own store and propagate it to its slaves, if any. Will return '{"data": "Updated"}' to the caller on success. +- /guestbook: Will proxy the request to agnhost-master if the given cmd is 'set', or agnhost-slave if the given cmd is 'get'.`, + Args: cobra.MaximumNArgs(0), + Run: main, +} + +var ( + httpPort string + backendPort string + slaveOf string + slaves []string + store map[string]interface{} +) + +const ( + timeout = time.Duration(15) * time.Second + sleep = time.Duration(1) * time.Second +) + +func init() { + CmdGuestbook.Flags().StringVar(&httpPort, "http-port", "80", "HTTP Listen Port") + CmdGuestbook.Flags().StringVar(&backendPort, "backend-port", "6379", "Backend's HTTP Listen Port") + CmdGuestbook.Flags().StringVar(&slaveOf, "slaveof", "", "The host's name to register to") + store = make(map[string]interface{}) +} + +func main(cmd *cobra.Command, args []string) { + go registerNode(slaveOf, backendPort) + startHTTPServer(httpPort) +} + +func registerNode(registerTo, port string) { + if registerTo == "" { + return + } + + hostPort := net.JoinHostPort(registerTo, backendPort) + _, err := net.ResolveTCPAddr("tcp", hostPort) + if err != nil { + log.Fatalf("--slaveof param and/or --backend-port param are invalid. %v", err) + return + } + + start := time.Now() + for time.Since(start) < timeout { + response, err := dialHTTP("register", hostPort) + if err != nil { + log.Printf("encountered error while registering to master: %v. Retrying in 1 second.", err) + time.Sleep(sleep) + continue + } + + responseJSON := make(map[string]interface{}) + err = json.Unmarshal([]byte(response), &responseJSON) + if err != nil { + log.Fatalf("Error while unmarshaling master's response: %v", err) + } + + responseJSON["data"] = "something" + var ok bool + store, ok = responseJSON["data"].(map[string]interface{}) + if !ok { + log.Fatalf("Could not cast responseJSON: %s", responseJSON["data"]) + } + log.Printf("Registered to node: %s", registerTo) + return + } + + log.Fatal("Timed out while registering to master.") +} + +func startHTTPServer(port string) { + http.HandleFunc("/register", registerHandler) + http.HandleFunc("/get", getHandler) + http.HandleFunc("/set", setHandler) + http.HandleFunc("/guestbook", guestbookHandler) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil)) +} + +// registerHandler will register the caller in this server's list of slaves. +// /set requests will be propagated to slaves, if any. +func registerHandler(w http.ResponseWriter, r *http.Request) { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + fmt.Fprintf(w, "userip: %q is not IP:port", r.RemoteAddr) + return + } + log.Printf("GET /register, IP: %s", ip) + + // send all the store to the slave as well. + output := make(map[string]interface{}) + output["data"] = store + bytes, err := json.Marshal(output) + if err != nil { + http.Error(w, fmt.Sprintf("response could not be serialized. %v", err), http.StatusExpectationFailed) + return + } + fmt.Fprint(w, string(bytes)) + slaves = append(slaves, ip) + log.Printf("Node '%s' registered.", ip) +} + +// getHandler will return '{"data": value}', where value is the stored value for +// the given key if non-empty, or entire store. +func getHandler(w http.ResponseWriter, r *http.Request) { + values, err := url.Parse(r.URL.RequestURI()) + if err != nil { + http.Error(w, fmt.Sprintf("%v", err), http.StatusBadRequest) + return + } + + key := values.Query().Get("key") + + log.Printf("GET /get?key=%s", key) + + output := make(map[string]interface{}) + if key == "" { + output["data"] = store + } else { + value, found := store[key] + if !found { + value = "" + } + output["data"] = value + } + + bytes, err := json.Marshal(output) + if err == nil { + fmt.Fprint(w, string(bytes)) + } else { + http.Error(w, fmt.Sprintf("response could not be serialized. %v", err), http.StatusExpectationFailed) + } +} + +// setHandler will set the given key-value pair in its own store and propagate +// it to its slaves, if any. Will return '{"message": "Updated"}' to the caller on success. +func setHandler(w http.ResponseWriter, r *http.Request) { + values, err := url.Parse(r.URL.RequestURI()) + if err != nil { + http.Error(w, fmt.Sprintf("%v", err), http.StatusBadRequest) + return + } + + key := values.Query().Get("key") + value := values.Query().Get("value") + + log.Printf("GET /set?key=%s&value=%s", key, value) + + if key == "" { + http.Error(w, "cannot set with empty key.", http.StatusBadRequest) + return + } + + store[key] = value + request := fmt.Sprintf("set?key=%s&value=%s", key, value) + for _, slave := range slaves { + hostPort := net.JoinHostPort(slave, backendPort) + _, err = dialHTTP(request, hostPort) + if err != nil { + http.Error(w, fmt.Sprintf("encountered error while propagating to slave '%s': %v", slave, err), http.StatusExpectationFailed) + return + } + } + + output := map[string]string{} + output["message"] = "Updated" + bytes, err := json.Marshal(output) + if err == nil { + fmt.Fprint(w, string(bytes)) + } else { + http.Error(w, fmt.Sprintf("response could not be serialized. %v", err), http.StatusExpectationFailed) + } +} + +// guestbookHandler will proxy the request to agnhost-master if the given cmd is +// 'set' or agnhost-slave if the given cmd is 'get'. +func guestbookHandler(w http.ResponseWriter, r *http.Request) { + values, err := url.Parse(r.URL.RequestURI()) + if err != nil { + http.Error(w, fmt.Sprintf("%v", err), http.StatusBadRequest) + return + } + + cmd := strings.ToLower(values.Query().Get("cmd")) + key := values.Query().Get("key") + value := values.Query().Get("value") + + log.Printf("GET /guestbook?cmd=%s&key=%s&value=%s", cmd, key, value) + + if cmd != "get" && cmd != "set" { + http.Error(w, fmt.Sprintf("unsupported cmd: '%s'", cmd), http.StatusBadRequest) + return + } + if cmd == "set" && key == "" { + http.Error(w, "cannot set with empty key.", http.StatusBadRequest) + return + } + + host := "agnhost-master" + if cmd == "get" { + host = "agnhost-slave" + } + + hostPort := net.JoinHostPort(host, backendPort) + _, err = net.ResolveTCPAddr("tcp", hostPort) + if err != nil { + http.Error(w, fmt.Sprintf("host and/or port param are invalid. %v", err), http.StatusBadRequest) + return + } + + request := fmt.Sprintf("%s?key=%s&value=%s", cmd, key, value) + response, err := dialHTTP(request, hostPort) + if err == nil { + fmt.Fprint(w, response) + } else { + http.Error(w, fmt.Sprintf("encountered error: %v", err), http.StatusExpectationFailed) + } +} + +func dialHTTP(request, hostPort string) (string, error) { + transport := utilnet.SetTransportDefaults(&http.Transport{}) + httpClient := createHTTPClient(transport) + resp, err := httpClient.Get(fmt.Sprintf("http://%s/%s", hostPort, request)) + defer transport.CloseIdleConnections() + if err == nil { + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err == nil { + return string(body), nil + } + } + return "", err +} + +func createHTTPClient(transport *http.Transport) *http.Client { + client := &http.Client{ + Transport: transport, + Timeout: 5 * time.Second, + } + return client +}