diff options
Diffstat (limited to 'client')
-rw-r--r-- | client/go/internal/cli/cmd/cert.go | 2 | ||||
-rw-r--r-- | client/go/internal/cli/cmd/deploy_test.go | 2 | ||||
-rw-r--r-- | client/go/internal/cli/cmd/destroy.go | 64 | ||||
-rw-r--r-- | client/go/internal/cli/cmd/destroy_test.go | 51 | ||||
-rw-r--r-- | client/go/internal/cli/cmd/login.go | 4 | ||||
-rw-r--r-- | client/go/internal/cli/cmd/root.go | 20 | ||||
-rw-r--r-- | client/go/internal/vespa/deploy.go | 42 | ||||
-rw-r--r-- | client/go/internal/vespa/deploy_test.go | 25 |
8 files changed, 191 insertions, 19 deletions
diff --git a/client/go/internal/cli/cmd/cert.go b/client/go/internal/cli/cmd/cert.go index 1fa5339e42e..ccfce5eb7bb 100644 --- a/client/go/internal/cli/cmd/cert.go +++ b/client/go/internal/cli/cmd/cert.go @@ -168,7 +168,7 @@ func maybeCopyCertificate(force, ignoreZip bool, cli *CLI, target vespa.Target, } if cli.isTerminal() { cli.printWarning("Application package does not contain " + color.CyanString("security/clients.pem") + ", which is required for deployments to Vespa Cloud") - ok, err := cli.confirm("Do you want to copy the certificate of application " + color.GreenString(target.Deployment().Application.String()) + " into this application package?") + ok, err := cli.confirm("Do you want to copy the certificate of application "+color.GreenString(target.Deployment().Application.String())+" into this application package?", true) if err != nil { return err } diff --git a/client/go/internal/cli/cmd/deploy_test.go b/client/go/internal/cli/cmd/deploy_test.go index 78834b7185b..9e82723e816 100644 --- a/client/go/internal/cli/cmd/deploy_test.go +++ b/client/go/internal/cli/cmd/deploy_test.go @@ -55,7 +55,7 @@ Hint: Pass --add-cert to use the certificate of the current application cli.Stdin = &buf require.NotNil(t, cli.Run("deploy", "--add-cert=false", pkgDir2)) warning := "Warning: Application package does not contain security/clients.pem, which is required for deployments to Vespa Cloud\n" - assert.Equal(t, warning+strings.Repeat("Error: please answer 'Y' or 'n'\n", 3)+certError, stderr.String()) + assert.Equal(t, warning+strings.Repeat("Error: please answer 'y' or 'n'\n", 3)+certError, stderr.String()) buf.WriteString("y\n") require.Nil(t, cli.Run("deploy", "--add-cert=false", pkgDir2)) assert.Contains(t, stdout.String(), "Success: Triggered deployment") diff --git a/client/go/internal/cli/cmd/destroy.go b/client/go/internal/cli/cmd/destroy.go new file mode 100644 index 00000000000..316eb6022ca --- /dev/null +++ b/client/go/internal/cli/cmd/destroy.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/vespa-engine/vespa/client/go/internal/vespa" +) + +func newDestroyCmd(cli *CLI) *cobra.Command { + force := false + cmd := &cobra.Command{ + Use: "destroy", + Short: "Remove a deployed application and its data", + Long: `Remove a deployed application and its data. + +This command removes the currently deployed application and permanently +deletes its data. + +When run interactively, the command will prompt for confirmation before +removing the application. When run non-interactively, the command will refuse +to remove the application unless the --force option is given. + +This command cannot be used to remove production deployments in Vespa Cloud. See +https://cloud.vespa.ai/en/deleting-applications for how to remove production +deployments. +`, + Example: `$ vespa destroy +$ vespa destroy -a mytenant.myapp.myinstance +$ vespa destroy --force`, + DisableAutoGenTag: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + target, err := cli.target(targetOptions{}) + if err != nil { + return err + } + description := "current deployment" + if target.IsCloud() { + description = target.Deployment().String() + env := target.Deployment().Zone.Environment + if env != "dev" && env != "perf" { + return errHint(fmt.Errorf("cannot remove production %s", description), "See https://cloud.vespa.ai/en/deleting-applications") + } + } + ok := force + if !ok { + cli.printWarning(fmt.Sprintf("This operation will irrecoverably remove %s and all of its data", color.RedString(description))) + ok, _ = cli.confirm("Proceed with removal?", false) + } + if ok { + err := vespa.Deactivate(vespa.DeploymentOptions{Target: target}) + if err == nil { + cli.printSuccess(fmt.Sprintf("Removed %s", description)) + } + return err + } + return fmt.Errorf("refusing to remove %s without confirmation", description) + }, + } + cmd.PersistentFlags().BoolVar(&force, "force", false, "Disable confirmation (default false)") + return cmd +} diff --git a/client/go/internal/cli/cmd/destroy_test.go b/client/go/internal/cli/cmd/destroy_test.go new file mode 100644 index 00000000000..c6198b9b877 --- /dev/null +++ b/client/go/internal/cli/cmd/destroy_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vespa-engine/vespa/client/go/internal/mock" +) + +func TestDestroy(t *testing.T) { + cli, stdout, stderr := newTestCLI(t, "NO_COLOR=true", "CI=true") + httpClient := &mock.HTTPClient{} + httpClient.NextResponseString(200, "ok") + httpClient.NextResponseString(200, "ok") + cli.httpClient = httpClient + cli.isTerminal = func() bool { return true } + var buf bytes.Buffer + cli.Stdin = &buf + + // No removal without confirmation + buf.WriteString("\n") + require.NotNil(t, cli.Run("destroy")) + warning := "Warning: This operation will irrecoverably remove current deployment and all of its data" + confirmation := "Proceed with removal? [y/N] " + assert.Equal(t, warning+"\nError: refusing to remove current deployment without confirmation\n", stderr.String()) + assert.Equal(t, confirmation, stdout.String()) + + // Removes deployment with confirmation + stdout.Reset() + stderr.Reset() + buf.WriteString("y\n") + require.Nil(t, cli.Run("destroy")) + success := "Success: Removed current deployment\n" + assert.Equal(t, confirmation+success, stdout.String()) + + // Force flag always removes deployment + stdout.Reset() + stderr.Reset() + require.Nil(t, cli.Run("destroy", "--force")) + assert.Equal(t, success, stdout.String()) + + // Cannot remove prod deployment in Vespa Cloud + stderr.Reset() + require.Nil(t, cli.Run("config", "set", "target", "cloud")) + require.Nil(t, cli.Run("config", "set", "application", "foo.bar.baz")) + require.Nil(t, cli.Run("auth", "api-key")) + require.NotNil(t, cli.Run("destroy", "-z", "prod.aws-us-east-1c")) + assert.Equal(t, "Error: cannot remove production deployment of foo.bar.baz in prod.aws-us-east-1c\nHint: See https://cloud.vespa.ai/en/deleting-applications\n", stderr.String()) +} diff --git a/client/go/internal/cli/cmd/login.go b/client/go/internal/cli/cmd/login.go index 54c0dfef770..baf35ce7954 100644 --- a/client/go/internal/cli/cmd/login.go +++ b/client/go/internal/cli/cmd/login.go @@ -45,12 +45,12 @@ func newLoginCmd(cli *CLI) *cobra.Command { log.Printf("Your Device Confirmation code is: %s\n", state.UserCode) - auto_open, err := cli.confirm("Automatically open confirmation page in your default browser?") + autoOpen, err := cli.confirm("Automatically open confirmation page in your default browser?", true) if err != nil { return err } - if auto_open { + if autoOpen { log.Printf("Opened link in your browser: %s\n", state.VerificationURI) err = browser.OpenURL(state.VerificationURI) if err != nil { diff --git a/client/go/internal/cli/cmd/root.go b/client/go/internal/cli/cmd/root.go index 5aa345cd8e4..c6742d74f3e 100644 --- a/client/go/internal/cli/cmd/root.go +++ b/client/go/internal/cli/cmd/root.go @@ -255,6 +255,7 @@ func (c *CLI) configureCommands() { rootCmd.AddCommand(configCmd) // config rootCmd.AddCommand(newCurlCmd(c)) // curl rootCmd.AddCommand(newDeployCmd(c)) // deploy + rootCmd.AddCommand(newDestroyCmd(c)) // destroy rootCmd.AddCommand(newPrepareCmd(c)) // prepare rootCmd.AddCommand(newActivateCmd(c)) // activate documentCmd.AddCommand(newDocumentPutCmd(c)) // document put @@ -301,22 +302,29 @@ func (c *CLI) printWarning(msg interface{}, hints ...string) { } } -func (c *CLI) confirm(question string) (bool, error) { +func (c *CLI) confirm(question string, confirmByDefault bool) (bool, error) { if !c.isTerminal() { return false, fmt.Errorf("terminal is not interactive") } for { var answer string - fmt.Fprintf(c.Stdout, "%s [Y/n] ", question) + choice := "[Y/n]" + if !confirmByDefault { + choice = "[y/N]" + } + fmt.Fprintf(c.Stdout, "%s %s ", question, choice) fmt.Fscanln(c.Stdin, &answer) - answer = strings.TrimSpace(strings.ToLower(answer)) + answer = strings.TrimSpace(answer) + if answer == "" { + return confirmByDefault, nil + } switch answer { - case "y", "": + case "y", "Y": return true, nil - case "n": + case "n", "N": return false, nil default: - c.printErr(fmt.Errorf("please answer 'Y' or 'n'")) + c.printErr(fmt.Errorf("please answer 'y' or 'n'")) } } } diff --git a/client/go/internal/vespa/deploy.go b/client/go/internal/vespa/deploy.go index 8b2cb6ea05d..d04b8ba631c 100644 --- a/client/go/internal/vespa/deploy.go +++ b/client/go/internal/vespa/deploy.go @@ -132,13 +132,12 @@ func Prepare(deployment DeploymentOptions) (PrepareResult, error) { if err != nil { return PrepareResult{}, err } - serviceDescription := "Deploy service" response, err := deployServiceDo(req, time.Second*30, deployment) if err != nil { return PrepareResult{}, err } defer response.Body.Close() - if err := checkResponse(req, response, serviceDescription); err != nil { + if err := checkResponse(req, response); err != nil { return PrepareResult{}, err } var jsonResponse struct { @@ -173,13 +172,39 @@ func Activate(sessionID int64, deployment DeploymentOptions) error { if err != nil { return err } - serviceDescription := "Deploy service" response, err := deployServiceDo(req, time.Second*30, deployment) if err != nil { return err } defer response.Body.Close() - return checkResponse(req, response, serviceDescription) + return checkResponse(req, response) +} + +// Deactivate given deployment +func Deactivate(opts DeploymentOptions) error { + path := "/application/v2/tenant/default/application/default" + if opts.Target.IsCloud() { + if opts.Target.Deployment().Zone.Environment == "" || opts.Target.Deployment().Zone.Region == "" { + return fmt.Errorf("%s: missing zone", opts) + } + path = fmt.Sprintf("/application/v4/tenant/%s/application/%s/instance/%s/environment/%s/region/%s", + opts.Target.Deployment().Application.Tenant, + opts.Target.Deployment().Application.Application, + opts.Target.Deployment().Application.Instance, + opts.Target.Deployment().Zone.Environment, + opts.Target.Deployment().Zone.Region) + } + u, err := opts.url(path) + if err != nil { + return err + } + req := &http.Request{URL: u, Method: "DELETE"} + resp, err := deployServiceDo(req, 30*time.Second, opts) + if err != nil { + return err + } + defer resp.Body.Close() + return checkResponse(req, resp) } func Deploy(opts DeploymentOptions) (PrepareResult, error) { @@ -265,13 +290,12 @@ func Submit(opts DeploymentOptions) error { Header: make(http.Header), } request.Header.Set("Content-Type", writer.FormDataContentType()) - serviceDescription := "Deploy service" response, err := deployServiceDo(request, time.Minute*10, opts) if err != nil { return err } defer response.Body.Close() - return checkResponse(request, response, serviceDescription) + return checkResponse(request, response) } func deployServiceDo(request *http.Request, timeout time.Duration, opts DeploymentOptions) (*http.Response, error) { @@ -354,7 +378,7 @@ func uploadApplicationPackage(url *url.URL, opts DeploymentOptions) (PrepareResu Log []LogLinePrepareResponse `json:"log"` } jsonResponse.SessionID = "0" // Set a default session ID for responses that don't contain int (e.g. cloud deployment) - if err := checkResponse(request, response, service.Description()); err != nil { + if err := checkResponse(request, response); err != nil { return PrepareResult{}, err } jsonDec := json.NewDecoder(response.Body) @@ -372,11 +396,11 @@ func uploadApplicationPackage(url *url.URL, opts DeploymentOptions) (PrepareResu }, err } -func checkResponse(req *http.Request, response *http.Response, serviceDescription string) error { +func checkResponse(req *http.Request, response *http.Response) error { if response.StatusCode/100 == 4 { return fmt.Errorf("invalid application package (%s)\n%s", response.Status, extractError(response.Body)) } else if response.StatusCode != 200 { - return fmt.Errorf("error from %s at %s (%s):\n%s", strings.ToLower(serviceDescription), req.URL.Host, response.Status, util.ReaderToJSON(response.Body)) + return fmt.Errorf("error from deploy api at %s (%s):\n%s", req.URL.Host, response.Status, util.ReaderToJSON(response.Body)) } return nil } diff --git a/client/go/internal/vespa/deploy_test.go b/client/go/internal/vespa/deploy_test.go index da2604282c0..ddb500d26e3 100644 --- a/client/go/internal/vespa/deploy_test.go +++ b/client/go/internal/vespa/deploy_test.go @@ -122,6 +122,31 @@ func TestFindApplicationPackage(t *testing.T) { }) } +func TestDeactivate(t *testing.T) { + httpClient := mock.HTTPClient{} + target := LocalTarget(&httpClient, TLSOptions{}) + opts := DeploymentOptions{Target: target} + require.Nil(t, Deactivate(opts)) + assert.Equal(t, 1, len(httpClient.Requests)) + req := httpClient.LastRequest + assert.Equal(t, "DELETE", req.Method) + assert.Equal(t, "http://127.0.0.1:19071/application/v2/tenant/default/application/default", req.URL.String()) +} + +func TestDeactivateCloud(t *testing.T) { + httpClient := mock.HTTPClient{} + target := createCloudTarget(t, "http://vespacloud", io.Discard) + cloudTarget, ok := target.(*cloudTarget) + require.True(t, ok) + cloudTarget.httpClient = &httpClient + opts := DeploymentOptions{Target: target} + require.Nil(t, Deactivate(opts)) + assert.Equal(t, 1, len(httpClient.Requests)) + req := httpClient.LastRequest + assert.Equal(t, "DELETE", req.Method) + assert.Equal(t, "http://vespacloud/application/v4/tenant/t1/application/a1/instance/i1/environment/dev/region/us-north-1", req.URL.String()) +} + type pkgFixture struct { expectedPath string expectedTestPath string |