Repository: elastic/ecctl
Pull request #173 (2020-02-10 23:16:56)

cmd: init ask for type of infrastructure and default to ESS config setup
<!--- Provide a general summary of your changes in the title above. -->

## Description
The init command will now ask for the type of infrastructure to determine whether to ask for a custom host address or to use the default ESS public api address (https://api.elastic-cloud.com) .

The output is as follows:

When choosing ESS:
```console
Select which type of infrastructure this configuration will for:
  [1] Elastic Cloud (Elasticsearch Service).
  [2] Elastic Cloud Enterprise (ECE).

Please enter your choice: 1

What default output format...
```

When choosing ECE:
```console
Select which type of infrastructure this configuration will for:
  [1] Elastic Cloud (Elasticsearch Service).
  [2] Elastic Cloud Enterprise (ECE).

Please enter your choice: 2

Enter the URL of your ECE installation:
```

Default to https://api.elastic-cloud.com:
```console
Select which type of infrastructure this configuration will for:
  [1] Elastic Cloud (Elasticsearch Service).
  [2] Elastic Cloud Enterprise (ECE).

Please enter your choice: 5

invalid choice, defaulting to "https://api.elastic-cloud.com"
```

## Related Issues
Closes: https://github.com/elastic/ecctl/issues/159

## Motivation and Context
Improved UX now that ESS public api will be available

## How Has This Been Tested?
Manually and unit tests

## Types of Changes
<!--- What types of changes does your code introduce? Put an `x` in -->
<!--- all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Refactoring (improves code quality but has no user-facing effect)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation

## Readiness Checklist
<!--- Go over all the following points, and put an `x` in all the boxes -->
<!--- that apply.  If you're unsure about any of these, don't hesitate -->
<!--- to ask. We're here to help! -->
- [x] My code follows the code style of this project
- [ ] My change requires a change to the documentation
- [ ] I have updated the documentation accordingly
- [x] I have added tests to cover my changes
- [x] All new and existing tests passed


marclop: I'm also wondering if we should prompt for a region when ESS is select and wether or not to provide a sane default.
karencfv: @marclop sounds good! we'll add that with https://github.com/elastic/ecctl/issues/162
zanbel: >I'm also wondering if we should prompt for a region when ESS is select and whether or not to provide a sane default too.

Based on previous research down as part of randomizing provide and region, I don't think there can be a sane default here. Users are very selective about their region of choice, even if they don't have a strong preference about which cloud provider to use.

>Select which type of Elastic Cloud offering will you be working with:"
[1] Elasticsearch Service (default)
[2] Elastic Cloud Enterprise (ECE) or Elasticsearch Service Private (ESSP)

Just to mention that, per the other [comment](https://github.com/elastic/ecctl/issues/159#issuecomment-583840820), you might want to actually offer 3 options and the reason is that, AFAIK, the way the URL is constructed in ESSP is similar to ESS, not ECE, and related to the need to include specific region. If that's not the case two options can be fine.

Kushmaro: > Just to mention that, per the other [comment](https://github.com/elastic/ecctl/issues/159#issuecomment-583840820), you might want to actually offer 3 options and the reason is that, AFAIK, the way the URL is constructed in ESSP is similar to ESS, not ECE, and related to the need to include specific region. If that's not the case two options can be fine.

sure, that's up for @marclop @karencfv to comment implementation wise, my reasoning for 2 options was that for the foreseeable timeframe, ECE / ESSP should behave pretty much the same so didn't see a real reason to duplicate the flow

If you feel 3 choice is a bit more future proof in terms of ESSP - sure lets do that

Patch:
diff --git a/build/Makefile.deps b/build/Makefile.deps
index 27ef90c..25989dc 100644
--- a/build/Makefile.deps
+++ b/build/Makefile.deps
@@ -1,12 +1,61 @@
 ## Installs all the required binaries for the makefile targets.
-.PHONY: deps
-deps:
-ifndef SKIP_DEPCHECK
-	@ echo "-> Installing project dependencies..."
-	@ GO111MODULE=off go get -u github.com/myitcv/gobin
-	@ $(GOBIN)/gobin github.com/elastic/go-licenser@v0.3.0
-	@ $(GOBIN)/gobin github.com/go-bindata/go-bindata/go-bindata@v0.0.0-20190711162640-ee3c2418e368
-	@ $(GOBIN)/gobin golang.org/x/lint/golint
-	@ curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $(GOBIN) v1.21.0
-	@ echo "-> Done."
-endif
+
+OS:=$(shell uname -s | tr '[:upper:]' '[:lower:]')
+ARCH:=$(shell $(PWD)/scripts/uname_arch.sh)
+VERSION_DIR:=$(GOBIN)/versions
+
+VERSION_GOBIN:=v0.0.13
+VERSION_GOLINT:=v0.0.0-20191125180803-fdd1cda4f05f
+VERSION_GOLICENSER:=v0.3.0
+VERSION_GOLANGCILINT:=v1.21.0
+VERSION_GOBINDATA:=v0.0.0-20190711162640-ee3c2418e368
+
+deps: $(GOBIN)/gobin $(GOBIN)/golint $(GOBIN)/go-licenser $(GOBIN)/golangci-lint $(GOBIN)/go-bindata
+
+$(GOBIN):
+	@ mkdir -p $(GOBIN)
+
+$(VERSION_DIR): | $(GOBIN)
+	@ mkdir -p $(GOBIN)/versions
+
+
+$(VERSION_DIR)/.version-gobin-$(VERSION_GOBIN): | $(VERSION_DIR)
+	@ rm -f $(VERSION_DIR)/.version-gobin-*
+	@ echo $(VERSION_GOBIN) > $(VERSION_DIR)/.version-gobin-$(VERSION_GOBIN)
+
+$(GOBIN)/gobin: $(VERSION_DIR)/.version-gobin-$(VERSION_GOBIN) | $(GOBIN)
+	@ echo "-> Installing gobin..."
+	@ curl -sL -o $(GOBIN)/gobin https://github.com/myitcv/gobin/releases/download/$(VERSION_GOBIN)/$(OS)-$(ARCH)
+	@ chmod +x $(GOBIN)/gobin
+
+$(VERSION_DIR)/.version-golint-$(VERSION_GOLINT): | $(VERSION_DIR)
+	@ rm -f $(VERSION_DIR)/.version-golint-*
+	@ echo $(VERSION_GOLINT) > $(VERSION_DIR)/.version-golint-$(VERSION_GOLINT)
+
+$(GOBIN)/golint: $(GOBIN)/gobin $(VERSION_DIR)/.version-golint-$(VERSION_GOLINT) | $(GOBIN)
+	@ echo "-> Installing golint..."
+	@ $(GOBIN)/gobin golang.org/x/lint/golint@$(VERSION_GOLINT)
+
+$(VERSION_DIR)/.version-go-licenser-$(VERSION_GOLICENSER): | $(VERSION_DIR)
+	@ rm -f $(VERSION_DIR)/.version-go-licenser-*
+	@ echo $(VERSION_GOLICENSER) > $(VERSION_DIR)/.version-go-licenser-$(VERSION_GOLICENSER)
+
+$(GOBIN)/go-licenser: $(GOBIN)/gobin $(VERSION_DIR)/.version-go-licenser-$(VERSION_GOLICENSER) | $(GOBIN)
+	@ echo "-> Installing go-licenser..."
+	@ $(GOBIN)/gobin github.com/elastic/go-licenser@$(VERSION_GOLICENSER)
+
+$(VERSION_DIR)/.version-golangci-lint-$(VERSION_GOLANGCILINT): | $(VERSION_DIR)
+	@ rm -f $(VERSION_DIR)/.version-golangci-lint-*
+	@ echo $(VERSION_GOLANGCILINT) > $(VERSION_DIR)/.version-golangci-lint-$(VERSION_GOLANGCILINT)
+
+$(GOBIN)/golangci-lint: $(VERSION_DIR)/.version-golangci-lint-$(VERSION_GOLANGCILINT) | $(GOBIN)
+	@ echo "-> Installing golangci-lint..."
+	@ curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $(GOBIN) $(VERSION_GOLANGCILINT)
+
+$(VERSION_DIR)/.version-go-bindata-$(VERSION_GOBINDATA):
+	@ rm -f $(VERSION_DIR)/.version-go-bindata-*
+	@ echo $(VERSION_GOBINDATA) > $(VERSION_DIR)/.version-go-bindata-$(VERSION_GOBINDATA)
+
+$(GOBIN)/go-bindata: $(GOBIN)/gobin $(VERSION_DIR)/.version-go-bindata-$(VERSION_GOBINDATA) | $(GOBIN)
+	@ echo "-> Installing go-bindata..."
+	@ $(GOBIN)/gobin github.com/go-bindata/go-bindata/go-bindata@$(VERSION_GOBINDATA)
diff --git a/pkg/ecctl/init.go b/pkg/ecctl/init.go
index 0e7868d..c2e1101 100644
--- a/pkg/ecctl/init.go
+++ b/pkg/ecctl/init.go
@@ -49,14 +49,24 @@ const (
 )
 
 const (
-	disclaimer      = "Welcome to the Elastic Cloud CLI! This command will guide you through authenticating and setting some default values.\n\n"
+	_ = iota
+	essInfraChoice
+	eceInfraChoice
+	esspInfraChoice
+)
+
+const (
+	disclaimer      = "Welcome to Elastic Cloud Control (ecctl)! This command will guide you through authenticating and setting some default values.\n\n"
 	redacted        = "[REDACTED]"
 	settingsPathMsg = "Found existing settings in %s. Here's a JSON representation of what they look like:\n"
 
 	missingConfigMsg  = `Missing configuration file, would you like to initialise it? [y/n]: `
 	existingConfigMsg = `Would you like to change your current settings? [y/n]: `
 
-	hostMsg = "Enter the URL of your ECE installation: "
+	essHostAddress = "https://api.elastic-cloud.com"
+	eceHostMsg     = "Enter the URL of your ECE installation: "
+	esspHostMsg    = "Enter the URL of your ESSP installation: "
+	essChoiceMsg   = "Using \"%s\" as the API endpoint.\n"
 
 	apiKeyMsg = "Paste your API Key and press enter: "
 	userMsg   = "Type in your username: "
@@ -67,6 +77,14 @@ const (
 )
 
 var (
+	hostChoiceMsg = `
+Select which type of Elastic Cloud offering will you be working with:
+  [1] Elasticsearch Service (default).
+  [2] Elastic Cloud Enterprise (ECE).
+  [3] Elasticsearch Service Private (ESSP).
+
+Please enter your choice: `
+
 	authChoiceMsg = `
 Which authentication mechanism would you like to use?
   [1] API Keys (Recommended).
@@ -186,7 +204,10 @@ func InitConfig(params InitConfigParams) error {
 	// Insecure is set to true by default to allow API calls against HTTPS
 	// endpoints with self-signed certificates.
 	cfg.Insecure = true
-	cfg.Host = scanner.Scan(hostMsg)
+
+	if err := askInfraSelection(&cfg, scanner, params.Writer, params.ErrWriter); err != nil {
+		return err
+	}
 
 	if err := askOutputFormat(&cfg, scanner, params.Writer, params.ErrWriter); err != nil {
 		return err
@@ -206,7 +227,7 @@ func InitConfig(params InitConfigParams) error {
 		return err
 	}
 
-	// It's better to write the config as is since it ommits defaults and
+	// It's better to write the config as is since it omits defaults and
 	// empties vs viper's behaviour in `WriteConfig`.
 	return writeConfig(cfg, params.FilePath, ".json")
 }
@@ -243,6 +264,29 @@ func printConfig(writer io.Writer, v *viper.Viper) error {
 	return enc.Encode(c)
 }
 
+func askInfraSelection(cfg *Config, scanner *input.Scanner, writer, errWriter io.Writer) error {
+	infraChoiceRaw := scanner.Scan(hostChoiceMsg)
+	fmt.Fprintln(writer)
+	infraChoice, err := strconv.Atoi(infraChoiceRaw)
+	if err != nil {
+		return err
+	}
+
+	cfg.Host = essHostAddress
+	switch infraChoice {
+	case essInfraChoice:
+		fmt.Fprintf(writer, essChoiceMsg, essHostAddress)
+	case eceInfraChoice:
+		cfg.Host = scanner.Scan(eceHostMsg)
+	case esspInfraChoice:
+		cfg.Host = scanner.Scan(esspHostMsg)
+	default:
+		fmt.Fprintf(errWriter, "invalid choice, defaulting to %s", essHostAddress)
+	}
+
+	return nil
+}
+
 func askOutputFormat(cfg *Config, scanner *input.Scanner, writer, errWriter io.Writer) error {
 	formatChoiceRaw := scanner.Scan(formatChoiceMsg)
 	fmt.Fprintln(writer)
diff --git a/pkg/platform/allocator/vacate.go b/pkg/platform/allocator/vacate.go
index 0f969cc..8cd1f74 100644
--- a/pkg/platform/allocator/vacate.go
+++ b/pkg/platform/allocator/vacate.go
@@ -174,6 +174,9 @@ func waitVacateCompletion(p *pool.Pool, hasWork bool) error {
 	return errs.ErrorOrNil()
 }
 
+// nolint since the linter says it's too complex, it's because of the for loop
+// combination with ifs. It's better to have this grouped rather than scattered
+// around.
 func addAllocatorMovesToPool(params addAllocatorMovesToPoolParams) ([]pool.Validator, bool) {
 	var leftovers []pool.Validator
 	var vacates = make([]pool.Validator, 0)
@@ -219,6 +222,18 @@ func addAllocatorMovesToPool(params addAllocatorMovesToPoolParams) ([]pool.Valid
 		vacates = append(vacates, newVacateClusterParams(params, *move.ClusterID, kind))
 	}
 
+	for _, move := range params.Moves.AppsearchClusters {
+		if len(filter) > 0 && !slice.HasString(filter, *move.ClusterID) {
+			continue
+		}
+
+		var kind = "appsearch"
+		if kindFilter != "" && kind != kindFilter {
+			break
+		}
+		vacates = append(vacates, newVacateClusterParams(params, *move.ClusterID, kind))
+	}
+
 	if leftover, _ := params.Pool.Add(vacates...); len(leftover) > 0 {
 		leftovers = append(leftovers, leftover...)
 	}
@@ -353,6 +368,10 @@ func newMoveClusterParams(params *VacateClusterParams) (*platform_infrastructure
 		moveParams.SetClusterType(ec.String("apm"))
 	}
 
+	if len(req.AppsearchClusters) > 0 {
+		moveParams.SetClusterType(ec.String("appsearch"))
+	}
+
 	return moveParams, nil
 }
 
@@ -384,7 +403,14 @@ func trackMovedCluster(params *VacateClusterParams) error {
 		MaxRetries:    params.MaxPollRetries,
 	})
 	if err != nil {
-		return errors.Wrap(err, vacateWrapMessage(params, "track"))
+		// Remove this after AppSearch is supported in the plan tracker
+		// https://github.com/elastic/cloud-sdk-go/issues/34
+		if params.Kind == "appsearch" {
+			wrapMessage := "instance is being moved but tracking is not supported, please check the vacate progress from the UI"
+			return errors.New(vacateWrapMessage(params, wrapMessage))
+		}
+		wrapMessage := "plan tracker returned an error but vacate is in progress"
+		return errors.Wrap(err, vacateWrapMessage(params, wrapMessage))
 	}
 
 	return plan.Stream(channel, params.Output)
@@ -392,6 +418,8 @@ func trackMovedCluster(params *VacateClusterParams) error {
 
 // CheckVacateFailures iterates over the list of failures returning
 // a multierror with any of the failures found.
+//
+// nolint because of the complexity score here
 func CheckVacateFailures(failures *models.MoveClustersDetails, filter []string) error {
 	if failures == nil {
 		return nil
@@ -451,6 +479,24 @@ func CheckVacateFailures(failures *models.MoveClustersDetails, filter []string)
 		}
 	}
 
+	for _, failure := range failures.AppsearchClusters {
+		if len(filter) > 0 && !slice.HasString(filter, *failure.ClusterID) {
+			continue
+		}
+
+		var ferr error
+		if len(failure.Errors) > 0 {
+			var err = failure.Errors[0]
+			ferr = fmt.Errorf("code: %s, message: %s", *err.Code, *err.Message)
+		}
+
+		if !strings.Contains(ferr.Error(), PlanPendingMessage) {
+			merr = multierror.Append(merr,
+				fmt.Errorf("cluster [%s][appsearch] failed vacating, reason: %s", *failure.ClusterID, ferr),
+			)
+		}
+	}
+
 	return merr.ErrorOrNil()
 }
 
@@ -513,5 +559,19 @@ func ComputeVacateRequest(pr *models.MoveClustersDetails, clusters, to []string,
 		)
 	}
 
+	for _, c := range pr.AppsearchClusters {
+		if len(clusters) > 0 && !slice.HasString(clusters, *c.ClusterID) {
+			continue
+		}
+
+		c.CalculatedPlan.PlanConfiguration.PreferredAllocators = to
+		req.AppsearchClusters = append(req.AppsearchClusters,
+			&models.MoveAppSearchConfiguration{
+				ClusterIds:   []string{*c.ClusterID},
+				PlanOverride: c.CalculatedPlan,
+			},
+		)
+	}
+
 	return &req
 }
diff --git a/pkg/platform/allocator/vacate_params.go b/pkg/platform/allocator/vacate_params.go
index 0caf034..113107d 100644
--- a/pkg/platform/allocator/vacate_params.go
+++ b/pkg/platform/allocator/vacate_params.go
@@ -40,7 +40,7 @@ var (
 	errOutputDeviceCannotBeNil      = errors.New("output device cannot be nil")
 	errCannotOverrideAllocatorDown  = errors.New("cannot set the AllocatorDown when multiple allocators are specified")
 
-	allowedClusterKinds = []string{"apm", "elasticsearch", "kibana"}
+	allowedClusterKinds = []string{"apm", "elasticsearch", "kibana", "appsearch"}
 )
 
 // VacateParams used to vacate N allocators or clusters.
diff --git a/scripts/uname_arch.sh b/scripts/uname_arch.sh
new file mode 100755
index 0000000..3f63c6a
--- /dev/null
+++ b/scripts/uname_arch.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+
+arch=$(uname -m)
+case $arch in
+  x86_64) arch="amd64" ;;
+  x86) arch="386" ;;
+  i686) arch="386" ;;
+  i386) arch="386" ;;
+  aarch64) arch="arm64" ;;
+  armv5*) arch="armv5" ;;
+  armv6*) arch="armv6" ;;
+  armv7*) arch="armv7" ;;
+esac
+echo ${arch}


Test patch:
diff --git a/pkg/ecctl/init_test.go b/pkg/ecctl/init_test.go
index 075766c..68914dd 100644
--- a/pkg/ecctl/init_test.go
+++ b/pkg/ecctl/init_test.go
@@ -241,7 +241,7 @@ func TestInitConfig(t *testing.T) {
 				FilePath: filepath.Join(testFiles, "newConfig"),
 				Reader: io.MultiReader(
 					strings.NewReader("y\n"),
-					strings.NewReader("https://ahost\n"),
+					strings.NewReader("1\n"),
 					strings.NewReader("1\n"),
 					strings.NewReader("1\n"),
 					strings.NewReader("anapikey\n"),
@@ -257,13 +257,14 @@ func TestInitConfig(t *testing.T) {
 			}},
 			wantSettings: map[string]interface{}{
 				"apikey":   "somekey",
-				"host":     "https://ahost",
+				"host":     "https://api.elastic-cloud.com",
 				"insecure": true,
 				"output":   "text",
 			},
-			wantOutput: disclaimer + missingConfigMsg + hostMsg +
-				formatChoiceMsg + "\n" + authChoiceMsg + "\n" + apiKeyMsg + "\n" +
-				"\n" + fmt.Sprintf(validCredentialsMsg, "anacleto") + finalMsg + "\n",
+			wantOutput: disclaimer + missingConfigMsg + hostChoiceMsg + "\n" +
+				fmt.Sprintf(essChoiceMsg, essHostAddress) + formatChoiceMsg +
+				"\n" + authChoiceMsg + "\n" + apiKeyMsg + "\n" + "\n" +
+				fmt.Sprintf(validCredentialsMsg, "anacleto") + finalMsg + "\n",
 		},
 		{
 			name: "doesn't find a config file and user creates a new one with user/pass",
@@ -272,6 +273,7 @@ func TestInitConfig(t *testing.T) {
 				FilePath: filepath.Join(testFiles, "newConfigUserPass"),
 				Reader: io.MultiReader(
 					strings.NewReader("y\n"),
+					strings.NewReader("2\n"),
 					strings.NewReader("https://ahost\n"),
 					strings.NewReader("1\n"),
 					strings.NewReader("2\n"),
@@ -298,7 +300,7 @@ func TestInitConfig(t *testing.T) {
 				"pass":     "apassword",
 				"user":     "auser",
 			},
-			wantOutput: disclaimer + missingConfigMsg + hostMsg +
+			wantOutput: disclaimer + missingConfigMsg + hostChoiceMsg + "\n" + eceHostMsg +
 				formatChoiceMsg + "\n" + authChoiceMsg + "\n" + userMsg + passMsg +
 				"\n" + "\n" + fmt.Sprintf(validCredentialsMsg, "auser") + finalMsg + "\n",
 		},
@@ -309,6 +311,7 @@ func TestInitConfig(t *testing.T) {
 				FilePath: filepath.Join(testFiles, "doesnt_matter"),
 				Reader: io.MultiReader(
 					strings.NewReader("y\n"),
+					strings.NewReader("3\n"),
 					strings.NewReader("https://ahost\n"),
 					strings.NewReader("1\n"),
 					strings.NewReader("1\n"),
@@ -331,9 +334,9 @@ func TestInitConfig(t *testing.T) {
 			},
 			wantOutput: disclaimer +
 				fmt.Sprintf(settingsPathMsg, "test_files/userpassmodif.yaml") +
-				userPassConfigToModifyContents + "\n" + existingConfigMsg + hostMsg +
-				formatChoiceMsg + "\n" + authChoiceMsg + "\n" + apiKeyMsg + "\n" +
-				"\n" + fmt.Sprintf(validCredentialsMsg, "anacleto") + finalMsg + "\n",
+				userPassConfigToModifyContents + "\n" + existingConfigMsg + hostChoiceMsg +
+				"\n" + esspHostMsg + formatChoiceMsg + "\n" + authChoiceMsg + "\n" + apiKeyMsg +
+				"\n" + "\n" + fmt.Sprintf(validCredentialsMsg, "anacleto") + finalMsg + "\n",
 		},
 		{
 			name: "finds a config file and user changes the values, from apikey to user/pass",
@@ -342,6 +345,7 @@ func TestInitConfig(t *testing.T) {
 				FilePath: filepath.Join(testFiles, "doesnt_matter"),
 				Reader: io.MultiReader(
 					strings.NewReader("y\n"),
+					strings.NewReader("2\n"),
 					strings.NewReader("https://ahost\n"),
 					strings.NewReader("1\n"),
 					strings.NewReader("2\n"),
@@ -370,9 +374,9 @@ func TestInitConfig(t *testing.T) {
 			},
 			wantOutput: disclaimer +
 				fmt.Sprintf(settingsPathMsg, "test_files/apikeymodif.yaml") +
-				apiKeyConfigToModifyContents + "\n" + existingConfigMsg + hostMsg +
-				formatChoiceMsg + "\n" + authChoiceMsg + "\n" + userMsg + passMsg +
-				"\n" + "\n" + fmt.Sprintf(validCredentialsMsg, "auser") + finalMsg + "\n",
+				apiKeyConfigToModifyContents + "\n" + existingConfigMsg + hostChoiceMsg +
+				"\n" + eceHostMsg + formatChoiceMsg + "\n" + authChoiceMsg + "\n" + userMsg +
+				passMsg + "\n" + "\n" + fmt.Sprintf(validCredentialsMsg, "auser") + finalMsg + "\n",
 		},
 	}
 	for _, tt := range tests {
diff --git a/pkg/platform/allocator/vacate_full_test.go b/pkg/platform/allocator/vacate_full_test.go
index eaf16e7..0f37ac5 100644
--- a/pkg/platform/allocator/vacate_full_test.go
+++ b/pkg/platform/allocator/vacate_full_test.go
@@ -141,6 +141,67 @@ func TestVacate(t *testing.T) {
 				"\x1b[92;mCluster [3ee11eb40eda22cac0cce259625c6734][Apm]: finished running all the plan steps\x1b[0m (Total plan duration )",
 			),
 		},
+		{
+			name: "Succeeds moving a single Appsearch cluster from a single allocator (without tracking)",
+			args: args{
+				buf: sdkSync.NewBuffer(),
+				params: newVacateTestCase(t, vacateCase{
+					skipTracking: true,
+					topology: []vacateCaseClusters{
+						{
+							Allocator: "allocatorID",
+							appsearch: []vacateCaseClusterConfig{
+								{
+									ID: "3ee11eb40eda22cac0cce259625c6734",
+									steps: [][]*models.ClusterPlanStepInfo{
+										{
+											newPlanStep("step1", "success"),
+											newPlanStep("step2", "pending"),
+										},
+									},
+									plan: []*models.ClusterPlanStepInfo{
+										newPlanStep("step1", "success"),
+										newPlanStep("step2", "success"),
+									},
+								},
+							},
+						},
+					},
+				}),
+			},
+		},
+		{
+			name: "Succeeds moving a single Appsearch cluster from a single allocator, but returns error due to unsupported tracking type",
+			args: args{
+				buf: sdkSync.NewBuffer(),
+				params: newVacateTestCase(t, vacateCase{
+					topology: []vacateCaseClusters{
+						{
+							Allocator: "allocatorID",
+							appsearch: []vacateCaseClusterConfig{
+								{
+									ID: "3ee11eb40eda22cac0cce259625c6734",
+									steps: [][]*models.ClusterPlanStepInfo{
+										{
+											newPlanStep("step1", "success"),
+											newPlanStep("step2", "pending"),
+										},
+									},
+									plan: []*models.ClusterPlanStepInfo{
+										newPlanStep("step1", "success"),
+										newPlanStep("step2", "success"),
+									},
+								},
+							},
+						},
+					},
+				}),
+			},
+			err: `1 error occurred:
+	* allocator allocatorID: cluster [3ee11eb40eda22cac0cce259625c6734][appsearch]: instance is being moved but tracking is not supported, please check the vacate progress from the UI
+
+`,
+		},
 		{
 			name: "Succeeds moving a multiple clusters from a single allocator",
 			args: args{
diff --git a/pkg/platform/allocator/vacate_responses_test.go b/pkg/platform/allocator/vacate_responses_test.go
index 9624185..0c5907a 100644
--- a/pkg/platform/allocator/vacate_responses_test.go
+++ b/pkg/platform/allocator/vacate_responses_test.go
@@ -42,6 +42,35 @@ import (
 
 const planStepLogErrorMessage = "Unexpected error during step: [perform-snapshot]: [no.found.constructor.models.TimeoutException: Timeout]"
 
+type vacateCase struct {
+	topology     []vacateCaseClusters
+	skipTracking bool
+}
+
+type vacateCaseClusters struct {
+	// Allocator ID
+	Allocator string
+	// Elasticsearch clusters that will be simulated
+	elasticsearch []vacateCaseClusterConfig
+	// Kibana instances that will be simulated
+	kibana []vacateCaseClusterConfig
+	// APM clusters that will be simulated
+	apm []vacateCaseClusterConfig
+	// AppSearch clusters that will be simulated
+	appsearch []vacateCaseClusterConfig
+}
+
+type vacateCaseClusterConfig struct {
+	// ID of the cluster
+	ID string
+	// Fails the `_move` api call to actually move a cluster
+	fail bool
+	// Plan steps that are fetched by the
+	steps [][]*models.ClusterPlanStepInfo
+	// Current plan steps the move
+	plan []*models.ClusterPlanStepInfo
+}
+
 func newBody(t *testing.T, v interface{}) string {
 	b, err := json.Marshal(v)
 	if err != nil {
@@ -119,6 +148,25 @@ func newApmMove(t *testing.T, clusterID, allocator string) io.ReadCloser {
 	)))
 }
 
+func newAppsearchMove(t *testing.T, clusterID, allocator string) io.ReadCloser {
+	return ioutil.NopCloser(strings.NewReader(newBody(t,
+		models.MoveClustersCommandResponse{
+			Moves: &models.MoveClustersDetails{
+				AppsearchClusters: []*models.MoveAppSearchDetails{{
+					ClusterID: ec.String(clusterID),
+					CalculatedPlan: &models.TransientAppSearchPlanConfiguration{
+						PlanConfiguration: &models.AppSearchPlanControlConfiguration{
+							MoveAllocators: []*models.AllocatorMoveRequest{
+								{From: ec.String(allocator)},
+							},
+						},
+					},
+				}},
+			},
+		},
+	)))
+}
+
 func newKibanaMoveFailure(t *testing.T, clusterID, allocator string) io.ReadCloser {
 	return ioutil.NopCloser(strings.NewReader(newBody(t,
 		models.MoveClustersCommandResponse{
@@ -148,7 +196,7 @@ func newKibanaMoveFailure(t *testing.T, clusterID, allocator string) io.ReadClos
 	)))
 }
 
-func newMulipleMoves(t *testing.T, allocator string, es, kibana, apm []string) io.ReadCloser {
+func newMulipleMoves(t *testing.T, allocator string, es, kibana, apm, appsearch []string) io.ReadCloser {
 	var res = models.MoveClustersCommandResponse{Moves: &models.MoveClustersDetails{}}
 	for _, id := range es {
 		res.Moves.ElasticsearchClusters = append(res.Moves.ElasticsearchClusters,
@@ -193,31 +241,45 @@ func newMulipleMoves(t *testing.T, allocator string, es, kibana, apm []string) i
 			},
 		)
 	}
+
+	for _, id := range appsearch {
+		res.Moves.AppsearchClusters = append(res.Moves.AppsearchClusters,
+			&models.MoveAppSearchDetails{
+				ClusterID: ec.String(id),
+				CalculatedPlan: &models.TransientAppSearchPlanConfiguration{
+					PlanConfiguration: &models.AppSearchPlanControlConfiguration{
+						MoveAllocators: []*models.AllocatorMoveRequest{
+							{From: ec.String(allocator)},
+						},
+					},
+				},
+			},
+		)
+	}
 	return ioutil.NopCloser(strings.NewReader(newBody(t, res)))
 }
 
 func newPollerBody(t *testing.T, pending, current *models.ElasticsearchClusterPlanInfo) io.ReadCloser {
 	payload := &models.ElasticsearchClusterPlansInfo{Pending: pending, Current: current}
-	var response, err = json.MarshalIndent(payload, "", "  ")
-	if err != nil {
-		t.Fatal(err)
-	}
-	response = append(response, []byte("\n\n")...)
-	return ioutil.NopCloser(bytes.NewReader(response))
+	return newPayloadFromStruct(t, payload)
 }
 
 func newApmPollerBody(t *testing.T, pending, current *models.ApmPlanInfo) io.ReadCloser {
 	payload := &models.ApmPlansInfo{Pending: pending, Current: current}
-	var response, err = json.MarshalIndent(payload, "", "  ")
-	if err != nil {
-		t.Fatal(err)
-	}
-	response = append(response, []byte("\n\n")...)
-	return ioutil.NopCloser(bytes.NewReader(response))
+	return newPayloadFromStruct(t, payload)
+}
+
+func newAppSearchPollerBody(t *testing.T, pending, current *models.AppSearchPlanInfo) io.ReadCloser {
+	payload := &models.AppSearchPlansInfo{Pending: pending, Current: current}
+	return newPayloadFromStruct(t, payload)
 }
 
 func newKibanaPollerBody(t *testing.T, pending, current *models.KibanaClusterPlanInfo) io.ReadCloser {
 	payload := &models.KibanaClusterPlansInfo{Pending: pending, Current: current}
+	return newPayloadFromStruct(t, payload)
+}
+
+func newPayloadFromStruct(t *testing.T, payload interface{}) io.ReadCloser {
 	var response, err = json.MarshalIndent(payload, "", "  ")
 	if err != nil {
 		t.Fatal(err)
@@ -331,32 +393,6 @@ func newAllocator(t *testing.T, id, clusterID, kind string) io.ReadCloser {
 	return ioutil.NopCloser(strings.NewReader(newBody(t, res)))
 }
 
-type vacateCase struct {
-	topology []vacateCaseClusters
-}
-
-type vacateCaseClusters struct {
-	// Allocator ID
-	Allocator string
-	// Elasticsearch clusters that will be simulated
-	elasticsearch []vacateCaseClusterConfig
-	// Kibana instances that will be simulated
-	kibana []vacateCaseClusterConfig
-	// APM clusters that will be simulated
-	apm []vacateCaseClusterConfig
-}
-
-type vacateCaseClusterConfig struct {
-	// ID of the cluster
-	ID string
-	// Fails the `_move` api call to actually move a cluster
-	fail bool
-	// Plan steps that are fetched by the
-	steps [][]*models.ClusterPlanStepInfo
-	// Current plan steps the move
-	plan []*models.ClusterPlanStepInfo
-}
-
 // newVacateTestCase builds a test case from a vacateCase topology
 func newVacateTestCase(t *testing.T, tc vacateCase) *VacateParams {
 	var responses = make([]mock.Response, 0, len(tc.topology))
@@ -370,6 +406,7 @@ func newVacateTestCase(t *testing.T, tc vacateCase) *VacateParams {
 		var esmoves []string
 		var kibanaMoves []string
 		var apmMoves []string
+		var appsearchMoves []string
 
 		// Get all moves
 		for ii := range topology.elasticsearch {
@@ -381,9 +418,12 @@ func newVacateTestCase(t *testing.T, tc vacateCase) *VacateParams {
 		for ii := range topology.apm {
 			apmMoves = append(apmMoves, topology.apm[ii].ID)
 		}
+		for ii := range topology.appsearch {
+			appsearchMoves = append(appsearchMoves, topology.appsearch[ii].ID)
+		}
 
 		responses = append(responses, mock.Response{Response: http.Response{
-			Body:       newMulipleMoves(t, alloc, esmoves, kibanaMoves, apmMoves),
+			Body:       newMulipleMoves(t, alloc, esmoves, kibanaMoves, apmMoves, appsearchMoves),
 			StatusCode: 202,
 		}})
 	}
@@ -407,8 +447,14 @@ func newVacateTestCase(t *testing.T, tc vacateCase) *VacateParams {
 			_, r := newAPMVacateMove(t, alloc, topology.apm[ii])
 			responses = append(responses, r...)
 		}
+
+		for ii := range topology.appsearch {
+			_, r := newAppsearchVacateMove(t, alloc, topology.appsearch[ii])
+			responses = append(responses, r...)
+		}
 	}
 	return &VacateParams{
+		SkipTracking:   tc.skipTracking,
 		Output:         output.NewDevice(sdkSync.NewBuffer()),
 		Allocators:     allocators,
 		API:            api.NewMock(responses...),
@@ -424,7 +470,7 @@ func newAPMVacateMove(t *testing.T, alloc string, move vacateCaseClusterConfig)
 		Body:       newAllocator(t, alloc, move.ID, "apm"),
 		StatusCode: 200,
 	}}, mock.Response{Response: http.Response{
-		Body:       newKibanaMove(t, move.ID, alloc),
+		Body:       newApmMove(t, move.ID, alloc),
 		StatusCode: 202,
 	}})
 
@@ -629,3 +675,74 @@ func newElasticsearchVacateMove(t *testing.T, alloc string, move vacateCaseClust
 
 	return api.NewMock(responses...), responses
 }
+
+func newAppsearchVacateMove(t *testing.T, alloc string, move vacateCaseClusterConfig) (*api.API, []mock.Response) {
+	var responses = make([]mock.Response, 0, 4)
+	responses = append(responses, mock.Response{Response: http.Response{
+		Body:       newAllocator(t, alloc, move.ID, "appsearch"),
+		StatusCode: 200,
+	}}, mock.Response{Response: http.Response{
+		Body:       newAppsearchMove(t, move.ID, alloc),
+		StatusCode: 202,
+	}})
+
+	if move.fail {
+		body := ioutil.NopCloser(strings.NewReader(newBody(t, &models.MoveClustersCommandResponse{
+			Failures: &models.MoveClustersDetails{
+				ApmClusters: []*models.MoveApmClusterDetails{{
+					ClusterID: ec.String(move.ID),
+					CalculatedPlan: &models.TransientApmPlanConfiguration{
+						PlanConfiguration: &models.ApmPlanControlConfiguration{
+							MoveAllocators: []*models.AllocatorMoveRequest{{From: ec.String(alloc)}},
+						},
+					},
+					Errors: []*models.BasicFailedReplyElement{
+						{
+							Code:    ec.String("a code"),
+							Message: ec.String("a message"),
+						},
+					},
+				}},
+			}})))
+		// Return a response with a failed move
+		responses = append(responses, mock.Response{Response: http.Response{
+			Body:       body,
+			StatusCode: 202,
+		}})
+		// No extra responses should be given back for this cluster
+		// when a move failures happens.
+		return api.NewMock(responses...), responses
+	}
+
+	responses = append(responses, mock.Response{Response: http.Response{
+		Body:       newAppsearchMove(t, move.ID, alloc),
+		StatusCode: 202,
+	}})
+
+	// Define steps
+	for iii := range move.steps {
+		var step = move.steps[iii]
+		responses = append(responses, mock.Response{Response: http.Response{
+			StatusCode: 200,
+			Body: newAppSearchPollerBody(t,
+				&models.AppSearchPlanInfo{PlanAttemptLog: step},
+				nil,
+			),
+		}})
+	}
+
+	// Plan finished
+	responses = append(responses,
+		util.PlanNotFound,
+		util.PlanNotFound,
+		mock.Response{Response: http.Response{
+			StatusCode: 200,
+			Body: newAppSearchPollerBody(t,
+				nil,
+				&models.AppSearchPlanInfo{PlanAttemptLog: move.plan},
+			),
+		}},
+	)
+
+	return api.NewMock(responses...), responses
+}
diff --git a/pkg/platform/allocator/vacate_test.go b/pkg/platform/allocator/vacate_test.go
index 5773c25..f3bf658 100644
--- a/pkg/platform/allocator/vacate_test.go
+++ b/pkg/platform/allocator/vacate_test.go
@@ -97,6 +97,18 @@ func TestComputeVacateRequest(t *testing.T) {
 							},
 						},
 					},
+					AppsearchClusters: []*models.MoveAppSearchDetails{
+						{
+							ClusterID: ec.String("d8ad23ad6f064709bbae7ab87a7e1bc9"),
+							CalculatedPlan: &models.TransientAppSearchPlanConfiguration{
+								PlanConfiguration: &models.AppSearchPlanControlConfiguration{
+									Timeout:             4096,
+									ReallocateInstances: ec.Bool(false),
+									ExtendedMaintenance: ec.Bool(false),
+								},
+							},
+						},
+					},
 				},
 				clusters: nil,
 				to:       nil,
@@ -148,6 +160,20 @@ func TestComputeVacateRequest(t *testing.T) {
 						},
 					},
 				},
+				AppsearchClusters: []*models.MoveAppSearchConfiguration{
+					{
+						ClusterIds: []string{
+							"d8ad23ad6f064709bbae7ab87a7e1bc9",
+						},
+						PlanOverride: &models.TransientAppSearchPlanConfiguration{
+							PlanConfiguration: &models.AppSearchPlanControlConfiguration{
+								Timeout:             4096,
+								ReallocateInstances: ec.Bool(false),
+								ExtendedMaintenance: ec.Bool(false),
+							},
+						},
+					},
+				},
 			},
 		},
 		{
@@ -579,7 +605,7 @@ func TestCheckVacateFailures(t *testing.T) {
 			}},
 		},
 		{
-			name: "Returns na apm error on Kibana vacate failure",
+			name: "Returns an error on APM vacate failure",
 			args: args{
 				failures: &models.MoveClustersDetails{
 					ApmClusters: []*models.MoveApmClusterDetails{
@@ -599,6 +625,27 @@ func TestCheckVacateFailures(t *testing.T) {
 				errors.New("cluster [123456789][apm] failed vacating, reason: code: unknown, message: an apm error message"),
 			}},
 		},
+		{
+			name: "Returns an error on AppSearch vacate failure",
+			args: args{
+				failures: &models.MoveClustersDetails{
+					AppsearchClusters: []*models.MoveAppSearchDetails{
+						{
+							ClusterID: ec.String("123456789"),
+							Errors: []*models.BasicFailedReplyElement{
+								{
+									Code:    ec.String("unknown"),
+									Message: ec.String("an apm error message"),
+								},
+							},
+						},
+					},
+				},
+			},
+			err: &multierror.Error{Errors: []error{
+				errors.New("cluster [123456789][appsearch] failed vacating, reason: code: unknown, message: an apm error message"),
+			}},
+		},
 		{
 			name: "Returns an elasticsearch & kibana error on multiple ES & Kibana vacate failures",
 			args: args{
@@ -823,6 +870,24 @@ func TestVacateCluster(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "Succeeds with an appsearch instance with no tracking",
+			args: args{
+				buf: new(bytes.Buffer),
+				params: &VacateClusterParams{
+					ID:             "someID",
+					ClusterID:      "2ee11eb40eda22cac0cce259625c6734",
+					Kind:           "appsearch",
+					Output:         new(output.Device),
+					TrackFrequency: time.Nanosecond,
+					SkipTracking:   true,
+					MaxPollRetries: 1,
+					API: discardResponses(
+						newKibanaVacateMove(t, "someID", vacateCaseClusterConfig{}),
+					),
+				},
+			},
+		},
 		{
 			name: "Moving kibana instance fails",
 			args: args{
@@ -1131,7 +1196,61 @@ func Test_addAllocatorMovesToPool(t *testing.T) {
 			wantMoved:     true,
 		},
 		{
-			name: "Move clusters when a matching filter is specified",
+			name: "Move clusters when no filter is specified (Apm)",
+			args: args{params: addAllocatorMovesToPoolParams{
+				ID: "allocator-1",
+				Pool: func() *pool.Pool {
+					p, _ := pool.NewPool(pool.Params{
+						Size:    1,
+						Run:     VacateClusterInPool,
+						Timeout: pool.DefaultTimeout,
+					})
+					return p
+				}(),
+				VacateParams: &VacateParams{},
+				Moves: &models.MoveClustersDetails{
+					ApmClusters: []*models.MoveApmClusterDetails{
+						{
+							ClusterID: ec.String("d7ad23ad6f064709bbae7ab87a7e1bc9"),
+							CalculatedPlan: &models.TransientApmPlanConfiguration{
+								PlanConfiguration: &models.ApmPlanControlConfiguration{},
+							},
+						},
+					},
+				},
+			}},
+			wantLeftovers: nil,
+			wantMoved:     true,
+		},
+		{
+			name: "Move clusters when no filter is specified (AppSearch)",
+			args: args{params: addAllocatorMovesToPoolParams{
+				ID: "allocator-1",
+				Pool: func() *pool.Pool {
+					p, _ := pool.NewPool(pool.Params{
+						Size:    1,
+						Run:     VacateClusterInPool,
+						Timeout: pool.DefaultTimeout,
+					})
+					return p
+				}(),
+				VacateParams: &VacateParams{},
+				Moves: &models.MoveClustersDetails{
+					AppsearchClusters: []*models.MoveAppSearchDetails{
+						{
+							ClusterID: ec.String("d7ad23ad6f064709bbae7ab87a7e1bc9"),
+							CalculatedPlan: &models.TransientAppSearchPlanConfiguration{
+								PlanConfiguration: &models.AppSearchPlanControlConfiguration{},
+							},
+						},
+					},
+				},
+			}},
+			wantLeftovers: nil,
+			wantMoved:     true,
+		},
+		{
+			name: "Move clusters when a matching filter is specified (Kibana)",
 			args: args{params: addAllocatorMovesToPoolParams{
 				ID: "allocator-1",
 				Pool: func() *pool.Pool {
@@ -1166,7 +1285,7 @@ func Test_addAllocatorMovesToPool(t *testing.T) {
 			wantMoved:     true,
 		},
 		{
-			name: "Move clusters when a matching filter is specified",
+			name: "Move clusters when a matching filter is specified (Elasticsearch)",
 			args: args{params: addAllocatorMovesToPoolParams{
 				ID: "allocator-1",
 				Pool: func() *pool.Pool {
@@ -1200,6 +1319,33 @@ func Test_addAllocatorMovesToPool(t *testing.T) {
 			wantLeftovers: nil,
 			wantMoved:     true,
 		},
+		{
+			name: "Move clusters when no filter is specified (AppSearch)",
+			args: args{params: addAllocatorMovesToPoolParams{
+				ID: "allocator-1",
+				Pool: func() *pool.Pool {
+					p, _ := pool.NewPool(pool.Params{
+						Size:    1,
+						Run:     VacateClusterInPool,
+						Timeout: pool.DefaultTimeout,
+					})
+					return p
+				}(),
+				VacateParams: &VacateParams{KindFilter: "appsearch"},
+				Moves: &models.MoveClustersDetails{
+					AppsearchClusters: []*models.MoveAppSearchDetails{
+						{
+							ClusterID: ec.String("d7ad23ad6f064709bbae7ab87a7e1bc9"),
+							CalculatedPlan: &models.TransientAppSearchPlanConfiguration{
+								PlanConfiguration: &models.AppSearchPlanControlConfiguration{},
+							},
+						},
+					},
+				},
+			}},
+			wantLeftovers: nil,
+			wantMoved:     true,
+		},
 		{
 			name: "Skip move clusters when a non-matching filter is specified",
 			args: args{params: addAllocatorMovesToPoolParams{