aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMartin Polden <mpolden@mpolden.no>2023-07-12 11:44:39 +0200
committerMartin Polden <mpolden@mpolden.no>2023-07-12 11:46:04 +0200
commit31cbaedf6893a015be1d7af6ff5f71a525446ca4 (patch)
treecb26d83c91dafb0196f69ee4fffdf08b50c821bc
parent9c3d6abd16a4c39c42b6bc9e03412b5a3d0dd644 (diff)
vespa destroy
-rw-r--r--client/go/internal/cli/cmd/cert.go2
-rw-r--r--client/go/internal/cli/cmd/deploy_test.go2
-rw-r--r--client/go/internal/cli/cmd/destroy.go64
-rw-r--r--client/go/internal/cli/cmd/destroy_test.go51
-rw-r--r--client/go/internal/cli/cmd/login.go4
-rw-r--r--client/go/internal/cli/cmd/root.go20
-rw-r--r--client/go/internal/vespa/deploy.go42
-rw-r--r--client/go/internal/vespa/deploy_test.go25
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