aboutsummaryrefslogtreecommitdiffstats
path: root/client/go/internal/cli
diff options
context:
space:
mode:
authorJon Bratseth <bratseth@gmail.com>2023-08-21 16:31:04 +0200
committerGitHub <noreply@github.com>2023-08-21 16:31:04 +0200
commita05e87aa96441660e5d7470b6fd9b7097b5e3f0f (patch)
treebf273fffa457b68a63c3b5920eeafddefb962f0d /client/go/internal/cli
parente23d2d7953ed8563db47af5bffd8c17293965863 (diff)
parent5d6cf5d65fc06341d1127d5de586276fb3a1659e (diff)
Merge pull request #28093 from vespa-engine/mpolden/cluster-discovery-and-wait
Improved cluster discovery and waiting
Diffstat (limited to 'client/go/internal/cli')
-rw-r--r--client/go/internal/cli/cmd/config.go9
-rw-r--r--client/go/internal/cli/cmd/curl.go22
-rw-r--r--client/go/internal/cli/cmd/curl_test.go1
-rw-r--r--client/go/internal/cli/cmd/deploy.go40
-rw-r--r--client/go/internal/cli/cmd/deploy_test.go33
-rw-r--r--client/go/internal/cli/cmd/document.go3
-rw-r--r--client/go/internal/cli/cmd/document_test.go41
-rw-r--r--client/go/internal/cli/cmd/feed.go10
-rw-r--r--client/go/internal/cli/cmd/feed_test.go4
-rw-r--r--client/go/internal/cli/cmd/prod.go2
-rw-r--r--client/go/internal/cli/cmd/query.go3
-rw-r--r--client/go/internal/cli/cmd/query_test.go8
-rw-r--r--client/go/internal/cli/cmd/root.go61
-rw-r--r--client/go/internal/cli/cmd/status.go149
-rw-r--r--client/go/internal/cli/cmd/status_test.go193
-rw-r--r--client/go/internal/cli/cmd/test.go19
-rw-r--r--client/go/internal/cli/cmd/test_test.go25
-rw-r--r--client/go/internal/cli/cmd/testutil_test.go32
-rw-r--r--client/go/internal/cli/cmd/visit_test.go8
-rw-r--r--client/go/internal/cli/cmd/waiter.go97
20 files changed, 544 insertions, 216 deletions
diff --git a/client/go/internal/cli/cmd/config.go b/client/go/internal/cli/cmd/config.go
index 0a03686dd33..2ebd6b0793e 100644
--- a/client/go/internal/cli/cmd/config.go
+++ b/client/go/internal/cli/cmd/config.go
@@ -52,7 +52,7 @@ most to least preferred:
3. Global config value
4. Default value
-The following flags/options can be configured:
+The following global flags/options can be configured:
application
@@ -96,13 +96,6 @@ e.g. vespa deploy or vespa query. Possible values are:
- hosted: Connect to hosted Vespa (internal platform)
- *url*: Connect to a platform running at given URL.
-wait
-
-Specifies the number of seconds to wait for a service to become ready or
-deployment to complete. Use this to have a potentially long-running command
-block until the operation is complete, e.g. with vespa deploy. Defaults to 0
-(no waiting)
-
zone
Specifies a custom dev or perf zone to use when connecting to a Vespa platform.
diff --git a/client/go/internal/cli/cmd/curl.go b/client/go/internal/cli/cmd/curl.go
index 3009cab2b5e..44540db9ccf 100644
--- a/client/go/internal/cli/cmd/curl.go
+++ b/client/go/internal/cli/cmd/curl.go
@@ -39,7 +39,17 @@ $ vespa curl -- -v --data-urlencode "yql=select * from music where album contain
if err != nil {
return err
}
- service, err := target.Service(curlService, time.Duration(waitSecs)*time.Second, 0, cli.config.cluster())
+ var service *vespa.Service
+ useDeploy := curlService == "deploy"
+ waiter := cli.waiter(false, time.Duration(waitSecs)*time.Second)
+ if useDeploy {
+ if cli.config.cluster() != "" {
+ return fmt.Errorf("cannot specify cluster for service %s", curlService)
+ }
+ service, err = target.DeployService()
+ } else {
+ service, err = waiter.Service(target, cli.config.cluster())
+ }
if err != nil {
return err
}
@@ -49,19 +59,15 @@ $ vespa curl -- -v --data-urlencode "yql=select * from music where album contain
if err != nil {
return err
}
- switch curlService {
- case vespa.DeployService:
+ if useDeploy {
if err := addAccessToken(c, target); err != nil {
return err
}
- case vespa.DocumentService, vespa.QueryService:
+ } else {
c.CaCertificate = service.TLSOptions.CACertificateFile
c.PrivateKey = service.TLSOptions.PrivateKeyFile
c.Certificate = service.TLSOptions.CertificateFile
- default:
- return fmt.Errorf("service not found: %s", curlService)
}
-
if dryRun {
log.Print(c.String())
} else {
@@ -73,7 +79,7 @@ $ vespa curl -- -v --data-urlencode "yql=select * from music where album contain
},
}
cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Print the curl command that would be executed")
- cmd.Flags().StringVarP(&curlService, "service", "s", "query", "Which service to query. Must be \"deploy\", \"document\" or \"query\"")
+ cmd.Flags().StringVarP(&curlService, "service", "s", "container", "Which service to query. Must be \"deploy\" or \"container\"")
cli.bindWaitFlag(cmd, 60, &waitSecs)
return cmd
}
diff --git a/client/go/internal/cli/cmd/curl_test.go b/client/go/internal/cli/cmd/curl_test.go
index 3eca0726bb4..520cf41e308 100644
--- a/client/go/internal/cli/cmd/curl_test.go
+++ b/client/go/internal/cli/cmd/curl_test.go
@@ -14,7 +14,6 @@ func TestCurl(t *testing.T) {
cli.Environment["VESPA_CLI_ENDPOINTS"] = "{\"endpoints\":[{\"cluster\":\"container\",\"url\":\"http://127.0.0.1:8080\"}]}"
assert.Nil(t, cli.Run("config", "set", "application", "t1.a1.i1"))
assert.Nil(t, cli.Run("config", "set", "target", "cloud"))
- assert.Nil(t, cli.Run("config", "set", "cluster", "container"))
assert.Nil(t, cli.Run("auth", "api-key"))
assert.Nil(t, cli.Run("auth", "cert", "--no-add"))
diff --git a/client/go/internal/cli/cmd/deploy.go b/client/go/internal/cli/cmd/deploy.go
index ef32d7f01b7..cf2e435fea5 100644
--- a/client/go/internal/cli/cmd/deploy.go
+++ b/client/go/internal/cli/cmd/deploy.go
@@ -25,7 +25,7 @@ func newDeployCmd(cli *CLI) *cobra.Command {
copyCert bool
)
cmd := &cobra.Command{
- Use: "deploy [application-directory]",
+ Use: "deploy [application-directory-or-file]",
Short: "Deploy (prepare and activate) an application package",
Long: `Deploy (prepare and activate) an application package.
@@ -73,8 +73,12 @@ $ vespa deploy -t cloud -z perf.aws-us-east-1c`,
return err
}
}
+ waiter := cli.waiter(false, timeout)
+ if _, err := waiter.DeployService(target); err != nil {
+ return err
+ }
var result vespa.PrepareResult
- if err := cli.spinner(cli.Stderr, "Uploading application package ...", func() error {
+ if err := cli.spinner(cli.Stderr, "Uploading application package...", func() error {
result, err = vespa.Deploy(opts)
return err
}); err != nil {
@@ -84,18 +88,18 @@ $ vespa deploy -t cloud -z perf.aws-us-east-1c`,
if opts.Target.IsCloud() {
cli.printSuccess("Triggered deployment of ", color.CyanString(pkg.Path), " with run ID ", color.CyanString(strconv.FormatInt(result.ID, 10)))
} else {
- cli.printSuccess("Deployed ", color.CyanString(pkg.Path))
+ cli.printSuccess("Deployed ", color.CyanString(pkg.Path), " with session ID ", color.CyanString(strconv.FormatInt(result.ID, 10)))
printPrepareLog(cli.Stderr, result)
}
if opts.Target.IsCloud() {
- log.Printf("\nUse %s for deployment status, or follow this deployment at", color.CyanString("vespa status"))
+ log.Printf("\nUse %s for deployment status, or follow this deployment at", color.CyanString("vespa status deployment"))
log.Print(color.CyanString(fmt.Sprintf("%s/tenant/%s/application/%s/%s/instance/%s/job/%s-%s/run/%d",
opts.Target.Deployment().System.ConsoleURL,
opts.Target.Deployment().Application.Tenant, opts.Target.Deployment().Application.Application, opts.Target.Deployment().Zone.Environment,
opts.Target.Deployment().Application.Instance, opts.Target.Deployment().Zone.Environment, opts.Target.Deployment().Zone.Region,
result.ID)))
}
- return waitForQueryService(cli, target, result.ID, timeout)
+ return waitForDeploymentReady(cli, target, result.ID, timeout)
},
}
cmd.Flags().StringVarP(&logLevelArg, "log-level", "l", "error", `Log level for Vespa logs. Must be "error", "warning", "info" or "debug"`)
@@ -107,7 +111,7 @@ $ vespa deploy -t cloud -z perf.aws-us-east-1c`,
func newPrepareCmd(cli *CLI) *cobra.Command {
return &cobra.Command{
- Use: "prepare application-directory",
+ Use: "prepare [application-directory-or-file]",
Short: "Prepare an application package for activation",
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
@@ -123,7 +127,7 @@ func newPrepareCmd(cli *CLI) *cobra.Command {
}
opts := vespa.DeploymentOptions{ApplicationPackage: pkg, Target: target}
var result vespa.PrepareResult
- err = cli.spinner(cli.Stderr, "Uploading application package ...", func() error {
+ err = cli.spinner(cli.Stderr, "Uploading application package...", func() error {
result, err = vespa.Prepare(opts)
return err
})
@@ -149,10 +153,6 @@ func newActivateCmd(cli *CLI) *cobra.Command {
DisableAutoGenTag: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
- pkg, err := cli.applicationPackageFrom(args, true)
- if err != nil {
- return fmt.Errorf("could not find application package: %w", err)
- }
sessionID, err := cli.config.readSessionID(vespa.DefaultApplication)
if err != nil {
return fmt.Errorf("could not read session id: %w", err)
@@ -162,24 +162,32 @@ func newActivateCmd(cli *CLI) *cobra.Command {
return err
}
timeout := time.Duration(waitSecs) * time.Second
- opts := vespa.DeploymentOptions{ApplicationPackage: pkg, Target: target, Timeout: timeout}
+ waiter := cli.waiter(false, timeout)
+ if _, err := waiter.DeployService(target); err != nil {
+ return err
+ }
+ opts := vespa.DeploymentOptions{Target: target, Timeout: timeout}
err = vespa.Activate(sessionID, opts)
if err != nil {
return err
}
- cli.printSuccess("Activated ", color.CyanString(pkg.Path), " with session ", sessionID)
- return waitForQueryService(cli, target, sessionID, timeout)
+ cli.printSuccess("Activated application with session ", sessionID)
+ return waitForDeploymentReady(cli, target, sessionID, timeout)
},
}
cli.bindWaitFlag(cmd, 60, &waitSecs)
return cmd
}
-func waitForQueryService(cli *CLI, target vespa.Target, sessionOrRunID int64, timeout time.Duration) error {
+func waitForDeploymentReady(cli *CLI, target vespa.Target, sessionOrRunID int64, timeout time.Duration) error {
if timeout == 0 {
return nil
}
- _, err := cli.service(target, vespa.QueryService, sessionOrRunID, cli.config.cluster(), timeout)
+ waiter := cli.waiter(false, timeout)
+ if _, err := waiter.Deployment(target, sessionOrRunID); err != nil {
+ return err
+ }
+ _, err := waiter.Services(target)
return err
}
diff --git a/client/go/internal/cli/cmd/deploy_test.go b/client/go/internal/cli/cmd/deploy_test.go
index 16aa3fd0ed8..d578b2a4629 100644
--- a/client/go/internal/cli/cmd/deploy_test.go
+++ b/client/go/internal/cli/cmd/deploy_test.go
@@ -6,6 +6,7 @@ package cmd
import (
"bytes"
+ "io"
"path/filepath"
"strconv"
"strings"
@@ -61,6 +62,32 @@ Hint: Pass --add-cert to use the certificate of the current application
assert.Contains(t, stdout.String(), "Success: Triggered deployment")
}
+func TestDeployWait(t *testing.T) {
+ cli, stdout, _ := newTestCLI(t)
+ client := &mock.HTTPClient{}
+ cli.httpClient = client
+ cli.retryInterval = 0
+ pkg := "testdata/applications/withSource/src/main/application"
+ // Deploy service is initially unavailable
+ client.NextResponseError(io.EOF)
+ client.NextStatus(500)
+ client.NextStatus(500)
+ // ... then becomes healthy
+ client.NextStatus(200)
+ // Deployment succeeds
+ client.NextResponse(mock.HTTPResponse{
+ URI: "/application/v2/tenant/default/prepareandactivate",
+ Status: 200,
+ Body: []byte(`{"session-id": "1"}`),
+ })
+ mockServiceStatus(client, "foo") // Wait for deployment
+ mockServiceStatus(client, "foo") // Look up services
+ assert.Nil(t, cli.Run("deploy", "--wait=3", pkg))
+ assert.Equal(t,
+ "\nSuccess: Deployed "+pkg+" with session ID 1\n",
+ stdout.String())
+}
+
func TestPrepareZip(t *testing.T) {
assertPrepare("testdata/applications/withTarget/target/application.zip",
[]string{"prepare", "testdata/applications/withTarget/target/application.zip"}, t)
@@ -85,7 +112,7 @@ func TestDeployZipWithURLTargetArgument(t *testing.T) {
cli.httpClient = client
assert.Nil(t, cli.Run(arguments...))
assert.Equal(t,
- "\nSuccess: Deployed "+applicationPackage+"\n",
+ "\nSuccess: Deployed "+applicationPackage+" with session ID 0\n",
stdout.String())
assertDeployRequestMade("http://target:19071", client, t)
}
@@ -161,7 +188,7 @@ func assertDeploy(applicationPackage string, arguments []string, t *testing.T) {
cli.httpClient = client
assert.Nil(t, cli.Run(arguments...))
assert.Equal(t,
- "\nSuccess: Deployed "+applicationPackage+"\n",
+ "\nSuccess: Deployed "+applicationPackage+" with session ID 0\n",
stdout.String())
assertDeployRequestMade("http://127.0.0.1:19071", client, t)
}
@@ -194,7 +221,7 @@ func assertActivate(applicationPackage string, arguments []string, t *testing.T)
}
assert.Nil(t, cli.Run(arguments...))
assert.Equal(t,
- "Success: Activated "+applicationPackage+" with session 42\n",
+ "Success: Activated application with session 42\n",
stdout.String())
url := "http://127.0.0.1:19071/application/v2/tenant/default/session/42/active"
assert.Equal(t, url, client.LastRequest.URL.String())
diff --git a/client/go/internal/cli/cmd/document.go b/client/go/internal/cli/cmd/document.go
index 1e5d1c30f6e..6c46baa297a 100644
--- a/client/go/internal/cli/cmd/document.go
+++ b/client/go/internal/cli/cmd/document.go
@@ -298,7 +298,8 @@ func documentService(cli *CLI, waitSecs int) (*vespa.Service, error) {
if err != nil {
return nil, err
}
- return cli.service(target, vespa.DocumentService, 0, cli.config.cluster(), time.Duration(waitSecs)*time.Second)
+ waiter := cli.waiter(false, time.Duration(waitSecs)*time.Second)
+ return waiter.Service(target, cli.config.cluster())
}
func printResult(cli *CLI, result util.OperationResult, payloadOnlyOnSuccess bool) error {
diff --git a/client/go/internal/cli/cmd/document_test.go b/client/go/internal/cli/cmd/document_test.go
index bce81da91c5..64319296299 100644
--- a/client/go/internal/cli/cmd/document_test.go
+++ b/client/go/internal/cli/cmd/document_test.go
@@ -79,7 +79,7 @@ func TestDocumentRemoveWithoutIdArgVerbose(t *testing.T) {
func TestDocumentSendMissingId(t *testing.T) {
cli, _, stderr := newTestCLI(t)
- assert.NotNil(t, cli.Run("document", "put", "testdata/A-Head-Full-of-Dreams-Without-Operation.json"))
+ assert.NotNil(t, cli.Run("-t", "http://127.0.0.1:8080", "document", "put", "testdata/A-Head-Full-of-Dreams-Without-Operation.json"))
assert.Equal(t,
"Error: no document id given neither as argument or as a 'put', 'update' or 'remove' key in the JSON file\n",
stderr.String())
@@ -87,7 +87,7 @@ func TestDocumentSendMissingId(t *testing.T) {
func TestDocumentSendWithDisagreeingOperations(t *testing.T) {
cli, _, stderr := newTestCLI(t)
- assert.NotNil(t, cli.Run("document", "update", "testdata/A-Head-Full-of-Dreams-Put.json"))
+ assert.NotNil(t, cli.Run("-t", "http://127.0.0.1:8080", "document", "update", "testdata/A-Head-Full-of-Dreams-Put.json"))
assert.Equal(t,
"Error: wanted document operation is update, but JSON file specifies put\n",
stderr.String())
@@ -110,20 +110,20 @@ func TestDocumentGet(t *testing.T) {
"id:mynamespace:music::a-head-full-of-dreams", t)
}
-func assertDocumentSend(arguments []string, expectedOperation string, expectedMethod string, expectedDocumentId string, expectedPayloadFile string, t *testing.T) {
+func assertDocumentSend(args []string, expectedOperation string, expectedMethod string, expectedDocumentId string, expectedPayloadFile string, t *testing.T) {
+ t.Helper()
client := &mock.HTTPClient{}
cli, stdout, stderr := newTestCLI(t)
cli.httpClient = client
- documentURL, err := documentServiceURL(client)
- if err != nil {
- t.Fatal(err)
- }
+ documentURL := "http://127.0.0.1:8080"
expectedPath, _ := vespa.IdToURLPath(expectedDocumentId)
expectedURL := documentURL + "/document/v1/" + expectedPath + "?timeout=60000ms"
- assert.Nil(t, cli.Run(arguments...))
+ finalArgs := []string{"-t", documentURL}
+ finalArgs = append(finalArgs, args...)
+ assert.Nil(t, cli.Run(finalArgs...))
verbose := false
- for _, a := range arguments {
+ for _, a := range args {
if a == "-v" {
verbose = true
}
@@ -154,16 +154,15 @@ func assertDocumentSend(arguments []string, expectedOperation string, expectedMe
}
}
-func assertDocumentGet(arguments []string, documentId string, t *testing.T) {
+func assertDocumentGet(args []string, documentId string, t *testing.T) {
client := &mock.HTTPClient{}
- documentURL, err := documentServiceURL(client)
- if err != nil {
- t.Fatal(err)
- }
+ documentURL := "http://127.0.0.1:8080"
client.NextResponseString(200, "{\"fields\":{\"foo\":\"bar\"}}")
cli, stdout, _ := newTestCLI(t)
cli.httpClient = client
- assert.Nil(t, cli.Run(arguments...))
+ finalArgs := []string{"-t", documentURL}
+ finalArgs = append(finalArgs, args...)
+ assert.Nil(t, cli.Run(finalArgs...))
assert.Equal(t,
`{
"fields": {
@@ -182,7 +181,7 @@ func assertDocumentTransportError(t *testing.T, errorMessage string) {
client.NextResponseError(fmt.Errorf(errorMessage))
cli, _, stderr := newTestCLI(t)
cli.httpClient = client
- assert.NotNil(t, cli.Run("document", "put",
+ assert.NotNil(t, cli.Run("-t", "http://127.0.0.1:8080", "document", "put",
"id:mynamespace:music::a-head-full-of-dreams",
"testdata/A-Head-Full-of-Dreams-Put.json"))
assert.Equal(t,
@@ -195,7 +194,7 @@ func assertDocumentError(t *testing.T, status int, errorMessage string) {
client.NextResponseString(status, errorMessage)
cli, _, stderr := newTestCLI(t)
cli.httpClient = client
- assert.NotNil(t, cli.Run("document", "put",
+ assert.NotNil(t, cli.Run("-t", "http://127.0.0.1:8080", "document", "put",
"id:mynamespace:music::a-head-full-of-dreams",
"testdata/A-Head-Full-of-Dreams-Put.json"))
assert.Equal(t,
@@ -208,14 +207,10 @@ func assertDocumentServerError(t *testing.T, status int, errorMessage string) {
client.NextResponseString(status, errorMessage)
cli, _, stderr := newTestCLI(t)
cli.httpClient = client
- assert.NotNil(t, cli.Run("document", "put",
+ assert.NotNil(t, cli.Run("-t", "http://127.0.0.1:8080", "document", "put",
"id:mynamespace:music::a-head-full-of-dreams",
"testdata/A-Head-Full-of-Dreams-Put.json"))
assert.Equal(t,
- "Error: Container (document API) at http://127.0.0.1:8080: Status "+strconv.Itoa(status)+"\n\n"+errorMessage+"\n",
+ "Error: container at http://127.0.0.1:8080: Status "+strconv.Itoa(status)+"\n\n"+errorMessage+"\n",
stderr.String())
}
-
-func documentServiceURL(client *mock.HTTPClient) (string, error) {
- return "http://127.0.0.1:8080", nil
-}
diff --git a/client/go/internal/cli/cmd/feed.go b/client/go/internal/cli/cmd/feed.go
index 7d4b9cc8042..8b8589baec3 100644
--- a/client/go/internal/cli/cmd/feed.go
+++ b/client/go/internal/cli/cmd/feed.go
@@ -12,7 +12,6 @@ import (
"github.com/spf13/cobra"
"github.com/vespa-engine/vespa/client/go/internal/util"
- "github.com/vespa-engine/vespa/client/go/internal/vespa"
"github.com/vespa-engine/vespa/client/go/internal/vespa/document"
)
@@ -57,17 +56,17 @@ type feedOptions struct {
func newFeedCmd(cli *CLI) *cobra.Command {
var options feedOptions
cmd := &cobra.Command{
- Use: "feed FILE [FILE]...",
+ Use: "feed json-file [json-file]...",
Short: "Feed multiple document operations to a Vespa cluster",
Long: `Feed multiple document operations to a Vespa cluster.
This command can be used to feed large amounts of documents to a Vespa cluster
efficiently.
-The contents of FILE must be either a JSON array or JSON objects separated by
+The contents of JSON-FILE must be either a JSON array or JSON objects separated by
newline (JSONL).
-If FILE is a single dash ('-'), documents will be read from standard input.
+If JSON-FILE is a single dash ('-'), documents will be read from standard input.
`,
Example: `$ vespa feed docs.jsonl moredocs.json
$ cat docs.jsonl | vespa feed -`,
@@ -108,8 +107,9 @@ func createServices(n int, timeout time.Duration, waitSecs int, cli *CLI) ([]uti
}
services := make([]util.HTTPClient, 0, n)
baseURL := ""
+ waiter := cli.waiter(false, time.Duration(waitSecs)*time.Second)
for i := 0; i < n; i++ {
- service, err := cli.service(target, vespa.DocumentService, 0, cli.config.cluster(), time.Duration(waitSecs)*time.Second)
+ service, err := waiter.Service(target, cli.config.cluster())
if err != nil {
return nil, "", err
}
diff --git a/client/go/internal/cli/cmd/feed_test.go b/client/go/internal/cli/cmd/feed_test.go
index fc2c5ec7520..84328cad5fb 100644
--- a/client/go/internal/cli/cmd/feed_test.go
+++ b/client/go/internal/cli/cmd/feed_test.go
@@ -43,7 +43,7 @@ func TestFeed(t *testing.T) {
httpClient.NextResponseString(200, `{"message":"OK"}`)
httpClient.NextResponseString(200, `{"message":"OK"}`)
- require.Nil(t, cli.Run("feed", jsonFile1, jsonFile2))
+ require.Nil(t, cli.Run("feed", "-t", "http://127.0.0.1:8080", jsonFile1, jsonFile2))
assert.Equal(t, "", stderr.String())
want := `{
@@ -113,7 +113,7 @@ func TestFeedInvalid(t *testing.T) {
jsonFile := filepath.Join(td, "docs.jsonl")
require.Nil(t, os.WriteFile(jsonFile, doc, 0644))
httpClient.NextResponseString(200, `{"message":"OK"}`)
- require.NotNil(t, cli.Run("feed", jsonFile))
+ require.NotNil(t, cli.Run("feed", "-t", "http://127.0.0.1:8080", jsonFile))
want := `{
"feeder.seconds": 3.000,
diff --git a/client/go/internal/cli/cmd/prod.go b/client/go/internal/cli/cmd/prod.go
index 3b37197340f..79a6907eef2 100644
--- a/client/go/internal/cli/cmd/prod.go
+++ b/client/go/internal/cli/cmd/prod.go
@@ -114,7 +114,7 @@ type prodDeployOptions struct {
func newProdDeployCmd(cli *CLI) *cobra.Command {
var options prodDeployOptions
cmd := &cobra.Command{
- Use: "deploy",
+ Use: "deploy [application-directory-or-file]",
Aliases: []string{"submit"}, // TODO: Remove in Vespa 9
Short: "Deploy an application to production",
Long: `Deploy an application to production.
diff --git a/client/go/internal/cli/cmd/query.go b/client/go/internal/cli/cmd/query.go
index a5b35052b11..3f849fae99e 100644
--- a/client/go/internal/cli/cmd/query.go
+++ b/client/go/internal/cli/cmd/query.go
@@ -64,7 +64,8 @@ func query(cli *CLI, arguments []string, timeoutSecs, waitSecs int, curl bool) e
if err != nil {
return err
}
- service, err := cli.service(target, vespa.QueryService, 0, cli.config.cluster(), time.Duration(waitSecs)*time.Second)
+ waiter := cli.waiter(false, time.Duration(waitSecs)*time.Second)
+ service, err := waiter.Service(target, cli.config.cluster())
if err != nil {
return err
}
diff --git a/client/go/internal/cli/cmd/query_test.go b/client/go/internal/cli/cmd/query_test.go
index 1caf6d33e70..6d5adc0508e 100644
--- a/client/go/internal/cli/cmd/query_test.go
+++ b/client/go/internal/cli/cmd/query_test.go
@@ -26,7 +26,7 @@ func TestQueryVerbose(t *testing.T) {
cli, stdout, stderr := newTestCLI(t)
cli.httpClient = client
- assert.Nil(t, cli.Run("query", "-v", "select from sources * where title contains 'foo'"))
+ assert.Nil(t, cli.Run("-t", "http://127.0.0.1:8080", "query", "-v", "select from sources * where title contains 'foo'"))
assert.Equal(t, "curl 'http://127.0.0.1:8080/search/?timeout=10s&yql=select+from+sources+%2A+where+title+contains+%27foo%27'\n", stderr.String())
assert.Equal(t, "{\n \"query\": \"result\"\n}\n", stdout.String())
}
@@ -75,7 +75,7 @@ func assertQuery(t *testing.T, expectedQuery string, query ...string) {
cli, stdout, _ := newTestCLI(t)
cli.httpClient = client
- args := []string{"query"}
+ args := []string{"-t", "http://127.0.0.1:8080", "query"}
assert.Nil(t, cli.Run(append(args, query...)...))
assert.Equal(t,
"{\n \"query\": \"result\"\n}\n",
@@ -91,7 +91,7 @@ func assertQueryError(t *testing.T, status int, errorMessage string) {
client.NextResponseString(status, errorMessage)
cli, _, stderr := newTestCLI(t)
cli.httpClient = client
- assert.NotNil(t, cli.Run("query", "yql=select from sources * where title contains 'foo'"))
+ assert.NotNil(t, cli.Run("-t", "http://127.0.0.1:8080", "query", "yql=select from sources * where title contains 'foo'"))
assert.Equal(t,
"Error: invalid query: Status "+strconv.Itoa(status)+"\n"+errorMessage+"\n",
stderr.String(),
@@ -103,7 +103,7 @@ func assertQueryServiceError(t *testing.T, status int, errorMessage string) {
client.NextResponseString(status, errorMessage)
cli, _, stderr := newTestCLI(t)
cli.httpClient = client
- assert.NotNil(t, cli.Run("query", "yql=select from sources * where title contains 'foo'"))
+ assert.NotNil(t, cli.Run("-t", "http://127.0.0.1:8080", "query", "yql=select from sources * where title contains 'foo'"))
assert.Equal(t,
"Error: Status "+strconv.Itoa(status)+" from container at 127.0.0.1:8080\n"+errorMessage+"\n",
stderr.String(),
diff --git a/client/go/internal/cli/cmd/root.go b/client/go/internal/cli/cmd/root.go
index ad42ea588b0..69fd88c1b2b 100644
--- a/client/go/internal/cli/cmd/root.go
+++ b/client/go/internal/cli/cmd/root.go
@@ -43,6 +43,13 @@ type CLI struct {
Stdout io.Writer
Stderr io.Writer
+ exec executor
+ isTerminal func() bool
+ spinner func(w io.Writer, message string, fn func() error) error
+
+ now func() time.Time
+ retryInterval time.Duration
+
cmd *cobra.Command
config *Config
version version.Version
@@ -51,10 +58,6 @@ type CLI struct {
httpClientFactory func(timeout time.Duration) util.HTTPClient
auth0Factory auth0Factory
ztsFactory ztsFactory
- exec executor
- isTerminal func() bool
- spinner func(w io.Writer, message string, fn func() error) error
- now func() time.Time
}
// ErrCLI is an error returned to the user. It wraps an exit status, a regular error and optional hints for resolving
@@ -100,7 +103,7 @@ type ztsFactory func(httpClient util.HTTPClient, domain, url string) (vespa.Auth
// New creates the Vespa CLI, writing output to stdout and stderr, and reading environment variables from environment.
func New(stdout, stderr io.Writer, environment []string) (*CLI, error) {
cmd := &cobra.Command{
- Use: "vespa command-name",
+ Use: "vespa",
Short: "The command-line tool for Vespa.ai",
Long: `The command-line tool for Vespa.ai.
@@ -134,12 +137,15 @@ For detailed description of flags and configuration, see 'vespa help config'.
Stdout: stdout,
Stderr: stderr,
- version: version,
- cmd: cmd,
+ exec: &execSubprocess{},
+ now: time.Now,
+ retryInterval: 2 * time.Second,
+
+ version: version,
+ cmd: cmd,
+
httpClient: httpClientFactory(time.Second * 10),
httpClientFactory: httpClientFactory,
- exec: &execSubprocess{},
- now: time.Now,
auth0Factory: func(httpClient util.HTTPClient, options auth0.Options) (vespa.Authenticator, error) {
return auth0.NewClient(httpClient, options)
},
@@ -267,9 +273,8 @@ func (c *CLI) configureCommands() {
prodCmd.AddCommand(newProdDeployCmd(c)) // prod deploy
rootCmd.AddCommand(prodCmd) // prod
rootCmd.AddCommand(newQueryCmd(c)) // query
- statusCmd.AddCommand(newStatusQueryCmd(c)) // status query
- statusCmd.AddCommand(newStatusDocumentCmd(c)) // status document
statusCmd.AddCommand(newStatusDeployCmd(c)) // status deploy
+ statusCmd.AddCommand(newStatusDeploymentCmd(c)) // status deployment
rootCmd.AddCommand(statusCmd) // status
rootCmd.AddCommand(newTestCmd(c)) // test
rootCmd.AddCommand(newVersionCmd(c)) // version
@@ -278,7 +283,7 @@ func (c *CLI) configureCommands() {
}
func (c *CLI) bindWaitFlag(cmd *cobra.Command, defaultSecs int, value *int) {
- desc := "Number of seconds to wait for a service to become ready. 0 to disable"
+ desc := "Number of seconds to wait for service(s) to become ready. 0 to disable"
if defaultSecs == 0 {
desc += " (default 0)"
}
@@ -296,6 +301,10 @@ func (c *CLI) printSuccess(msg ...interface{}) {
fmt.Fprintln(c.Stdout, color.GreenString("Success:"), fmt.Sprint(msg...))
}
+func (c *CLI) printInfo(msg ...interface{}) {
+ fmt.Fprintln(c.Stderr, fmt.Sprint(msg...))
+}
+
func (c *CLI) printDebug(msg ...interface{}) {
fmt.Fprintln(c.Stderr, color.CyanString("Debug:"), fmt.Sprint(msg...))
}
@@ -334,6 +343,10 @@ func (c *CLI) confirm(question string, confirmByDefault bool) (bool, error) {
}
}
+func (c *CLI) waiter(once bool, timeout time.Duration) *Waiter {
+ return &Waiter{Once: once, Timeout: timeout, cli: c}
+}
+
// target creates a target according the configuration of this CLI and given opts.
func (c *CLI) target(opts targetOptions) (vespa.Target, error) {
targetType, err := c.targetType()
@@ -402,9 +415,9 @@ func (c *CLI) createCustomTarget(targetType, customURL string) (vespa.Target, er
}
switch targetType {
case vespa.TargetLocal:
- return vespa.LocalTarget(c.httpClient, tlsOptions), nil
+ return vespa.LocalTarget(c.httpClient, tlsOptions, c.retryInterval), nil
case vespa.TargetCustom:
- return vespa.CustomTarget(c.httpClient, customURL, tlsOptions), nil
+ return vespa.CustomTarget(c.httpClient, customURL, tlsOptions, c.retryInterval), nil
default:
return nil, fmt.Errorf("invalid custom target: %s", targetType)
}
@@ -486,7 +499,7 @@ func (c *CLI) createCloudTarget(targetType string, opts targetOptions, customURL
Writer: c.Stdout,
Level: vespa.LogLevel(logLevel),
}
- return vespa.CloudTarget(c.httpClient, apiAuth, deploymentAuth, apiOptions, deploymentOptions, logOptions)
+ return vespa.CloudTarget(c.httpClient, apiAuth, deploymentAuth, apiOptions, deploymentOptions, logOptions, c.retryInterval)
}
// system returns the appropiate system for the target configured in this CLI.
@@ -504,24 +517,6 @@ func (c *CLI) system(targetType string) (vespa.System, error) {
return vespa.System{}, fmt.Errorf("no default system found for %s target", targetType)
}
-// service returns the service of given name located at target. If non-empty, cluster specifies a cluster to query. This
-// function blocks according to the wait period configured in this CLI. The parameter sessionOrRunID specifies either
-// the session ID (local target) or run ID (cloud target) to wait for.
-func (c *CLI) service(target vespa.Target, name string, sessionOrRunID int64, cluster string, timeout time.Duration) (*vespa.Service, error) {
- if timeout > 0 {
- log.Printf("Waiting up to %s for %s service to become available ...", color.CyanString(timeout.String()), color.CyanString(name))
- }
- s, err := target.Service(name, timeout, sessionOrRunID, cluster)
- if err != nil {
- err := fmt.Errorf("service '%s' is unavailable: %w", name, err)
- if target.IsCloud() {
- return nil, errHint(err, "Confirm that you're communicating with the correct zone and cluster", "The -z option controls the zone", "The -C option controls the cluster")
- }
- return nil, err
- }
- return s, nil
-}
-
// isCI returns true if running inside a continuous integration environment.
func (c *CLI) isCI() bool {
_, ok := c.Environment["CI"]
diff --git a/client/go/internal/cli/cmd/status.go b/client/go/internal/cli/cmd/status.go
index 6570aeff448..f185fde6ca1 100644
--- a/client/go/internal/cli/cmd/status.go
+++ b/client/go/internal/cli/cmd/status.go
@@ -7,6 +7,8 @@ package cmd
import (
"fmt"
"log"
+ "strconv"
+ "strings"
"time"
"github.com/fatih/color"
@@ -17,94 +19,127 @@ import (
func newStatusCmd(cli *CLI) *cobra.Command {
var waitSecs int
cmd := &cobra.Command{
- Use: "status",
- Short: "Verify that a service is ready to use (query by default)",
- Example: `$ vespa status query`,
- DisableAutoGenTag: true,
- SilenceUsage: true,
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- return printServiceStatus(cli, vespa.QueryService, waitSecs)
+ Use: "status",
+ Aliases: []string{
+ "status container",
+ "status document", // TODO: Remove on Vespa 9
+ "status query", // TODO: Remove on Vespa 9
},
- }
- cli.bindWaitFlag(cmd, 0, &waitSecs)
- return cmd
-}
-
-func newStatusQueryCmd(cli *CLI) *cobra.Command {
- var waitSecs int
- cmd := &cobra.Command{
- Use: "query",
- Short: "Verify that the query service is ready to use (default)",
- Example: `$ vespa status query`,
+ Short: "Verify that container service(s) are ready to use",
+ Example: `$ vespa status
+$ vespa status --cluster mycluster`,
DisableAutoGenTag: true,
SilenceUsage: true,
- Args: cobra.ExactArgs(0),
+ Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- return printServiceStatus(cli, vespa.QueryService, waitSecs)
+ cluster := cli.config.cluster()
+ t, err := cli.target(targetOptions{})
+ if err != nil {
+ return err
+ }
+ waiter := cli.waiter(true, time.Duration(waitSecs)*time.Second)
+ if cluster == "" {
+ services, err := waiter.Services(t)
+ if err != nil {
+ return err
+ }
+ if len(services) == 0 {
+ return errHint(fmt.Errorf("no services exist"), "Deployment may not be ready yet", "Try 'vespa status deployment'")
+ }
+ for _, s := range services {
+ printReadyService(s, cli)
+ }
+ return nil
+ } else {
+ s, err := waiter.Service(t, cluster)
+ if err != nil {
+ return err
+ }
+ printReadyService(s, cli)
+ return nil
+ }
},
}
cli.bindWaitFlag(cmd, 0, &waitSecs)
return cmd
}
-func newStatusDocumentCmd(cli *CLI) *cobra.Command {
+func newStatusDeployCmd(cli *CLI) *cobra.Command {
var waitSecs int
cmd := &cobra.Command{
- Use: "document",
- Short: "Verify that the document service is ready to use",
- Example: `$ vespa status document`,
+ Use: "deploy",
+ Short: "Verify that the deploy service is ready to use",
+ Example: `$ vespa status deploy`,
DisableAutoGenTag: true,
SilenceUsage: true,
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
- return printServiceStatus(cli, vespa.DocumentService, waitSecs)
+ t, err := cli.target(targetOptions{})
+ if err != nil {
+ return err
+ }
+ waiter := cli.waiter(true, time.Duration(waitSecs)*time.Second)
+ s, err := waiter.DeployService(t)
+ if err != nil {
+ return err
+ }
+ printReadyService(s, cli)
+ return nil
},
}
cli.bindWaitFlag(cmd, 0, &waitSecs)
return cmd
}
-func newStatusDeployCmd(cli *CLI) *cobra.Command {
+func newStatusDeploymentCmd(cli *CLI) *cobra.Command {
var waitSecs int
cmd := &cobra.Command{
- Use: "deploy",
- Short: "Verify that the deploy service is ready to use",
- Example: `$ vespa status deploy`,
+ Use: "deployment",
+ Short: "Verify that deployment has converged on latest, or given, ID",
+ Example: `$ vespa status deployment
+$ vespa status deployment -t cloud [run-id]
+$ vespa status deployment -t local [session-id]
+`,
DisableAutoGenTag: true,
SilenceUsage: true,
- Args: cobra.ExactArgs(0),
+ Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- return printServiceStatus(cli, vespa.DeployService, waitSecs)
+ wantedID := vespa.LatestDeployment
+ if len(args) > 0 {
+ n, err := strconv.ParseInt(args[0], 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid id: %s: %w", args[0], err)
+ }
+ wantedID = n
+ }
+ t, err := cli.target(targetOptions{logLevel: "none"})
+ if err != nil {
+ return err
+ }
+ waiter := cli.waiter(true, time.Duration(waitSecs)*time.Second)
+ id, err := waiter.Deployment(t, wantedID)
+ if err != nil {
+ return err
+ }
+ if t.IsCloud() {
+ log.Printf("Deployment run %s has completed", color.CyanString(strconv.FormatInt(id, 10)))
+ log.Printf("See %s for more details", color.CyanString(fmt.Sprintf("%s/tenant/%s/application/%s/%s/instance/%s/job/%s-%s/run/%d",
+ t.Deployment().System.ConsoleURL,
+ t.Deployment().Application.Tenant, t.Deployment().Application.Application, t.Deployment().Zone.Environment,
+ t.Deployment().Application.Instance, t.Deployment().Zone.Environment, t.Deployment().Zone.Region,
+ id)))
+ } else {
+ log.Printf("Deployment is %s on config generation %s", color.GreenString("ready"), color.CyanString(strconv.FormatInt(id, 10)))
+ }
+ return nil
},
}
cli.bindWaitFlag(cmd, 0, &waitSecs)
return cmd
}
-func printServiceStatus(cli *CLI, name string, waitSecs int) error {
- t, err := cli.target(targetOptions{})
- if err != nil {
- return err
- }
- cluster := cli.config.cluster()
- s, err := cli.service(t, name, 0, cluster, 0)
- if err != nil {
- return err
- }
- // Wait explicitly
- status, err := s.Wait(time.Duration(waitSecs) * time.Second)
- clusterPart := ""
- if cluster != "" {
- clusterPart = fmt.Sprintf(" named %s", color.CyanString(cluster))
- }
- if status/100 == 2 {
- log.Print(s.Description(), clusterPart, " at ", color.CyanString(s.BaseURL), " is ", color.GreenString("ready"))
- } else {
- if err == nil {
- err = fmt.Errorf("status %d", status)
- }
- return fmt.Errorf("%s%s at %s is %s: %w", s.Description(), clusterPart, color.CyanString(s.BaseURL), color.RedString("not ready"), err)
- }
- return nil
+func printReadyService(s *vespa.Service, cli *CLI) {
+ desc := s.Description()
+ desc = strings.ToUpper(string(desc[0])) + string(desc[1:])
+ log.Print(desc, " at ", color.CyanString(s.BaseURL), " is ", color.GreenString("ready"))
}
diff --git a/client/go/internal/cli/cmd/status_test.go b/client/go/internal/cli/cmd/status_test.go
index 76efea55503..36f51ff5073 100644
--- a/client/go/internal/cli/cmd/status_test.go
+++ b/client/go/internal/cli/cmd/status_test.go
@@ -5,10 +5,12 @@
package cmd
import (
+ "io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/vespa-engine/vespa/client/go/internal/mock"
+ "github.com/vespa-engine/vespa/client/go/internal/vespa"
)
func TestStatusDeployCommand(t *testing.T) {
@@ -23,63 +25,184 @@ func TestStatusDeployCommandWithLocalTarget(t *testing.T) {
assertDeployStatus("http://127.0.0.1:19071", []string{"-t", "local"}, t)
}
-func TestStatusQueryCommand(t *testing.T) {
- assertQueryStatus("http://127.0.0.1:8080", []string{}, t)
+func TestStatusCommand(t *testing.T) {
+ assertStatus("http://127.0.0.1:8080", []string{}, t)
}
-func TestStatusQueryCommandWithUrlTarget(t *testing.T) {
- assertQueryStatus("http://mycontainertarget:8080", []string{"-t", "http://mycontainertarget:8080"}, t)
+func TestStatusCommandMultiCluster(t *testing.T) {
+ client := &mock.HTTPClient{}
+ cli, stdout, stderr := newTestCLI(t)
+ cli.httpClient = client
+
+ mockServiceStatus(client)
+ assert.NotNil(t, cli.Run("status"))
+ assert.Equal(t, "Error: no services exist\nHint: Deployment may not be ready yet\nHint: Try 'vespa status deployment'\n", stderr.String())
+
+ mockServiceStatus(client, "foo", "bar")
+ assert.Nil(t, cli.Run("status"))
+ assert.Equal(t, `Container bar at http://127.0.0.1:8080 is ready
+Container foo at http://127.0.0.1:8080 is ready
+`, stdout.String())
+
+ stdout.Reset()
+ mockServiceStatus(client, "foo", "bar")
+ assert.Nil(t, cli.Run("status", "--cluster", "foo"))
+ assert.Equal(t, "Container foo at http://127.0.0.1:8080 is ready\n", stdout.String())
+}
+
+func TestStatusCommandWithUrlTarget(t *testing.T) {
+ assertStatus("http://mycontainertarget:8080", []string{"-t", "http://mycontainertarget:8080"}, t)
+}
+
+func TestStatusCommandWithLocalTarget(t *testing.T) {
+ assertStatus("http://127.0.0.1:8080", []string{"-t", "local"}, t)
+}
+
+func TestStatusError(t *testing.T) {
+ client := &mock.HTTPClient{}
+ mockServiceStatus(client, "default")
+ client.NextStatus(500)
+ cli, _, stderr := newTestCLI(t)
+ cli.httpClient = client
+ assert.NotNil(t, cli.Run("status", "container"))
+ assert.Equal(t,
+ "Error: unhealthy container default: status 500 at http://127.0.0.1:8080/ApplicationStatus: wait timed out\n",
+ stderr.String())
+
+ stderr.Reset()
+ client.NextResponseError(io.EOF)
+ assert.NotNil(t, cli.Run("status", "container", "-t", "http://example.com"))
+ assert.Equal(t,
+ "Error: unhealthy container at http://example.com/ApplicationStatus: EOF\n",
+ stderr.String())
}
-func TestStatusQueryCommandWithLocalTarget(t *testing.T) {
- assertQueryStatus("http://127.0.0.1:8080", []string{"-t", "local"}, t)
+func TestStatusLocalDeployment(t *testing.T) {
+ client := &mock.HTTPClient{}
+ cli, stdout, stderr := newTestCLI(t)
+ cli.httpClient = client
+ resp := mock.HTTPResponse{
+ URI: "/application/v2/tenant/default/application/default/environment/prod/region/default/instance/default/serviceconverge",
+ Status: 200,
+ }
+ // Latest generation
+ resp.Body = []byte(`{"currentGeneration": 42, "converged": true}`)
+ client.NextResponse(resp)
+ assert.Nil(t, cli.Run("status", "deployment"))
+ assert.Equal(t, "", stderr.String())
+ assert.Equal(t, "Deployment is ready on config generation 42\n", stdout.String())
+
+ // Latest generation without convergence
+ resp.Body = []byte(`{"currentGeneration": 42, "converged": false}`)
+ client.NextResponse(resp)
+ assert.NotNil(t, cli.Run("status", "deployment"))
+ assert.Equal(t, "Error: deployment not converged on latest generation after waiting 0s: wait timed out\n", stderr.String())
+
+ // Explicit generation
+ stderr.Reset()
+ client.NextResponse(resp)
+ assert.NotNil(t, cli.Run("status", "deployment", "41"))
+ assert.Equal(t, "Error: deployment not converged on generation 41 after waiting 0s: wait timed out\n", stderr.String())
}
-func TestStatusDocumentCommandWithLocalTarget(t *testing.T) {
- assertDocumentStatus("http://127.0.0.1:8080", []string{"-t", "local"}, t)
+func TestStatusCloudDeployment(t *testing.T) {
+ cli, stdout, stderr := newTestCLI(t, "CI=true")
+ app := vespa.ApplicationID{Tenant: "t1", Application: "a1", Instance: "i1"}
+ assert.Nil(t, cli.Run("config", "set", "application", app.String()))
+ assert.Nil(t, cli.Run("config", "set", "target", "cloud"))
+ assert.Nil(t, cli.Run("config", "set", "zone", "dev.us-north-1"))
+ assert.Nil(t, cli.Run("auth", "api-key"))
+ stdout.Reset()
+ client := &mock.HTTPClient{}
+ cli.httpClient = client
+ // Latest run
+ client.NextResponse(mock.HTTPResponse{
+ URI: "/application/v4/tenant/t1/application/a1/instance/i1/job/dev-us-north-1?limit=1",
+ Status: 200,
+ Body: []byte(`{"runs": [{"id": 1337}]}`),
+ })
+ client.NextResponse(mock.HTTPResponse{
+ URI: "/application/v4/tenant/t1/application/a1/instance/i1/job/dev-us-north-1/run/1337?after=-1",
+ Status: 200,
+ Body: []byte(`{"active": false, "status": "success"}`),
+ })
+ assert.Nil(t, cli.Run("status", "deployment"))
+ assert.Equal(t, "", stderr.String())
+ assert.Equal(t,
+ "Deployment run 1337 has completed\nSee https://console.vespa-cloud.com/tenant/t1/application/a1/dev/instance/i1/job/dev-us-north-1/run/1337 for more details\n",
+ stdout.String())
+ // Explicit run
+ client.NextResponse(mock.HTTPResponse{
+ URI: "/application/v4/tenant/t1/application/a1/instance/i1/job/dev-us-north-1/run/42?after=-1",
+ Status: 200,
+ Body: []byte(`{"active": false, "status": "failure"}`),
+ })
+ assert.NotNil(t, cli.Run("status", "deployment", "42"))
+ assert.Equal(t, "Error: deployment run 42 incomplete after waiting 0s: run 42 ended with unsuccessful status: failure\n", stderr.String())
}
-func TestStatusErrorResponse(t *testing.T) {
- assertQueryStatusError("http://127.0.0.1:8080", []string{}, t)
+func isLocalTarget(args []string) bool {
+ for i := 0; i < len(args)-1; i++ {
+ if args[i] == "-t" {
+ return args[i+1] == "local"
+ }
+ }
+ return true // local is default
}
-func assertDeployStatus(target string, args []string, t *testing.T) {
+func assertDeployStatus(expectedTarget string, args []string, t *testing.T) {
+ t.Helper()
client := &mock.HTTPClient{}
+ client.NextResponse(mock.HTTPResponse{
+ URI: "/status.html",
+ Status: 200,
+ })
cli, stdout, _ := newTestCLI(t)
cli.httpClient = client
statusArgs := []string{"status", "deploy"}
assert.Nil(t, cli.Run(append(statusArgs, args...)...))
assert.Equal(t,
- "Deploy API at "+target+" is ready\n",
- stdout.String(),
- "vespa status config-server")
- assert.Equal(t, target+"/status.html", client.LastRequest.URL.String())
+ "Deploy API at "+expectedTarget+" is ready\n",
+ stdout.String())
+ assert.Equal(t, expectedTarget+"/status.html", client.LastRequest.URL.String())
}
-func assertQueryStatus(target string, args []string, t *testing.T) {
+func assertStatus(expectedTarget string, args []string, t *testing.T) {
+ t.Helper()
client := &mock.HTTPClient{}
+ clusterName := ""
+ for i := 0; i < 2; i++ {
+ if isLocalTarget(args) {
+ clusterName = "foo"
+ mockServiceStatus(client, clusterName)
+ }
+ client.NextResponse(mock.HTTPResponse{URI: "/ApplicationStatus", Status: 200})
+ }
cli, stdout, _ := newTestCLI(t)
cli.httpClient = client
- statusArgs := []string{"status", "query"}
+ statusArgs := []string{"status"}
assert.Nil(t, cli.Run(append(statusArgs, args...)...))
- assert.Equal(t,
- "Container (query API) at "+target+" is ready\n",
- stdout.String(),
- "vespa status container")
- assert.Equal(t, target+"/ApplicationStatus", client.LastRequest.URL.String())
-
- statusArgs = []string{"status"}
+ prefix := "Container"
+ if clusterName != "" {
+ prefix += " " + clusterName
+ }
+ assert.Equal(t, prefix+" at "+expectedTarget+" is ready\n", stdout.String())
+ assert.Equal(t, expectedTarget+"/ApplicationStatus", client.LastRequest.URL.String())
+
+ // Test legacy command
+ statusArgs = []string{"status query"}
stdout.Reset()
assert.Nil(t, cli.Run(append(statusArgs, args...)...))
- assert.Equal(t,
- "Container (query API) at "+target+" is ready\n",
- stdout.String(),
- "vespa status (the default)")
- assert.Equal(t, target+"/ApplicationStatus", client.LastRequest.URL.String())
+ assert.Equal(t, prefix+" at "+expectedTarget+" is ready\n", stdout.String())
+ assert.Equal(t, expectedTarget+"/ApplicationStatus", client.LastRequest.URL.String())
}
func assertDocumentStatus(target string, args []string, t *testing.T) {
+ t.Helper()
client := &mock.HTTPClient{}
+ if isLocalTarget(args) {
+ mockServiceStatus(client, "default")
+ }
cli, stdout, _ := newTestCLI(t)
cli.httpClient = client
assert.Nil(t, cli.Run("status", "document"))
@@ -89,15 +212,3 @@ func assertDocumentStatus(target string, args []string, t *testing.T) {
"vespa status container")
assert.Equal(t, target+"/ApplicationStatus", client.LastRequest.URL.String())
}
-
-func assertQueryStatusError(target string, args []string, t *testing.T) {
- client := &mock.HTTPClient{}
- client.NextStatus(500)
- cli, _, stderr := newTestCLI(t)
- cli.httpClient = client
- assert.NotNil(t, cli.Run("status", "container"))
- assert.Equal(t,
- "Error: Container (query API) at "+target+" is not ready: status 500\n",
- stderr.String(),
- "vespa status container")
-}
diff --git a/client/go/internal/cli/cmd/test.go b/client/go/internal/cli/cmd/test.go
index abee760efbb..5b99973d879 100644
--- a/client/go/internal/cli/cmd/test.go
+++ b/client/go/internal/cli/cmd/test.go
@@ -79,7 +79,7 @@ func runTests(cli *CLI, rootPath string, dryRun bool, waitSecs int) (int, []stri
if err != nil {
return 0, nil, errHint(err, "See https://docs.vespa.ai/en/reference/testing")
}
- context := testContext{testsPath: rootPath, dryRun: dryRun, cli: cli}
+ context := testContext{testsPath: rootPath, dryRun: dryRun, cli: cli, clusters: map[string]*vespa.Service{}}
previousFailed := false
for _, test := range tests {
if !test.IsDir() && filepath.Ext(test.Name()) == ".json" {
@@ -100,7 +100,7 @@ func runTests(cli *CLI, rootPath string, dryRun bool, waitSecs int) (int, []stri
}
}
} else if strings.HasSuffix(stat.Name(), ".json") {
- failure, err := runTest(rootPath, testContext{testsPath: filepath.Dir(rootPath), dryRun: dryRun, cli: cli}, waitSecs)
+ failure, err := runTest(rootPath, testContext{testsPath: filepath.Dir(rootPath), dryRun: dryRun, cli: cli, clusters: map[string]*vespa.Service{}}, waitSecs)
if err != nil {
return 0, nil, err
}
@@ -216,9 +216,16 @@ func verify(step step, defaultCluster string, defaultParameters map[string]strin
if err != nil {
return "", "", err
}
- service, err = target.Service(vespa.QueryService, time.Duration(waitSecs)*time.Second, 0, cluster)
- if err != nil {
- return "", "", err
+ ok := false
+ service, ok = context.clusters[cluster]
+ if !ok {
+ // Cache service so we don't have to discover it for every step
+ waiter := context.cli.waiter(false, time.Duration(waitSecs)*time.Second)
+ service, err = waiter.Service(target, cluster)
+ if err != nil {
+ return "", "", err
+ }
+ context.clusters[cluster] = service
}
requestUrl, err = url.ParseRequestURI(service.BaseURL + requestUri)
if err != nil {
@@ -474,6 +481,8 @@ type testContext struct {
lazyTarget vespa.Target
testsPath string
dryRun bool
+ // Cache of services by their cluster name
+ clusters map[string]*vespa.Service
}
func (t *testContext) target() (vespa.Target, error) {
diff --git a/client/go/internal/cli/cmd/test_test.go b/client/go/internal/cli/cmd/test_test.go
index 5d6bb441b2a..4f8e6d49a2a 100644
--- a/client/go/internal/cli/cmd/test_test.go
+++ b/client/go/internal/cli/cmd/test_test.go
@@ -23,20 +23,26 @@ import (
func TestSuite(t *testing.T) {
client := &mock.HTTPClient{}
searchResponse, _ := os.ReadFile("testdata/tests/response.json")
+ mockServiceStatus(client, "container")
client.NextStatus(200)
client.NextStatus(200)
- for i := 0; i < 11; i++ {
+ for i := 0; i < 2; i++ {
+ client.NextResponseString(200, string(searchResponse))
+ }
+ mockServiceStatus(client, "container") // Some tests do not specify cluster, which is fine since we only have one, but this causes a cache miss
+ for i := 0; i < 9; i++ {
client.NextResponseString(200, string(searchResponse))
}
-
expectedBytes, _ := os.ReadFile("testdata/tests/expected-suite.out")
cli, stdout, stderr := newTestCLI(t)
cli.httpClient = client
assert.NotNil(t, cli.Run("test", "testdata/tests/system-test"))
-
+ assert.Equal(t, "", stderr.String())
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)}
+ discoveryRequest := createSearchRequest("http://127.0.0.1:19071/application/v2/tenant/default/application/default/environment/prod/region/default/instance/default/serviceconverge")
+ requests := []*http.Request{discoveryRequest, createFeedRequest(baseUrl), createFeedRequest(baseUrl), createSearchRequest(urlWithQuery), createSearchRequest(urlWithQuery)}
+ requests = append(requests, discoveryRequest)
requests = append(requests, createSearchRequest(baseUrl+"/search/"))
requests = append(requests, createSearchRequest(baseUrl+"/search/?foo=%2F"))
for i := 0; i < 7; i++ {
@@ -95,6 +101,7 @@ func TestSuiteWithoutTests(t *testing.T) {
func TestSingleTest(t *testing.T) {
client := &mock.HTTPClient{}
searchResponse, _ := os.ReadFile("testdata/tests/response.json")
+ mockServiceStatus(client, "container")
client.NextStatus(200)
client.NextStatus(200)
client.NextResponseString(200, string(searchResponse))
@@ -109,7 +116,8 @@ func TestSingleTest(t *testing.T) {
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)
+ discoveryRequest := createSearchRequest("http://127.0.0.1:19071/application/v2/tenant/default/application/default/environment/prod/region/default/instance/default/serviceconverge")
+ assertRequests([]*http.Request{discoveryRequest, createFeedRequest(baseUrl), createFeedRequest(baseUrl), createSearchRequest(rawUrl), createSearchRequest(rawUrl)}, client, t)
}
func TestSingleTestWithCloudAndEndpoints(t *testing.T) {
@@ -172,12 +180,17 @@ func createRequest(method string, uri string, body string) *http.Request {
}
func assertRequests(requests []*http.Request, client *mock.HTTPClient, t *testing.T) {
+ t.Helper()
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))
+ actualBody := a.Body
+ if actualBody == nil {
+ actualBody = io.NopCloser(strings.NewReader(""))
+ }
+ assert.Equal(t, util.ReaderToJSON(e.Body), util.ReaderToJSON(actualBody))
}
}
}
diff --git a/client/go/internal/cli/cmd/testutil_test.go b/client/go/internal/cli/cmd/testutil_test.go
index 61d6c15c5a0..c16c9f8dc50 100644
--- a/client/go/internal/cli/cmd/testutil_test.go
+++ b/client/go/internal/cli/cmd/testutil_test.go
@@ -3,8 +3,10 @@ package cmd
import (
"bytes"
+ "fmt"
"net/http"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -41,6 +43,36 @@ func newTestCLI(t *testing.T, envVars ...string) (*CLI, *bytes.Buffer, *bytes.Bu
return cli, &stdout, &stderr
}
+func mockServiceStatus(client *mock.HTTPClient, clusterNames ...string) {
+ var serviceObjects []string
+ for _, name := range clusterNames {
+ service := fmt.Sprintf(`{
+ "clusterName": "%s",
+ "host": "localhost",
+ "port": 8080,
+ "type": "container",
+ "url": "http://localhost:19071/application/v2/tenant/default/application/default/environment/prod/region/default/instance/default/serviceconverge/localhost:8080",
+ "currentGeneration": 1
+ }
+`, name)
+ serviceObjects = append(serviceObjects, service)
+ }
+ services := "[]"
+ if len(serviceObjects) > 0 {
+ services = "[" + strings.Join(serviceObjects, ",") + "]"
+ }
+ response := fmt.Sprintf(`
+{
+ "services": %s,
+ "currentGeneration": 1
+}`, services)
+ client.NextResponse(mock.HTTPResponse{
+ URI: "/application/v2/tenant/default/application/default/environment/prod/region/default/instance/default/serviceconverge",
+ Status: 200,
+ Body: []byte(response),
+ })
+}
+
type mockAuthenticator struct{}
func (a *mockAuthenticator) Authenticate(request *http.Request) error { return nil }
diff --git a/client/go/internal/cli/cmd/visit_test.go b/client/go/internal/cli/cmd/visit_test.go
index f85fb739370..bd806f1d9c9 100644
--- a/client/go/internal/cli/cmd/visit_test.go
+++ b/client/go/internal/cli/cmd/visit_test.go
@@ -93,10 +93,14 @@ func TestRunOneVisit(t *testing.T) {
func withMockClient(t *testing.T, prepCli func(*mock.HTTPClient), runOp func(*vespa.Service)) *http.Request {
client := &mock.HTTPClient{}
+ mockServiceStatus(client, "container")
prepCli(client)
cli, _, _ := newTestCLI(t)
cli.httpClient = client
- service, _ := documentService(cli, 0)
+ service, err := documentService(cli, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
runOp(service)
return client.LastRequest
}
@@ -126,6 +130,7 @@ func TestVisitCommand(t *testing.T) {
}
func assertVisitResults(arguments []string, t *testing.T, responses []string, queryPart, output string) {
+ t.Helper()
client := &mock.HTTPClient{}
client.NextResponseString(200, handlersResponse)
client.NextResponseString(400, clusterStarResponse)
@@ -134,6 +139,7 @@ func assertVisitResults(arguments []string, t *testing.T, responses []string, qu
}
cli, stdout, stderr := newTestCLI(t)
cli.httpClient = client
+ arguments = append(arguments, "-t", "http://127.0.0.1:8080")
assert.Nil(t, cli.Run(arguments...))
assert.Equal(t, output, stdout.String())
assert.Equal(t, "", stderr.String())
diff --git a/client/go/internal/cli/cmd/waiter.go b/client/go/internal/cli/cmd/waiter.go
new file mode 100644
index 00000000000..a1919de798d
--- /dev/null
+++ b/client/go/internal/cli/cmd/waiter.go
@@ -0,0 +1,97 @@
+package cmd
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/fatih/color"
+ "github.com/vespa-engine/vespa/client/go/internal/vespa"
+)
+
+// Waiter waits for Vespa services to become ready, within a timeout.
+type Waiter struct {
+ // Once species whether we should wait at least one time, irregardless of timeout.
+ Once bool
+
+ // Timeout specifies how long we should wait for an operation to complete.
+ Timeout time.Duration // TODO(mpolden): Consider making this a budget
+
+ cli *CLI
+}
+
+func (w *Waiter) wait() bool { return w.Once || w.Timeout > 0 }
+
+// DeployService returns the service providing the deploy API on given target,
+func (w *Waiter) DeployService(target vespa.Target) (*vespa.Service, error) {
+ s, err := target.DeployService()
+ if err != nil {
+ return nil, err
+ }
+ if w.Timeout > 0 {
+ w.cli.printInfo("Waiting up to ", color.CyanString(w.Timeout.String()), " for ", s.Description(), " to become ready...")
+ }
+ if w.wait() {
+ if err := s.Wait(w.Timeout); err != nil {
+ return nil, err
+ }
+ }
+ return s, nil
+}
+
+// Service returns the service identified by cluster ID, available on target.
+func (w *Waiter) Service(target vespa.Target, cluster string) (*vespa.Service, error) {
+ targetType, err := w.cli.targetType()
+ if err != nil {
+ return nil, err
+ }
+ if targetType.url != "" && cluster != "" {
+ return nil, fmt.Errorf("cluster cannot be specified when target is an URL")
+ }
+ services, err := w.Services(target)
+ if err != nil {
+ return nil, err
+ }
+ service, err := vespa.FindService(cluster, services)
+ if err != nil {
+ return nil, errHint(err, "The --cluster option specifies the service to use")
+ }
+ if w.Timeout > 0 {
+ w.cli.printInfo("Waiting up to ", color.CyanString(w.Timeout.String()), " for ", color.CyanString(service.Description()), " to become available...")
+ }
+ if w.wait() {
+ if err := service.Wait(w.Timeout); err != nil {
+ return nil, err
+ }
+ }
+ return service, nil
+}
+
+// Services returns all container services available on target.
+func (w *Waiter) Services(target vespa.Target) ([]*vespa.Service, error) {
+ if w.Timeout > 0 {
+ w.cli.printInfo("Waiting up to ", color.CyanString(w.Timeout.String()), " for cluster discovery...")
+ }
+ services, err := target.ContainerServices(w.Timeout)
+ if err != nil {
+ return nil, err
+ }
+ for _, s := range services {
+ if w.Timeout > 0 {
+ w.cli.printInfo("Waiting up to ", color.CyanString(w.Timeout.String()), " for ", s.Description(), "...")
+ }
+ if w.wait() {
+ if err := s.Wait(w.Timeout); err != nil {
+ return nil, err
+ }
+ }
+ }
+ return services, nil
+}
+
+// Deployment waits for a deployment to become ready, returning the ID of the converged deployment.
+func (w *Waiter) Deployment(target vespa.Target, id int64) (int64, error) {
+ if w.Timeout > 0 {
+ w.cli.printInfo("Waiting up to ", color.CyanString(w.Timeout.String()), " for deployment to converge...")
+ }
+ return target.AwaitDeployment(id, w.Timeout)
+}