diff options
author | Jon Marius Venstad <venstad@gmail.com> | 2021-11-24 11:30:55 +0100 |
---|---|---|
committer | Jon Marius Venstad <venstad@gmail.com> | 2021-11-24 11:30:55 +0100 |
commit | a83829df8798c40d6b8d53f08f840a967c9bc722 (patch) | |
tree | a32b7b870a313e3673374e25b8acfa50a210b4e4 /client | |
parent | bdd3bf47dde7a047f77dc2832ab7c92ba629e54e (diff) |
Revert "Merge pull request #20181 from vespa-engine/revert-20098-jonmv/vespa-cli-test-runner"
This reverts commit bdd3bf47dde7a047f77dc2832ab7c92ba629e54e, reversing
changes made to a0e159641f8361ee2fc1b39836b8a8f9364e9e3d.
Diffstat (limited to 'client')
27 files changed, 1069 insertions, 28 deletions
diff --git a/client/go/cmd/config.go b/client/go/cmd/config.go index 750664e51b1..966080dbdc2 100644 --- a/client/go/cmd/config.go +++ b/client/go/cmd/config.go @@ -133,10 +133,16 @@ func (c *Config) Write() error { } func (c *Config) CertificatePath(app vespa.ApplicationID) (string, error) { + if override, ok := os.LookupEnv("VESPA_CLI_DATA_PLANE_CERT_FILE"); ok { + return override, nil + } return c.applicationFilePath(app, "data-plane-public-cert.pem") } func (c *Config) PrivateKeyPath(app vespa.ApplicationID) (string, error) { + if override, ok := os.LookupEnv("VESPA_CLI_DATA_PLANE_KEY_FILE"); ok { + return override, nil + } return c.applicationFilePath(app, "data-plane-private-key.pem") } diff --git a/client/go/cmd/curl.go b/client/go/cmd/curl.go index bd9fad1b47e..2496ddc3abc 100644 --- a/client/go/cmd/curl.go +++ b/client/go/cmd/curl.go @@ -46,7 +46,7 @@ $ vespa curl -t local -- -v /search/?yql=query fatalErr(err) return } - service := getService("query", 0) + service := getService("query", 0, "") url := joinURL(service.BaseURL, args[len(args)-1]) rawArgs := args[:len(args)-1] c, err := curl.RawArgs(url, rawArgs...) diff --git a/client/go/cmd/document.go b/client/go/cmd/document.go index cd0170684cf..84c384e701e 100644 --- a/client/go/cmd/document.go +++ b/client/go/cmd/document.go @@ -118,7 +118,7 @@ var documentGetCmd = &cobra.Command{ }, } -func documentService() *vespa.Service { return getService("document", 0) } +func documentService() *vespa.Service { return getService("document", 0, "") } func operationOptions() vespa.OperationOptions { return vespa.OperationOptions{ diff --git a/client/go/cmd/document_test.go b/client/go/cmd/document_test.go index 649aca8703a..f3a5fbe9543 100644 --- a/client/go/cmd/document_test.go +++ b/client/go/cmd/document_test.go @@ -161,5 +161,5 @@ func assertDocumentServerError(t *testing.T, status int, errorMessage string) { } func documentServiceURL(client *mockHttpClient) string { - return getService("document", 0).BaseURL + return getService("document", 0, "").BaseURL } diff --git a/client/go/cmd/helpers.go b/client/go/cmd/helpers.go index 79ba1fcef26..89ea87f198e 100644 --- a/client/go/cmd/helpers.go +++ b/client/go/cmd/helpers.go @@ -6,6 +6,7 @@ package cmd import ( "crypto/tls" + "encoding/json" "fmt" "io/ioutil" "log" @@ -129,19 +130,21 @@ func getTargetType() string { return target } -func getService(service string, sessionOrRunID int64) *vespa.Service { +func getService(service string, sessionOrRunID int64, cluster string) *vespa.Service { t := getTarget() timeout := time.Duration(waitSecsArg) * time.Second if timeout > 0 { log.Printf("Waiting up to %d %s for service to become available ...", color.Cyan(waitSecsArg), color.Cyan("seconds")) } - s, err := t.Service(service, timeout, sessionOrRunID) + s, err := t.Service(service, timeout, sessionOrRunID, cluster) if err != nil { fatalErr(err, "Invalid service: ", service) } return s } +func getEndpointsOverride() string { return os.Getenv("VESPA_CLI_ENDPOINTS") } + func getSystem() string { return os.Getenv("VESPA_CLI_CLOUD_SYSTEM") } func getSystemName() string { @@ -175,15 +178,17 @@ func getTarget() vespa.Target { case "local": return vespa.LocalTarget() case "cloud": - deployment := deploymentFromArgs() cfg, err := LoadConfig() if err != nil { fatalErr(err, "Could not load config") return nil } + deployment := deploymentFromArgs() + endpoints := getEndpointsFromEnv() + var apiKey []byte = nil apiKey, err = ioutil.ReadFile(cfg.APIKeyPath(deployment.Application.Tenant)) - if !vespa.Auth0AccessTokenEnabled() { + if !vespa.Auth0AccessTokenEnabled() && endpoints == nil { if err != nil { fatalErrHint(err, "Deployment to cloud requires an API key. Try 'vespa api-key'") } @@ -228,14 +233,15 @@ func getTarget() vespa.Target { }, cfg.AuthConfigPath(), getSystemName(), - cloudAuth) + cloudAuth, + endpoints) } fatalErrHint(fmt.Errorf("Invalid target: %s", targetType), "Valid targets are 'local', 'cloud' or an URL") return nil } func waitForService(service string, sessionOrRunID int64) { - s := getService(service, sessionOrRunID) + s := getService(service, sessionOrRunID, "") timeout := time.Duration(waitSecsArg) * time.Second if timeout > 0 { log.Printf("Waiting up to %d %s for service to become ready ...", color.Cyan(waitSecsArg), color.Cyan("seconds")) @@ -271,3 +277,32 @@ func getDeploymentOpts(cfg *Config, pkg vespa.ApplicationPackage, target vespa.T } return opts } + +func getEndpointsFromEnv() map[string]string { + endpointsString := getEndpointsOverride() + if endpointsString == "" { + return nil + } + + var endpoints endpoints + urlsByCluster := make(map[string]string) + if err := json.Unmarshal([]byte(endpointsString), &endpoints); err != nil { + fatalErrHint(err, "Endpoints must be valid JSON") + } + if len(endpoints.Endpoints) == 0 { + fatalErr(fmt.Errorf("endpoints must be non-empty")) + } + for _, endpoint := range endpoints.Endpoints { + urlsByCluster[endpoint.Cluster] = endpoint.URL + } + return urlsByCluster +} + +type endpoints struct { + Endpoints []endpoint `json:"endpoints"` +} + +type endpoint struct { + Cluster string `json:"cluster"` + URL string `json:"url"` +} diff --git a/client/go/cmd/query.go b/client/go/cmd/query.go index 76688438fb4..6638c275330 100644 --- a/client/go/cmd/query.go +++ b/client/go/cmd/query.go @@ -39,7 +39,7 @@ can be set by the syntax [parameter-name]=[value].`, } func query(arguments []string) { - service := getService("query", 0) + service := getService("query", 0, "") url, _ := url.Parse(service.BaseURL + "/search/") urlQuery := url.Query() for i := 0; i < len(arguments); i++ { diff --git a/client/go/cmd/query_test.go b/client/go/cmd/query_test.go index ec6c3063906..55046ae49ba 100644 --- a/client/go/cmd/query_test.go +++ b/client/go/cmd/query_test.go @@ -75,5 +75,5 @@ func assertQueryServiceError(t *testing.T, status int, errorMessage string) { } func queryServiceURL(client *mockHttpClient) string { - return getService("query", 0).BaseURL + return getService("query", 0, "").BaseURL } diff --git a/client/go/cmd/test.go b/client/go/cmd/test.go new file mode 100644 index 00000000000..4c6d9ec265d --- /dev/null +++ b/client/go/cmd/test.go @@ -0,0 +1,369 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +// vespa test command +// Author: jonmv + +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/spf13/cobra" + "github.com/vespa-engine/vespa/client/go/util" + "github.com/vespa-engine/vespa/client/go/vespa" + "io/ioutil" + "math" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +func init() { + rootCmd.AddCommand(testCmd) +} + +// TODO: add link to test doc at cloud.vespa.ai +var testCmd = &cobra.Command{ + Use: "test [tests directory or test file]", + Short: "Run a test suite, or a single test", + Long: `Run a test suite, or a single test + +Runs all JSON test files in the specified directory, or the single JSON +test file specified. + +If no directory or file is specified, the working directory is used instead.`, + Example: `$ vespa test src/test/application/tests/system-test +$ vespa test src/test/application/tests/system-test/feed-and-query.json`, + Args: cobra.MaximumNArgs(1), + DisableAutoGenTag: true, + Run: func(cmd *cobra.Command, args []string) { + target := getTarget() + testPath := "." + if len(args) > 0 { + testPath = args[0] + } + if count, failed := runTests(testPath, target); len(failed) != 0 { + fmt.Fprintf(stdout, "\nFailed %d of %d tests:\n", len(failed), count) + for _, test := range failed { + fmt.Fprintln(stdout, test) + } + exitFunc(3) + } else if count == 0 { + fmt.Fprintf(stdout, "Failed to find any tests at '%v'\n", testPath) + exitFunc(3) + } else { + fmt.Fprintf(stdout, "%d tests completed successfully\n", count) + } + }, +} + +func runTests(rootPath string, target vespa.Target) (int, []string) { + count := 0 + failed := make([]string, 0) + if stat, err := os.Stat(rootPath); err != nil { + fatalErr(err, "Failed reading specified test path") + } else if stat.IsDir() { + tests, err := os.ReadDir(rootPath) + if err != nil { + fatalErr(err, "Failed reading specified test directory") + } + for _, test := range tests { + if !test.IsDir() && filepath.Ext(test.Name()) == ".json" { + testPath := path.Join(rootPath, test.Name()) + failure := runTest(testPath, target) + if failure != "" { + failed = append(failed, failure) + } + count++ + } + } + } else if strings.HasSuffix(stat.Name(), ".json") { + failure := runTest(rootPath, target) + if failure != "" { + failed = append(failed, failure) + } + count++ + } + return count, failed +} + +// Runs the test at the given path, and returns the specified test name if the test fails +func runTest(testPath string, target vespa.Target) string { + var test test + testBytes, err := ioutil.ReadFile(testPath) + if err != nil { + fatalErr(err, fmt.Sprintf("Failed to read test file at '%s'", testPath)) + } + if err = json.Unmarshal(testBytes, &test); err != nil { + fatalErr(err, fmt.Sprintf("Failed to parse test file at '%s", testPath)) + } + + testName := test.Name + if test.Name == "" { + testName = testPath + } + fmt.Fprintf(stdout, "Running %s:", testName) + + defaultParameters, err := getParameters(test.Defaults.ParametersRaw, path.Dir(testPath)) + if err != nil { + fatalErr(err, fmt.Sprintf("Invalid default parameters for '%s'", testName)) + } + + if len(test.Assertions) == 0 { + fatalErr(fmt.Errorf("a test must have at least one assertion, but none were found in '%s'", testPath)) + } + for i, assertion := range test.Assertions { + assertionName := assertion.Name + if assertionName == "" { + assertionName = fmt.Sprintf("assertion %d", i) + } + failure, err := verify(assertion, path.Dir(testPath), test.Defaults.Cluster, defaultParameters, target) + if err != nil { + fatalErr(err, fmt.Sprintf("\nError verifying %s", assertionName)) + } + if failure != "" { + fmt.Fprintf(stdout, "\nFailed verifying %s:\n%s\n", assertionName, failure) + return fmt.Sprintf("%v: %v", testName, assertionName) + } + if i == 0 { + fmt.Fprintf(stdout, " ") + } + fmt.Fprint(stdout, ".") + } + fmt.Fprintln(stdout, " OK!") + return "" +} + +// Asserts specified response is obtained for request, or returns a failure message, or an error if this fails +func verify(assertion assertion, testsPath string, defaultCluster string, defaultParameters map[string]string, target vespa.Target) (string, error) { + requestBody, err := getBody(assertion.Request.BodyRaw, testsPath) + if err != nil { + return "", err + } + + parameters, err := getParameters(assertion.Request.ParametersRaw, testsPath) + if err != nil { + return "", err + } + for name, value := range defaultParameters { + if _, present := parameters[name]; !present { + parameters[name] = value + } + } + + cluster := assertion.Request.Cluster + if cluster == "" { + cluster = defaultCluster + } + + service, err := target.Service("query", 0, 0, cluster) + if err != nil { + return "", err + } + + method := assertion.Request.Method + if method == "" { + method = "GET" + } + + pathAndQuery := assertion.Request.URI + if pathAndQuery == "" { + pathAndQuery = "/search/" + } + requestUrl, err := url.ParseRequestURI(service.BaseURL + pathAndQuery) + if err != nil { + return "", err + } + query := requestUrl.Query() + for name, value := range parameters { + query.Add(name, value) + } + requestUrl.RawQuery = query.Encode() + + header := http.Header{} + header.Add("Content-Type", "application/json") // TODO: Not guaranteed to be true ... + + request := &http.Request{ + URL: requestUrl, + Method: method, + Header: header, + Body: ioutil.NopCloser(bytes.NewReader(requestBody)), + } + defer request.Body.Close() + + response, err := service.Do(request, 600*time.Second) // Vespa should provide a response within the given request timeout + if err != nil { + return "", err + } + defer response.Body.Close() + + statusCode := assertion.Response.Code + if statusCode == 0 { + statusCode = 200 + } + if statusCode != response.StatusCode { + return fmt.Sprintf("Expected status code (%d) does not match actual (%d). Response body:\n%s", statusCode, response.StatusCode, util.ReaderToJSON(response.Body)), nil + } + + responseBodySpecBytes, err := getBody(assertion.Response.BodyRaw, testsPath) + if err != nil { + return "", err + } + if responseBodySpecBytes == nil { + return "", nil + } + var responseBodySpec interface{} + err = json.Unmarshal(responseBodySpecBytes, &responseBodySpec) + if err != nil { + return "", err + } + + responseBodyBytes, err := ioutil.ReadAll(response.Body) + if err != nil { + return "", err + } + var responseBody interface{} + err = json.Unmarshal(responseBodyBytes, &responseBody) + if err != nil { + return "", fmt.Errorf("got non-JSON response; %w:\n%s", err, string(responseBodyBytes)) + } + + failure, err := compare(responseBodySpec, responseBody, "") + if failure != "" { + responsePretty, _ := json.MarshalIndent(responseBody, "", " ") + failure = failure + " Response body:\n" + string(responsePretty) + } + return failure, err +} + +func compare(expected interface{}, actual interface{}, path string) (string, error) { + typeMatch := false + valueMatch := false + switch u := expected.(type) { + case nil: + typeMatch = actual == nil + valueMatch = actual == nil + case bool: + v, ok := actual.(bool) + typeMatch = ok + valueMatch = ok && u == v + case float64: + v, ok := actual.(float64) + typeMatch = ok + valueMatch = ok && math.Abs(u-v) < 1e-9 + case string: + v, ok := actual.(string) + typeMatch = ok + valueMatch = ok && (u == v) + case []interface{}: + v, ok := actual.([]interface{}) + typeMatch = ok + if ok { + if len(u) == len(v) { + for i, e := range u { + result, err := compare(e, v[i], fmt.Sprintf("%s/%d", path, i)) + if result != "" || err != nil { + return result, err + } + } + valueMatch = true + } else { + return fmt.Sprintf("Expected number of elements at %s (%d) does not match actual (%d).", path, len(u), len(v)), nil + } + } + case map[string]interface{}: + v, ok := actual.(map[string]interface{}) + typeMatch = ok + if ok { + for n, e := range u { + childPath := fmt.Sprintf("%s/%s", path, strings.ReplaceAll(strings.ReplaceAll(n, "~", "~0"), "/", "~1")) + f, ok := v[n] + if !ok { + return fmt.Sprintf("Expected field at %s not present in actual data.", childPath), nil + } + result, err := compare(e, f, childPath) + if result != "" || err != nil { + return result, err + } + } + valueMatch = true + } + default: + return "", fmt.Errorf("unexpected expected JSON type for value '%v'", expected) + } + + if !(typeMatch && valueMatch) { + if path == "" { + path = "root" + } + expectedJson, _ := json.MarshalIndent(expected, "", " ") + actualJson, _ := json.MarshalIndent(actual, "", " ") + return fmt.Sprintf("Expected JSON at %s (%s) does not match actual (%s).", path, expectedJson, actualJson), nil + } + return "", nil +} + +func getParameters(parametersRaw []byte, testsPath string) (map[string]string, error) { + if parametersRaw != nil { + var parametersPath string + if err := json.Unmarshal(parametersRaw, ¶metersPath); err == nil { + resolvedParametersPath := path.Join(testsPath, parametersPath) + parametersRaw, err = ioutil.ReadFile(resolvedParametersPath) + if err != nil { + fatalErr(err, fmt.Sprintf("Failed to read request parameters file at '%s'", resolvedParametersPath)) + } + } + var parameters map[string]string + if err := json.Unmarshal(parametersRaw, ¶meters); err != nil { + return nil, fmt.Errorf("request parameters must be JSON with only string values: %w", err) + } + return parameters, nil + } + return make(map[string]string), nil +} + +func getBody(bodyRaw []byte, testsPath string) ([]byte, error) { + var bodyPath string + if err := json.Unmarshal(bodyRaw, &bodyPath); err == nil { + resolvedBodyPath := path.Join(testsPath, bodyPath) + bodyRaw, err = ioutil.ReadFile(resolvedBodyPath) + if err != nil { + fatalErr(err, fmt.Sprintf("Failed to read body file at '%s'", resolvedBodyPath)) + } + } + return bodyRaw, nil +} + +type test struct { + Name string `json:"name"` + Defaults defaults `json:"defaults"` + Assertions []assertion `json:"assertions"` +} + +type defaults struct { + Cluster string `json:"cluster"` + ParametersRaw json.RawMessage `json:"parameters"` +} + +type assertion struct { + Name string `json:"name"` + Request request `json:"request"` + Response response `json:"response"` +} + +type request struct { + Cluster string `json:"cluster"` + Method string `json:"method"` + URI string `json:"uri"` + ParametersRaw json.RawMessage `json:"parameters"` + BodyRaw json.RawMessage `json:"body"` +} + +type response struct { + Code int `json:"code"` + BodyRaw json.RawMessage `json:"body"` +} diff --git a/client/go/cmd/test_test.go b/client/go/cmd/test_test.go new file mode 100644 index 00000000000..9d92e285750 --- /dev/null +++ b/client/go/cmd/test_test.go @@ -0,0 +1,136 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +// test command tests +// Author: jonmv + +package cmd + +import ( + "github.com/vespa-engine/vespa/client/go/util" + "github.com/vespa-engine/vespa/client/go/vespa" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSuite(t *testing.T) { + client := &mockHttpClient{} + searchResponse, _ := ioutil.ReadFile("testdata/tests/response.json") + client.NextStatus(200) + client.NextStatus(200) + for i := 0; i < 9; i++ { + client.NextResponse(200, string(searchResponse)) + } + + expectedBytes, _ := ioutil.ReadFile("testdata/tests/expected-suite.out") + outBytes, errBytes := execute(command{args: []string{"test", "testdata/tests/system-test"}}, t, client) + assert.Equal(t, string(expectedBytes), outBytes) + assert.Equal(t, "", errBytes) + + baseUrl := "http://127.0.0.1:8080" + urlWithQuery := baseUrl + "/search/?presentation.timing=true&query=artist%3A+foo&timeout=3.4s" + requests := []*http.Request{createFeedRequest(baseUrl), createFeedRequest(baseUrl), createSearchRequest(urlWithQuery), createSearchRequest(urlWithQuery)} + for i := 0; i < 7; i++ { + requests = append(requests, createSearchRequest(baseUrl+"/search/")) + } + assertRequests(requests, client, t) +} + +func TestTestWithoutAssertions(t *testing.T) { + client := &mockHttpClient{} + _, errBytes := execute(command{args: []string{"test", "testdata/tests/system-test/foo/query.json"}}, t, client) + assert.Equal(t, "a test must have at least one assertion, but none were found in 'testdata/tests/system-test/foo/query.json'\n", errBytes) +} + +func TestSuiteWithoutTests(t *testing.T) { + client := &mockHttpClient{} + outBytes, errBytes := execute(command{args: []string{"test", "testdata/tests/staging-test"}}, t, client) + assert.Equal(t, "Failed to find any tests at 'testdata/tests/staging-test'\n", outBytes) + assert.Equal(t, "", errBytes) +} + +func TestSingleTest(t *testing.T) { + client := &mockHttpClient{} + searchResponse, _ := ioutil.ReadFile("testdata/tests/response.json") + client.NextStatus(200) + client.NextStatus(200) + client.NextResponse(200, string(searchResponse)) + client.NextResponse(200, string(searchResponse)) + + expectedBytes, _ := ioutil.ReadFile("testdata/tests/expected.out") + outBytes, errBytes := execute(command{args: []string{"test", "testdata/tests/system-test/test.json"}}, t, client) + assert.Equal(t, string(expectedBytes), outBytes) + assert.Equal(t, "", errBytes) + + baseUrl := "http://127.0.0.1:8080" + rawUrl := baseUrl + "/search/?presentation.timing=true&query=artist%3A+foo&timeout=3.4s" + assertRequests([]*http.Request{createFeedRequest(baseUrl), createFeedRequest(baseUrl), createSearchRequest(rawUrl), createSearchRequest(rawUrl)}, client, t) +} + +func TestSingleTestWithCloudAndEndpoints(t *testing.T) { + cmd := command{args: []string{"test", "testdata/tests/system-test/test.json", "-t", "cloud", "-a", "t.a.i"}} + cmd.homeDir = filepath.Join(t.TempDir(), ".vespa") + os.MkdirAll(cmd.homeDir, 0700) + keyFile := filepath.Join(cmd.homeDir, "key") + certFile := filepath.Join(cmd.homeDir, "cert") + + os.Setenv("VESPA_CLI_DATA_PLANE_KEY_FILE", keyFile) + os.Setenv("VESPA_CLI_DATA_PLANE_CERT_FILE", certFile) + os.Setenv("VESPA_CLI_ENDPOINTS", "{\"endpoints\":[{\"cluster\":\"container\",\"url\":\"https://url\"}]}") + + kp, _ := vespa.CreateKeyPair() + ioutil.WriteFile(keyFile, kp.PrivateKey, 0600) + ioutil.WriteFile(certFile, kp.Certificate, 0600) + + client := &mockHttpClient{} + searchResponse, _ := ioutil.ReadFile("testdata/tests/response.json") + client.NextStatus(200) + client.NextStatus(200) + client.NextResponse(200, string(searchResponse)) + client.NextResponse(200, string(searchResponse)) + + expectedBytes, _ := ioutil.ReadFile("testdata/tests/expected.out") + outBytes, errBytes := execute(cmd, t, client) + assert.Equal(t, string(expectedBytes), outBytes) + assert.Equal(t, "", errBytes) + + baseUrl := "https://url" + rawUrl := baseUrl + "/search/?presentation.timing=true&query=artist%3A+foo&timeout=3.4s" + assertRequests([]*http.Request{createFeedRequest(baseUrl), createFeedRequest(baseUrl), createSearchRequest(rawUrl), createSearchRequest(rawUrl)}, client, t) +} + +func createFeedRequest(urlPrefix string) *http.Request { + return createRequest("POST", + urlPrefix+"/document/v1/test/music/docid/doc?timeout=3.4s", + "{\"fields\":{\"artist\":\"Foo Fighters\"}}") +} + +func createSearchRequest(rawUrl string) *http.Request { + return createRequest("GET", rawUrl, "") +} + +func createRequest(method string, uri string, body string) *http.Request { + requestUrl, _ := url.ParseRequestURI(uri) + return &http.Request{ + URL: requestUrl, + Method: method, + Header: nil, + Body: ioutil.NopCloser(strings.NewReader(body)), + } +} + +func assertRequests(requests []*http.Request, client *mockHttpClient, t *testing.T) { + if assert.Equal(t, len(requests), len(client.requests)) { + for i, e := range requests { + a := client.requests[i] + assert.Equal(t, e.URL.String(), a.URL.String()) + assert.Equal(t, e.Method, a.Method) + assert.Equal(t, util.ReaderToJSON(e.Body), util.ReaderToJSON(a.Body)) + } + } +} diff --git a/client/go/cmd/testdata/tests/body.json b/client/go/cmd/testdata/tests/body.json new file mode 100644 index 00000000000..767330b1a2d --- /dev/null +++ b/client/go/cmd/testdata/tests/body.json @@ -0,0 +1,12 @@ +{ + "root": { + "id": "toplevel", + "coverage": { + "full": true + }, + "fields": { + "totalCount" : 1 + }, + "children": [{}] + } +}
\ No newline at end of file diff --git a/client/go/cmd/testdata/tests/expected-suite.out b/client/go/cmd/testdata/tests/expected-suite.out new file mode 100644 index 00000000000..0fb8b897f4f --- /dev/null +++ b/client/go/cmd/testdata/tests/expected-suite.out @@ -0,0 +1,269 @@ +Running testdata/tests/system-test/test.json: .... OK! +Running testdata/tests/system-test/wrong-bool-value.json: +Failed verifying assertion 0: +Expected JSON at /root/coverage/full (false) does not match actual (true). Response body: +{ + "root": { + "children": [ + { + "fields": { + "artist": "Foo Fighters", + "documentid": "id:test:music::doc", + "sddocname": "music" + }, + "id": "id:test:music::doc", + "relevance": 0.38186238359951247, + "source": "music" + } + ], + "coverage": { + "coverage": 100, + "documents": 1, + "full": true, + "nodes": 1, + "results": 1, + "resultsFull": 1 + }, + "fields": { + "totalCount": 1 + }, + "id": "toplevel", + "relevance": 1 + }, + "timing": { + "querytime": 0.003, + "searchtime": 0.004, + "summaryfetchtime": 0 + } +} +Running testdata/tests/system-test/wrong-element-count.json: +Failed verifying assertion 0: +Expected number of elements at /root/children (0) does not match actual (1). Response body: +{ + "root": { + "children": [ + { + "fields": { + "artist": "Foo Fighters", + "documentid": "id:test:music::doc", + "sddocname": "music" + }, + "id": "id:test:music::doc", + "relevance": 0.38186238359951247, + "source": "music" + } + ], + "coverage": { + "coverage": 100, + "documents": 1, + "full": true, + "nodes": 1, + "results": 1, + "resultsFull": 1 + }, + "fields": { + "totalCount": 1 + }, + "id": "toplevel", + "relevance": 1 + }, + "timing": { + "querytime": 0.003, + "searchtime": 0.004, + "summaryfetchtime": 0 + } +} +Running testdata/tests/system-test/wrong-field-name.json: +Failed verifying assertion 0: +Expected field at /root/fields/totalCountDracula not present in actual data. Response body: +{ + "root": { + "children": [ + { + "fields": { + "artist": "Foo Fighters", + "documentid": "id:test:music::doc", + "sddocname": "music" + }, + "id": "id:test:music::doc", + "relevance": 0.38186238359951247, + "source": "music" + } + ], + "coverage": { + "coverage": 100, + "documents": 1, + "full": true, + "nodes": 1, + "results": 1, + "resultsFull": 1 + }, + "fields": { + "totalCount": 1 + }, + "id": "toplevel", + "relevance": 1 + }, + "timing": { + "querytime": 0.003, + "searchtime": 0.004, + "summaryfetchtime": 0 + } +} +Running testdata/tests/system-test/wrong-float-value.json: +Failed verifying assertion 0: +Expected JSON at /root/children/0/relevance (0.381862373599) does not match actual (0.38186238359951247). Response body: +{ + "root": { + "children": [ + { + "fields": { + "artist": "Foo Fighters", + "documentid": "id:test:music::doc", + "sddocname": "music" + }, + "id": "id:test:music::doc", + "relevance": 0.38186238359951247, + "source": "music" + } + ], + "coverage": { + "coverage": 100, + "documents": 1, + "full": true, + "nodes": 1, + "results": 1, + "resultsFull": 1 + }, + "fields": { + "totalCount": 1 + }, + "id": "toplevel", + "relevance": 1 + }, + "timing": { + "querytime": 0.003, + "searchtime": 0.004, + "summaryfetchtime": 0 + } +} +Running testdata/tests/system-test/wrong-int-value.json: +Failed verifying assertion 0: +Expected JSON at /root/fields/totalCount (2) does not match actual (1). Response body: +{ + "root": { + "children": [ + { + "fields": { + "artist": "Foo Fighters", + "documentid": "id:test:music::doc", + "sddocname": "music" + }, + "id": "id:test:music::doc", + "relevance": 0.38186238359951247, + "source": "music" + } + ], + "coverage": { + "coverage": 100, + "documents": 1, + "full": true, + "nodes": 1, + "results": 1, + "resultsFull": 1 + }, + "fields": { + "totalCount": 1 + }, + "id": "toplevel", + "relevance": 1 + }, + "timing": { + "querytime": 0.003, + "searchtime": 0.004, + "summaryfetchtime": 0 + } +} +Running testdata/tests/system-test/wrong-null-value.json: +Failed verifying assertion 0: +Expected field at /boot not present in actual data. Response body: +{ + "root": { + "children": [ + { + "fields": { + "artist": "Foo Fighters", + "documentid": "id:test:music::doc", + "sddocname": "music" + }, + "id": "id:test:music::doc", + "relevance": 0.38186238359951247, + "source": "music" + } + ], + "coverage": { + "coverage": 100, + "documents": 1, + "full": true, + "nodes": 1, + "results": 1, + "resultsFull": 1 + }, + "fields": { + "totalCount": 1 + }, + "id": "toplevel", + "relevance": 1 + }, + "timing": { + "querytime": 0.003, + "searchtime": 0.004, + "summaryfetchtime": 0 + } +} +Running testdata/tests/system-test/wrong-string-value.json: +Failed verifying assertion 0: +Expected JSON at /root/children/0/fields/artist ("Boo Fighters") does not match actual ("Foo Fighters"). Response body: +{ + "root": { + "children": [ + { + "fields": { + "artist": "Foo Fighters", + "documentid": "id:test:music::doc", + "sddocname": "music" + }, + "id": "id:test:music::doc", + "relevance": 0.38186238359951247, + "source": "music" + } + ], + "coverage": { + "coverage": 100, + "documents": 1, + "full": true, + "nodes": 1, + "results": 1, + "resultsFull": 1 + }, + "fields": { + "totalCount": 1 + }, + "id": "toplevel", + "relevance": 1 + }, + "timing": { + "querytime": 0.003, + "searchtime": 0.004, + "summaryfetchtime": 0 + } +} + +Failed 7 of 8 tests: +testdata/tests/system-test/wrong-bool-value.json: assertion 0 +testdata/tests/system-test/wrong-element-count.json: assertion 0 +testdata/tests/system-test/wrong-field-name.json: assertion 0 +testdata/tests/system-test/wrong-float-value.json: assertion 0 +testdata/tests/system-test/wrong-int-value.json: assertion 0 +testdata/tests/system-test/wrong-null-value.json: assertion 0 +testdata/tests/system-test/wrong-string-value.json: assertion 0 diff --git a/client/go/cmd/testdata/tests/expected.out b/client/go/cmd/testdata/tests/expected.out new file mode 100644 index 00000000000..f012ee30e95 --- /dev/null +++ b/client/go/cmd/testdata/tests/expected.out @@ -0,0 +1,2 @@ +Running testdata/tests/system-test/test.json: .... OK! +1 tests completed successfully diff --git a/client/go/cmd/testdata/tests/response.json b/client/go/cmd/testdata/tests/response.json new file mode 100644 index 00000000000..48368b935a8 --- /dev/null +++ b/client/go/cmd/testdata/tests/response.json @@ -0,0 +1,34 @@ +{ + "root": { + "children": [ + { + "fields": { + "artist": "Foo Fighters", + "documentid": "id:test:music::doc", + "sddocname": "music" + }, + "id": "id:test:music::doc", + "relevance": 0.38186238359951247, + "source": "music" + } + ], + "coverage": { + "coverage": 100, + "documents": 1, + "full": true, + "nodes": 1, + "results": 1, + "resultsFull": 1 + }, + "fields": { + "totalCount": 1 + }, + "id": "toplevel", + "relevance": 1 + }, + "timing": { + "querytime": 0.003, + "searchtime": 0.004, + "summaryfetchtime": 0 + } +}
\ No newline at end of file diff --git a/client/go/cmd/testdata/tests/staging-test/not-json b/client/go/cmd/testdata/tests/staging-test/not-json new file mode 100644 index 00000000000..b6fc4c620b6 --- /dev/null +++ b/client/go/cmd/testdata/tests/staging-test/not-json @@ -0,0 +1 @@ +hello
\ No newline at end of file diff --git a/client/go/cmd/testdata/tests/system-test/foo/body.json b/client/go/cmd/testdata/tests/system-test/foo/body.json new file mode 100644 index 00000000000..0bbf626eafe --- /dev/null +++ b/client/go/cmd/testdata/tests/system-test/foo/body.json @@ -0,0 +1,5 @@ +{ + "fields": { + "artist": "Foo Fighters" + } +}
\ No newline at end of file diff --git a/client/go/cmd/testdata/tests/system-test/foo/query.json b/client/go/cmd/testdata/tests/system-test/foo/query.json new file mode 100644 index 00000000000..25b8c5b0039 --- /dev/null +++ b/client/go/cmd/testdata/tests/system-test/foo/query.json @@ -0,0 +1,3 @@ +{ + "query": "artist: foo" +} diff --git a/client/go/cmd/testdata/tests/system-test/test.json b/client/go/cmd/testdata/tests/system-test/test.json new file mode 100644 index 00000000000..5aac76d29ff --- /dev/null +++ b/client/go/cmd/testdata/tests/system-test/test.json @@ -0,0 +1,64 @@ +{ + "defaults": { + "cluster": "container", + "parameters": { + "timeout": "3.4s" + } + }, + "assertions": [ + { + "name": "feed music", + "request": { + "method": "POST", + "body": "foo/body.json", + "uri": "/document/v1/test/music/docid/doc" + } + }, + { + "name": "re-feed music", + "request": { + "method": "POST", + "body": { + "fields": { + "artist": "Foo Fighters" + } + }, + "uri": "/document/v1/test/music/docid/doc" + } + }, + { + "name": "query for foo", + "request": { + "uri": "/search/?presentation.timing=true", + "parameters": { + "query": "artist: foo" + } + }, + "response": { + "code": 200, + "body": "../body.json" + } + }, + { + "name": "query for foo again", + "request": { + "uri": "/search/?presentation.timing=true", + "parameters": "foo/query.json" + }, + "response": { + "body": { + "root": { + "children": [ + { + "fields": { + "artist": "Foo Fighters" + }, + "relevance": 0.381862383599 + } + ] + } + } + } + } + ] +} diff --git a/client/go/cmd/testdata/tests/system-test/wrong-bool-value.json b/client/go/cmd/testdata/tests/system-test/wrong-bool-value.json new file mode 100644 index 00000000000..ae6f9de8de8 --- /dev/null +++ b/client/go/cmd/testdata/tests/system-test/wrong-bool-value.json @@ -0,0 +1,15 @@ +{ + "assertions": [ + { + "response": { + "body": { + "root": { + "coverage": { + "full": false + } + } + } + } + } + ] +} diff --git a/client/go/cmd/testdata/tests/system-test/wrong-element-count.json b/client/go/cmd/testdata/tests/system-test/wrong-element-count.json new file mode 100644 index 00000000000..77c687fa919 --- /dev/null +++ b/client/go/cmd/testdata/tests/system-test/wrong-element-count.json @@ -0,0 +1,13 @@ +{ + "assertions": [ + { + "response": { + "body": { + "root": { + "children": [] + } + } + } + } + ] +} diff --git a/client/go/cmd/testdata/tests/system-test/wrong-field-name.json b/client/go/cmd/testdata/tests/system-test/wrong-field-name.json new file mode 100644 index 00000000000..d020141ed12 --- /dev/null +++ b/client/go/cmd/testdata/tests/system-test/wrong-field-name.json @@ -0,0 +1,15 @@ +{ + "assertions": [ + { + "response": { + "body": { + "root": { + "fields": { + "totalCountDracula" : 1 + } + } + } + } + } + ] +} diff --git a/client/go/cmd/testdata/tests/system-test/wrong-float-value.json b/client/go/cmd/testdata/tests/system-test/wrong-float-value.json new file mode 100644 index 00000000000..804f2582176 --- /dev/null +++ b/client/go/cmd/testdata/tests/system-test/wrong-float-value.json @@ -0,0 +1,17 @@ +{ + "assertions": [ + { + "response": { + "body": { + "root": { + "children": [ + { + "relevance": 0.381862373599 + } + ] + } + } + } + } + ] +} diff --git a/client/go/cmd/testdata/tests/system-test/wrong-int-value.json b/client/go/cmd/testdata/tests/system-test/wrong-int-value.json new file mode 100644 index 00000000000..3cbf8acd1d8 --- /dev/null +++ b/client/go/cmd/testdata/tests/system-test/wrong-int-value.json @@ -0,0 +1,15 @@ +{ + "assertions": [ + { + "response": { + "body": { + "root": { + "fields": { + "totalCount" : 2 + } + } + } + } + } + ] +} diff --git a/client/go/cmd/testdata/tests/system-test/wrong-null-value.json b/client/go/cmd/testdata/tests/system-test/wrong-null-value.json new file mode 100644 index 00000000000..11425df7ad4 --- /dev/null +++ b/client/go/cmd/testdata/tests/system-test/wrong-null-value.json @@ -0,0 +1,11 @@ +{ + "assertions": [ + { + "response": { + "body": { + "boot": null + } + } + } + ] +} diff --git a/client/go/cmd/testdata/tests/system-test/wrong-string-value.json b/client/go/cmd/testdata/tests/system-test/wrong-string-value.json new file mode 100644 index 00000000000..2cf0a5fdb38 --- /dev/null +++ b/client/go/cmd/testdata/tests/system-test/wrong-string-value.json @@ -0,0 +1,19 @@ +{ + "assertions": [ + { + "response": { + "body": { + "root": { + "children": [ + { + "fields": { + "artist": "Boo Fighters" + } + } + ] + } + } + } + } + ] +} diff --git a/client/go/vespa/deploy.go b/client/go/vespa/deploy.go index 252a646bcfc..c1cc868e16f 100644 --- a/client/go/vespa/deploy.go +++ b/client/go/vespa/deploy.go @@ -73,7 +73,7 @@ func (d DeploymentOpts) String() string { func (d *DeploymentOpts) IsCloud() bool { return d.Target.Type() == cloudTargetType } func (d *DeploymentOpts) url(path string) (*url.URL, error) { - service, err := d.Target.Service(deployService, 0, 0) + service, err := d.Target.Service(deployService, 0, 0, "") if err != nil { return nil, err } diff --git a/client/go/vespa/target.go b/client/go/vespa/target.go index 093cb2b5cad..0b3223c0d2e 100644 --- a/client/go/vespa/target.go +++ b/client/go/vespa/target.go @@ -39,7 +39,6 @@ type Service struct { BaseURL string Name string TLSOptions TLSOptions - Target *Target } // Target represents a Vespa platform, running named Vespa services. @@ -48,7 +47,7 @@ type Target interface { Type() string // Service returns the service for given name. If timeout is non-zero, wait for the service to converge. - Service(name string, timeout time.Duration, sessionOrRunID int64) (*Service, error) + Service(name string, timeout time.Duration, sessionOrRunID int64, cluster string) (*Service, error) // PrintLog writes the logs of this deployment using given options to control output. PrintLog(options LogOptions) error @@ -129,7 +128,7 @@ func (s *Service) Description() string { func (t *customTarget) Type() string { return t.targetType } -func (t *customTarget) Service(name string, timeout time.Duration, sessionID int64) (*Service, error) { +func (t *customTarget) Service(name string, timeout time.Duration, sessionOrRunID int64, cluster string) (*Service, error) { if timeout > 0 && name != deployService { if err := t.waitForConvergence(timeout); err != nil { return nil, err @@ -171,7 +170,7 @@ func (t *customTarget) urlWithPort(serviceName string) (string, error) { } func (t *customTarget) waitForConvergence(timeout time.Duration) error { - deployer, err := t.Service(deployService, 0, 0) + deployer, err := t.Service(deployService, 0, 0, "") if err != nil { return err } @@ -241,8 +240,8 @@ func (t *cloudTarget) resolveEndpoint(cluster string) (string, error) { func (t *cloudTarget) Type() string { return t.targetType } -func (t *cloudTarget) Service(name string, timeout time.Duration, runID int64) (*Service, error) { - if name != deployService { +func (t *cloudTarget) Service(name string, timeout time.Duration, runID int64, cluster string) (*Service, error) { + if name != deployService && t.urlsByCluster == nil { if err := t.waitForEndpoints(timeout, runID); err != nil { return nil, err } @@ -251,13 +250,13 @@ func (t *cloudTarget) Service(name string, timeout time.Duration, runID int64) ( case deployService: return &Service{Name: name, BaseURL: t.apiURL}, nil case queryService: - queryURL, err := t.resolveEndpoint("") + queryURL, err := t.resolveEndpoint(cluster) if err != nil { return nil, err } return &Service{Name: name, BaseURL: queryURL, TLSOptions: t.tlsOptions}, nil case documentService: - documentURL, err := t.resolveEndpoint("") + documentURL, err := t.resolveEndpoint(cluster) if err != nil { return nil, err } @@ -489,7 +488,7 @@ func CustomTarget(baseURL string) Target { // CloudTarget creates a Target for the Vespa Cloud platform. func CloudTarget(apiURL string, deployment Deployment, apiKey []byte, tlsOptions TLSOptions, logOptions LogOptions, - authConfigPath string, systemName string, cloudAuth string) Target { + authConfigPath string, systemName string, cloudAuth string, urlsByCluster map[string]string) Target { return &cloudTarget{ apiURL: apiURL, targetType: cloudTargetType, @@ -500,6 +499,7 @@ func CloudTarget(apiURL string, deployment Deployment, apiKey []byte, tlsOptions authConfigPath: authConfigPath, systemName: systemName, cloudAuth: cloudAuth, + urlsByCluster: urlsByCluster, } } diff --git a/client/go/vespa/target_test.go b/client/go/vespa/target_test.go index 9d2418897e3..0cfe9f1962c 100644 --- a/client/go/vespa/target_test.go +++ b/client/go/vespa/target_test.go @@ -82,11 +82,11 @@ func TestCustomTargetWait(t *testing.T) { defer srv.Close() target := CustomTarget(srv.URL) - _, err := target.Service("query", time.Millisecond, 42) + _, err := target.Service("query", time.Millisecond, 42, "") assert.NotNil(t, err) vc.deploymentConverged = true - _, err = target.Service("query", time.Millisecond, 42) + _, err = target.Service("query", time.Millisecond, 42, "") assert.Nil(t, err) assertServiceWait(t, 200, target, "deploy") @@ -104,11 +104,11 @@ func TestCloudTargetWait(t *testing.T) { target := createCloudTarget(t, srv.URL, &logWriter) assertServiceWait(t, 200, target, "deploy") - _, err := target.Service("query", time.Millisecond, 42) + _, err := target.Service("query", time.Millisecond, 42, "") assert.NotNil(t, err) vc.deploymentConverged = true - _, err = target.Service("query", time.Millisecond, 42) + _, err = target.Service("query", time.Millisecond, 42, "") assert.Nil(t, err) assertServiceWait(t, 500, target, "query") @@ -152,7 +152,7 @@ func createCloudTarget(t *testing.T, url string, logWriter io.Writer) Target { target := CloudTarget("https://example.com", Deployment{ Application: ApplicationID{Tenant: "t1", Application: "a1", Instance: "i1"}, Zone: ZoneID{Environment: "dev", Region: "us-north-1"}, - }, apiKey, TLSOptions{KeyPair: x509KeyPair}, LogOptions{Writer: logWriter}, "", "", "") + }, apiKey, TLSOptions{KeyPair: x509KeyPair}, LogOptions{Writer: logWriter}, "", "", "", nil) if ct, ok := target.(*cloudTarget); ok { ct.apiURL = url } else { @@ -162,13 +162,13 @@ func createCloudTarget(t *testing.T, url string, logWriter io.Writer) Target { } func assertServiceURL(t *testing.T, url string, target Target, service string) { - s, err := target.Service(service, 0, 42) + s, err := target.Service(service, 0, 42, "") assert.Nil(t, err) assert.Equal(t, url, s.BaseURL) } func assertServiceWait(t *testing.T, expectedStatus int, target Target, service string) { - s, err := target.Service(service, 0, 42) + s, err := target.Service(service, 0, 42, "") assert.Nil(t, err) status, err := s.Wait(0) |