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