aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJon Bratseth <bratseth@gmail.com>2023-07-12 12:12:34 +0200
committerGitHub <noreply@github.com>2023-07-12 12:12:34 +0200
commit5b0ff16a85a4a4d7aaa424f5072a269c4c7ce356 (patch)
treecb26d83c91dafb0196f69ee4fffdf08b50c821bc
parent5df78771f0ff297c7d83eaaaa71df067896d5520 (diff)
parent31cbaedf6893a015be1d7af6ff5f71a525446ca4 (diff)
Merge pull request #27743 from vespa-engine/mpolden/vespa-destroy
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
-rw-r--r--client/go/internal/vespa/detect_hostname_test.go7
-rw-r--r--client/go/internal/vespa/find_user_test.go4
-rw-r--r--client/go/internal/vespa/load_env.go15
-rw-r--r--client/go/internal/vespa/load_env_test.go14
-rw-r--r--client/go/internal/vespa/tls_options.go2
13 files changed, 214 insertions, 38 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
diff --git a/client/go/internal/vespa/detect_hostname_test.go b/client/go/internal/vespa/detect_hostname_test.go
index 324701efb6f..26b3095dbf6 100644
--- a/client/go/internal/vespa/detect_hostname_test.go
+++ b/client/go/internal/vespa/detect_hostname_test.go
@@ -2,7 +2,6 @@
package vespa
import (
- "fmt"
"os"
"strings"
"testing"
@@ -19,15 +18,15 @@ func TestDetectHostname(t *testing.T) {
assert.Equal(t, "foo.bar", got)
os.Unsetenv("VESPA_HOSTNAME")
got, err = findOurHostnameFrom("bar.foo.123")
- fmt.Fprintln(os.Stderr, "findOurHostname from bar.foo.123 returns:", got, "with error:", err)
+ t.Log("findOurHostname from bar.foo.123 returns:", got, "with error:", err)
assert.NotEqual(t, "", got)
parts := strings.Split(got, ".")
if len(parts) > 1 {
expanded, err2 := findOurHostnameFrom(parts[0])
- fmt.Fprintln(os.Stderr, "findOurHostname from", parts[0], "returns:", expanded, "with error:", err2)
+ t.Log("findOurHostname from", parts[0], "returns:", expanded, "with error:", err2)
assert.Equal(t, got, expanded)
}
got, err = findOurHostnameFrom("")
assert.NotEqual(t, "", got)
- fmt.Fprintln(os.Stderr, "findOurHostname('') returns:", got, "with error:", err)
+ t.Log("findOurHostname('') returns:", got, "with error:", err)
}
diff --git a/client/go/internal/vespa/find_user_test.go b/client/go/internal/vespa/find_user_test.go
index 3d533b08c56..484a9a9cc2c 100644
--- a/client/go/internal/vespa/find_user_test.go
+++ b/client/go/internal/vespa/find_user_test.go
@@ -2,8 +2,6 @@
package vespa
import (
- "fmt"
- "os"
"testing"
"github.com/stretchr/testify/assert"
@@ -23,5 +21,5 @@ func TestFindVespaUser(t *testing.T) {
func TestFindVespaUidAndGid(t *testing.T) {
uid, gid := FindVespaUidAndGid()
- fmt.Fprintln(os.Stderr, "INFO: result from FindVespaUidAndGid() is", uid, "and", gid)
+ t.Log("INFO: result from FindVespaUidAndGid() is", uid, "and", gid)
}
diff --git a/client/go/internal/vespa/load_env.go b/client/go/internal/vespa/load_env.go
index 6c41c1fece5..87d60738366 100644
--- a/client/go/internal/vespa/load_env.go
+++ b/client/go/internal/vespa/load_env.go
@@ -8,6 +8,7 @@ import (
"bufio"
"errors"
"fmt"
+ "io"
"os"
"strings"
@@ -29,13 +30,15 @@ func LoadDefaultEnv() error {
}
// parse default-env.txt, then dump export statements for "sh" to stdout
-func ExportDefaultEnvToSh() error {
+func ExportDefaultEnvToSh() error { return ExportDefaultEnvTo(os.Stdout) }
+
+func ExportDefaultEnvTo(w io.Writer) error {
holder := newShellEnvExporter()
err := loadDefaultEnvTo(holder)
holder.fallbackVar(envvars.VESPA_HOME, FindHome())
holder.fallbackVar(envvars.VESPA_USER, FindVespaUser())
ensureGoodPath(holder)
- holder.dump()
+ holder.dump(w)
return err
}
@@ -250,13 +253,13 @@ func shellQuote(s string) string {
return string(res)
}
-func (p *shellEnvExporter) dump() {
+func (p *shellEnvExporter) dump(w io.Writer) {
for vn, vv := range p.exportVars {
- fmt.Printf("%s=%s\n", vn, vv)
- fmt.Printf("export %s\n", vn)
+ fmt.Fprintf(w, "%s=%s\n", vn, vv)
+ fmt.Fprintf(w, "export %s\n", vn)
}
for vn, _ := range p.unsetVars {
- fmt.Printf("unset %s\n", vn)
+ fmt.Fprintf(w, "unset %s\n", vn)
}
}
diff --git a/client/go/internal/vespa/load_env_test.go b/client/go/internal/vespa/load_env_test.go
index 8c3ef3ab3ab..b5903c50397 100644
--- a/client/go/internal/vespa/load_env_test.go
+++ b/client/go/internal/vespa/load_env_test.go
@@ -2,7 +2,7 @@
package vespa
import (
- "fmt"
+ "io"
"os"
"strings"
"testing"
@@ -112,9 +112,9 @@ override VESPA_V2 v2
func TestFindUser(t *testing.T) {
u := FindVespaUser()
if u == "" {
- fmt.Fprintln(os.Stderr, "WARNING: empty result from FindVespaUser()")
+ t.Log("WARNING: empty result from FindVespaUser()")
} else {
- fmt.Fprintln(os.Stderr, "INFO: result from FindVespaUser() is", u)
+ t.Log("INFO: result from FindVespaUser() is", u)
assert.Equal(t, u, os.Getenv("VESPA_USER"))
}
setup(t, `
@@ -166,7 +166,11 @@ unset XYZ
assert.Equal(t, 4, len(holder.exportVars))
assert.Equal(t, 2, len(holder.unsetVars))
// run it
- err = ExportDefaultEnvToSh()
+ w := io.Discard
+ if testing.Verbose() {
+ w = os.Stdout
+ }
+ err = ExportDefaultEnvTo(w)
assert.Nil(t, err)
}
@@ -177,7 +181,7 @@ func TestLoadEnvNop(t *testing.T) {
assert.Nil(t, err)
// check results
path := os.Getenv("PATH")
- fmt.Println("got path:", path)
+ t.Log("got path:", path)
assert.True(t, strings.Contains(path, td+"/vespa/bin:"))
assert.True(t, strings.Contains(path, ":"+td))
}
diff --git a/client/go/internal/vespa/tls_options.go b/client/go/internal/vespa/tls_options.go
index 6d5657f49e7..2578116ef68 100644
--- a/client/go/internal/vespa/tls_options.go
+++ b/client/go/internal/vespa/tls_options.go
@@ -62,5 +62,5 @@ func ExportSecurityEnvToSh() {
helper.overrideVar(envvars.VESPA_TLS_ENABLED, "1")
}
}
- helper.dump()
+ helper.dump(os.Stdout)
}