cloud/equinix/cli: add list command

This also replaces the packngo library with our fork

Change-Id: I7ef23b840ce0de01109ab5764ed2c23feff72e49
Reviewed-on: https://review.monogon.dev/c/monogon/+/3060
Reviewed-by: Serge Bazanski <serge@monogon.tech>
Tested-by: Jenkins CI
diff --git a/cloud/equinix/cli/BUILD.bazel b/cloud/equinix/cli/BUILD.bazel
index d10fdeb..020e93c 100644
--- a/cloud/equinix/cli/BUILD.bazel
+++ b/cloud/equinix/cli/BUILD.bazel
@@ -4,6 +4,7 @@
     name = "cli_lib",
     srcs = [
         "cmd_delete.go",
+        "cmd_list.go",
         "cmd_move.go",
         "cmd_reboot.go",
         "cmd_yoink.go",
diff --git a/cloud/equinix/cli/cmd_list.go b/cloud/equinix/cli/cmd_list.go
new file mode 100644
index 0000000..bfbb21d
--- /dev/null
+++ b/cloud/equinix/cli/cmd_list.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/signal"
+	"slices"
+	"strings"
+
+	"github.com/packethost/packngo"
+	"github.com/spf13/cobra"
+	"k8s.io/klog/v2"
+
+	"source.monogon.dev/cloud/equinix/wrapngo"
+)
+
+var listCmd = &cobra.Command{
+	Use: "list",
+	Long: `This lists all hardware reservations inside a specified organization or project.`,
+	Args:  cobra.NoArgs,
+	Run:   doList,
+}
+
+func init() {
+	listCmd.Flags().String("equinix_organization", "", "from which organization to list from")
+	listCmd.Flags().String("equinix_project", "", "from which project to list from")
+	rootCmd.AddCommand(listCmd)
+}
+
+func doList(cmd *cobra.Command, args []string) {
+	organization, err := cmd.Flags().GetString("equinix_organization")
+	if err != nil {
+		klog.Exitf("flag: %v", err)
+	}
+
+	project, err := cmd.Flags().GetString("equinix_project")
+	if err != nil {
+		klog.Exitf("flag: %v", err)
+	}
+
+	if organization == "" && project == "" {
+		klog.Exitf("missing organization or project flag")
+	}
+
+	ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
+	api := wrapngo.New(&c)
+
+	var (
+		reservations []packngo.HardwareReservation
+	)
+	switch {
+	case project != "" && organization == "":
+		klog.Infof("Listing reservations for project: %s", project)
+		reservations, err = api.ListReservations(ctx, project)
+	case organization != "" && project == "":
+		klog.Infof("Listing reservations for organization: %s", organization)
+		reservations, err = api.ListOrganizationReservations(ctx, organization)
+	default:
+		klog.Exitf("exactly one of organization or project flags has to be set")
+	}
+
+	if err != nil {
+		klog.Fatalf("Failed to list reservations: %v", err)
+	}
+
+	type configDC struct {
+		config string
+		dc     string
+	}
+	type configDCP struct {
+		configDC
+		project string
+	}
+	mtypes := make(map[configDC]int)
+	mptypes := make(map[configDCP]int)
+
+	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)}
+		curPType := configDCP{curType, r.Project.Name}
+		mtypes[curType]++
+		mptypes[curPType]++
+	}
+
+	klog.Infof("Found the following configurations:")
+	var mStrings []string
+	for dc, c := range mtypes {
+		mStrings = append(mStrings, fmt.Sprintf("%s | %s | %d", dc.dc, dc.config, c))
+	}
+	slices.Sort(mStrings)
+	for _, s := range mStrings {
+		klog.Info(s)
+	}
+
+	klog.Infof("Found the following configurations (per project):")
+	var mpStrings []string
+	for dc, c := range mptypes {
+		mpStrings = append(mpStrings, fmt.Sprintf("%s | %s | %s | %d", dc.project, dc.dc, dc.config, c))
+	}
+	slices.Sort(mpStrings)
+	for _, s := range mpStrings {
+		klog.Info(s)
+	}
+}
diff --git a/cloud/equinix/wrapngo/wrapn.go b/cloud/equinix/wrapngo/wrapn.go
index 4ef7b9b..8ee654c 100644
--- a/cloud/equinix/wrapngo/wrapn.go
+++ b/cloud/equinix/wrapngo/wrapn.go
@@ -110,6 +110,9 @@
 	// with project pid. This is an expensive method that takes a while to execute,
 	// handle with care.
 	ListReservations(ctx context.Context, pid string) ([]packngo.HardwareReservation, error)
+
+	ListOrganizationReservations(ctx context.Context, oid string) ([]packngo.HardwareReservation, error)
+
 	// MoveReservation moves a reserved device to the given project.
 	MoveReservation(ctx context.Context, hardwareReservationDID, projectID string) (*packngo.HardwareReservation, error)
 
@@ -346,6 +349,13 @@
 	})
 }
 
+func (c *client) ListOrganizationReservations(ctx context.Context, pid string) ([]packngo.HardwareReservation, error) {
+	return wrap(ctx, c, func(cl *packngo.Client) ([]packngo.HardwareReservation, error) {
+		res, _, err := cl.Organizations.ListHardwareReservations(pid, &packngo.ListOptions{Includes: []string{"facility", "device", "project"}})
+		return res, err
+	})
+}
+
 func (c *client) MoveReservation(ctx context.Context, hardwareReservationDID, projectID string) (*packngo.HardwareReservation, error) {
 	return wrap(ctx, c, func(cl *packngo.Client) (*packngo.HardwareReservation, error) {
 		hr, _, err := cl.HardwareReservations.Move(hardwareReservationDID, projectID)
diff --git a/cloud/shepherd/provider/equinix/fakequinix_test.go b/cloud/shepherd/provider/equinix/fakequinix_test.go
index bd0df4a..62892cc 100644
--- a/cloud/shepherd/provider/equinix/fakequinix_test.go
+++ b/cloud/shepherd/provider/equinix/fakequinix_test.go
@@ -23,6 +23,10 @@
 	reboots      map[string]int
 }
 
+func (f *fakequinix) ListOrganizationReservations(ctx context.Context, oid string) ([]packngo.HardwareReservation, error) {
+	return nil, fmt.Errorf("not implemented")
+}
+
 // newFakequinix makes a fakequinix with a given fake project ID and number of
 // hardware reservations to create.
 func newFakequinix(pid string, numReservations int) *fakequinix {