c/s/e/cli: add equinix api cli tooling

This is a fairly simple and hacked together cli but it does its job for basic maintenance tasks

Change-Id: I043c12b930546f9405b9f8190326724122f1c0aa
Reviewed-on: https://review.monogon.dev/c/monogon/+/1704
Tested-by: Jenkins CI
Reviewed-by: Serge Bazanski <serge@monogon.tech>
diff --git a/cloud/shepherd/equinix/cli/BUILD.bazel b/cloud/shepherd/equinix/cli/BUILD.bazel
new file mode 100644
index 0000000..8ac689a
--- /dev/null
+++ b/cloud/shepherd/equinix/cli/BUILD.bazel
@@ -0,0 +1,28 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "cli_lib",
+    srcs = [
+        "cmd_delete.go",
+        "cmd_move.go",
+        "cmd_reboot.go",
+        "cmd_yoink.go",
+        "main.go",
+    ],
+    importpath = "source.monogon.dev/cloud/shepherd/equinix/cli",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//cloud/shepherd/equinix/wrapngo",
+        "//metropolis/cli/pkg/context",
+        "@com_github_packethost_packngo//:packngo",
+        "@com_github_spf13_cobra//:cobra",
+        "@io_k8s_klog//:klog",
+        "@io_k8s_klog_v2//:klog",
+    ],
+)
+
+go_binary(
+    name = "cli",
+    embed = [":cli_lib"],
+    visibility = ["//visibility:public"],
+)
diff --git a/cloud/shepherd/equinix/cli/cmd_delete.go b/cloud/shepherd/equinix/cli/cmd_delete.go
new file mode 100644
index 0000000..e3c51a5
--- /dev/null
+++ b/cloud/shepherd/equinix/cli/cmd_delete.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+	"context"
+	"time"
+
+	"github.com/spf13/cobra"
+	"k8s.io/klog/v2"
+
+	"source.monogon.dev/cloud/shepherd/equinix/wrapngo"
+	clicontext "source.monogon.dev/metropolis/cli/pkg/context"
+)
+
+var deleteCmd = &cobra.Command{
+	Use:   "delete [target]",
+	Short: "Delete all devices from one project",
+	Args:  cobra.ExactArgs(1),
+	Run:   doDelete,
+}
+
+func init() {
+	rootCmd.AddCommand(deleteCmd)
+}
+
+func doDelete(cmd *cobra.Command, args []string) {
+	ctx := clicontext.WithInterrupt(context.Background())
+	api := wrapngo.New(&c)
+
+	klog.Infof("Listing devices for %q", args[0])
+
+	devices, err := api.ListDevices(ctx, args[0])
+	if err != nil {
+		klog.Exitf("failed listing devices: %v", err)
+	}
+
+	if len(devices) == 0 {
+		klog.Infof("No devices found in %s", args[0])
+		return
+	}
+
+	klog.Infof("Deleting %d Devices in %s. THIS WILL DELETE SERVERS! You have five seconds to cancel!", len(devices), args[0])
+	time.Sleep(5 * time.Second)
+
+	for _, d := range devices {
+		klog.Infof("deleting %s (%s)...", d.ID, d.Hostname)
+		if err := api.DeleteDevice(ctx, d.ID); err != nil {
+			klog.Infof("failed deleting device %s (%s): %v", d.ID, d.Hostname, err)
+			continue
+		}
+	}
+}
diff --git a/cloud/shepherd/equinix/cli/cmd_move.go b/cloud/shepherd/equinix/cli/cmd_move.go
new file mode 100644
index 0000000..fa93501
--- /dev/null
+++ b/cloud/shepherd/equinix/cli/cmd_move.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+	"context"
+
+	"github.com/spf13/cobra"
+	"k8s.io/klog/v2"
+
+	"source.monogon.dev/cloud/shepherd/equinix/wrapngo"
+	clicontext "source.monogon.dev/metropolis/cli/pkg/context"
+)
+
+var moveCmd = &cobra.Command{
+	Use:   "move [source] [target]",
+	Short: "Move all reserved hardware from one to another project",
+	Args:  cobra.ExactArgs(2),
+	Run:   doMove,
+}
+
+func init() {
+	rootCmd.AddCommand(moveCmd)
+}
+
+func doMove(cmd *cobra.Command, args []string) {
+	ctx := clicontext.WithInterrupt(context.Background())
+	api := wrapngo.New(&c)
+
+	klog.Infof("Listing reservations for %q", args[0])
+	reservations, err := api.ListReservations(ctx, args[0])
+	if err != nil {
+		klog.Exitf("failed listing reservations: %v", err)
+	}
+
+	klog.Infof("Got %d reservations. Moving machines", len(reservations))
+	for _, r := range reservations {
+		_, err := api.MoveReservation(ctx, r.ID, args[1])
+		if err != nil {
+			klog.Errorf("failed moving reservation: %v", err)
+			continue
+		}
+		klog.Infof("Moved Device %s", r.ID)
+	}
+}
diff --git a/cloud/shepherd/equinix/cli/cmd_reboot.go b/cloud/shepherd/equinix/cli/cmd_reboot.go
new file mode 100644
index 0000000..528cd2e
--- /dev/null
+++ b/cloud/shepherd/equinix/cli/cmd_reboot.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+	"context"
+
+	"github.com/spf13/cobra"
+	"k8s.io/klog/v2"
+
+	"source.monogon.dev/cloud/shepherd/equinix/wrapngo"
+	clicontext "source.monogon.dev/metropolis/cli/pkg/context"
+)
+
+var rebootCmd = &cobra.Command{
+	Use:   "reboot [project] [id]",
+	Short: "Reboots all or one specific node",
+	Args:  cobra.MaximumNArgs(1),
+	Run:   doReboot,
+}
+
+func init() {
+	rootCmd.AddCommand(rebootCmd)
+}
+
+func doReboot(cmd *cobra.Command, args []string) {
+	ctx := clicontext.WithInterrupt(context.Background())
+	api := wrapngo.New(&c)
+
+	klog.Infof("Requesting device list...")
+	devices, err := api.ListDevices(ctx, args[0])
+	if err != nil {
+		klog.Fatal(err)
+	}
+
+	for _, d := range devices {
+		if len(args) == 2 && args[1] != d.ID {
+			continue
+		}
+
+		err := api.RebootDevice(ctx, d.ID)
+		if err != nil {
+			klog.Error(err)
+			continue
+		}
+		klog.Infof("rebooted %s", d.ID)
+	}
+}
diff --git a/cloud/shepherd/equinix/cli/cmd_yoink.go b/cloud/shepherd/equinix/cli/cmd_yoink.go
new file mode 100644
index 0000000..7196d42
--- /dev/null
+++ b/cloud/shepherd/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/shepherd/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(args[0]), dc: strings.ToLower(args[1])}
+
+	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(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 {
+		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)
+		}
+	}
+}
diff --git a/cloud/shepherd/equinix/cli/main.go b/cloud/shepherd/equinix/cli/main.go
new file mode 100644
index 0000000..aa6f411
--- /dev/null
+++ b/cloud/shepherd/equinix/cli/main.go
@@ -0,0 +1,32 @@
+package main
+
+import (
+	"flag"
+
+	"github.com/spf13/cobra"
+
+	"k8s.io/klog"
+
+	"source.monogon.dev/cloud/shepherd/equinix/wrapngo"
+)
+
+// rootCmd represents the base command when called without any subcommands
+var rootCmd = &cobra.Command{
+	PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+		if c.APIKey == "" || c.User == "" {
+			klog.Exitf("-equinix_api_username and -equinix_api_key must be set")
+		}
+		return nil
+	},
+}
+
+var c wrapngo.Opts
+
+func init() {
+	c.RegisterFlags()
+	rootCmd.PersistentFlags().AddGoFlagSet(flag.CommandLine)
+}
+
+func main() {
+	cobra.CheckErr(rootCmd.Execute())
+}