cloud: split shepherd up

Change-Id: I8e386d9eaaf17543743e1e8a37a8d71426910d59
Reviewed-on: https://review.monogon.dev/c/monogon/+/2213
Reviewed-by: Serge Bazanski <serge@monogon.tech>
Tested-by: Jenkins CI
diff --git a/cloud/equinix/cli/cmd_yoink.go b/cloud/equinix/cli/cmd_yoink.go
new file mode 100644
index 0000000..bda9e82
--- /dev/null
+++ b/cloud/equinix/cli/cmd_yoink.go
@@ -0,0 +1,170 @@
+package main
+
+import (
+	"bufio"
+	"context"
+	"os"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/packethost/packngo"
+	"github.com/spf13/cobra"
+	"k8s.io/klog/v2"
+
+	"source.monogon.dev/cloud/equinix/wrapngo"
+	clicontext "source.monogon.dev/metropolis/cli/pkg/context"
+)
+
+var yoinkCmd = &cobra.Command{
+	Use: "yoink",
+	Long: `This moves a specified amount of servers that match the given spec to a different metro.
+While spec is a easy to find argument that matches the equinix system spec e.g. w3amd.75xx24c.512.8160.x86, 
+metro does not represent the public facing name. Instead it is the acutal datacenter name e.g. fr2"`,
+	Short: "Move a server base on the spec from one to another project",
+	Args:  cobra.NoArgs,
+	Run:   doYoink,
+}
+
+func init() {
+	yoinkCmd.Flags().Int("count", 1, "how many machines should be moved")
+	yoinkCmd.Flags().String("equinix_source_project", "", "from which project should the machine be yoinked")
+	yoinkCmd.Flags().String("equinix_target_project", "", "to which project should the machine be moved")
+	yoinkCmd.Flags().String("spec", "", "which device spec should be moved")
+	yoinkCmd.Flags().String("metro", "", "to which metro should be moved")
+	rootCmd.AddCommand(yoinkCmd)
+}
+
+func doYoink(cmd *cobra.Command, args []string) {
+	srcProject, err := cmd.Flags().GetString("equinix_source_project")
+	if err != nil {
+		klog.Exitf("flag: %v", err)
+	}
+
+	dstProject, err := cmd.Flags().GetString("equinix_target_project")
+	if err != nil {
+		klog.Exitf("flag: %v", err)
+	}
+
+	if srcProject == "" || dstProject == "" {
+		klog.Exitf("missing project flags")
+	}
+
+	count, err := cmd.Flags().GetInt("count")
+	if err != nil {
+		klog.Exitf("flag: %v", err)
+	}
+
+	spec, err := cmd.Flags().GetString("spec")
+	if err != nil {
+		klog.Exitf("flag: %v", err)
+	}
+
+	if spec == "" {
+		klog.Exitf("missing spec flag")
+	}
+
+	metro, err := cmd.Flags().GetString("metro")
+	if err != nil {
+		klog.Exitf("flag: %v", err)
+	}
+
+	if metro == "" {
+		klog.Exitf("missing metro flag")
+	}
+
+	ctx := clicontext.WithInterrupt(context.Background())
+	api := wrapngo.New(&c)
+
+	klog.Infof("Listing reservations for %q", srcProject)
+	reservations, err := api.ListReservations(ctx, srcProject)
+	if err != nil {
+		klog.Exitf("Failed to list reservations: %v", err)
+	}
+
+	type configDC struct {
+		config string
+		dc     string
+	}
+	mtypes := make(map[configDC]int)
+
+	var matchingReservations []packngo.HardwareReservation
+	reqType := configDC{config: strings.ToLower(spec), dc: strings.ToLower(metro)}
+
+	klog.Infof("Got %d reservations", len(reservations))
+	for _, r := range reservations {
+		curType := configDC{config: strings.ToLower(r.Plan.Name), dc: strings.ToLower(r.Facility.Metro.Code)}
+
+		mtypes[curType]++
+		if curType == reqType {
+			matchingReservations = append(matchingReservations, r)
+		}
+	}
+
+	klog.Infof("Found the following configurations:")
+	for dc, c := range mtypes {
+		klog.Infof("%s | %s | %d", dc.dc, dc.config, c)
+	}
+
+	if len(matchingReservations) == 0 {
+		klog.Exitf("Configuration not found: %s - %s", reqType.dc, reqType.config)
+	}
+
+	if len(matchingReservations)-count < 0 {
+		klog.Exitf("Not enough machines with matching configuration found ")
+	}
+
+	// prefer hosts that are not deployed
+	sort.Slice(matchingReservations, func(i, j int) bool {
+		return matchingReservations[i].Device == nil && matchingReservations[j].Device != nil
+	})
+
+	toMove := matchingReservations[:count]
+	var toDelete []string
+	for _, r := range toMove {
+		if r.Device != nil {
+			toDelete = append(toDelete, r.Device.Hostname)
+		}
+	}
+
+	stdInReader := bufio.NewReader(os.Stdin)
+	klog.Infof("Will move %d machines with spec %s in %s from %s to %s.", count, spec, metro, srcProject, dstProject)
+	if len(toDelete) > 0 {
+		klog.Warningf("Not enough free machines found. This will delete %d provisioned hosts! Hosts scheduled for deletion: ", len(toDelete))
+		klog.Warningf("%s", strings.Join(toDelete, ", "))
+		klog.Warningf("Please confirm by inputting in the number of machines that will be moved.")
+
+		read, err := stdInReader.ReadString('\n')
+		if err != nil {
+			klog.Exitf("failed reading input: %v", err)
+		}
+
+		atoi, err := strconv.Atoi(strings.TrimSpace(read))
+		if err != nil {
+			klog.Exitf("failed parsing number: %v", err)
+		}
+
+		if atoi != len(toDelete) {
+			klog.Exitf("Confirmation failed! Wanted \"%q\" got \"%d\"", len(toDelete), atoi)
+		} else {
+			klog.Infof("Thanks for the confirmation! continuing...")
+		}
+	}
+
+	klog.Infof("Note: It can be normal for a device move to fail for project validation issues. This is a known issue and can be ignored")
+	for _, r := range matchingReservations[:count] {
+		if r.Device != nil {
+			klog.Warningf("Deleting server %s (%s) on %s", r.Device.ID, r.Device.Hostname, r.ID)
+
+			if err := api.DeleteDevice(ctx, r.Device.ID); err != nil {
+				klog.Errorf("failed deleting device %s (%s): %v", r.Device.ID, r.Device.Hostname, err)
+				continue
+			}
+		}
+
+		_, err := api.MoveReservation(ctx, r.ID, dstProject)
+		if err != nil {
+			klog.Errorf("failed moving device %s: %v", r.ID, err)
+		}
+	}
+}