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)
+ }
+ }
+}