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 {
diff --git a/go.mod b/go.mod
index b6239da..b568bea 100644
--- a/go.mod
+++ b/go.mod
@@ -66,6 +66,10 @@
 // to appear in our dependency graph: https://github.com/golang/go/issues/37175
 replace golang.org/x/exp => golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa
 
+// Replace with our patched library to support hardware listings for a whole
+// organization at once.
+replace github.com/packethost/packngo => github.com/monogon-dev/packngo v0.0.0-20240122175436-ecbd9eb00ddb
+
 require (
 	4d63.com/gocheckcompilerdirectives v1.2.1
 	cloud.google.com/go/storage v1.36.0
diff --git a/go.sum b/go.sum
index 7f04307..1ea565c 100644
--- a/go.sum
+++ b/go.sum
@@ -1337,8 +1337,9 @@
 github.com/diskfs/go-diskfs v1.2.0/go.mod h1:ZTeTbzixuyfnZW5y5qKMtjV2o+GLLHo1KfMhotJI4Rk=
 github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
 github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
-github.com/dnaeon/go-vcr v1.0.1 h1:r8L/HqC0Hje5AXMu1ooW8oyQyOFv4GxqpL0nRP7SLLY=
 github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
+github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
+github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
 github.com/dnstap/golang-dnstap v0.4.0 h1:KRHBoURygdGtBjDI2w4HifJfMAhhOqDuktAokaSa234=
 github.com/dnstap/golang-dnstap v0.4.0/go.mod h1:FqsSdH58NAmkAvKcpyxht7i4FoBjKu8E4JUPt8ipSUs=
 github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
@@ -2283,6 +2284,7 @@
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
 github.com/mohae/deepcopy v0.0.0-20170603005431-491d3605edfb/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
@@ -2292,6 +2294,8 @@
 github.com/monogon-dev/ethtool v0.0.0-20231122193313-e9c21a3a83cb/go.mod h1:MQ3uW0nQeqrFtI+NIBalCybBz4C0cNAJs94InnMJqyk=
 github.com/monogon-dev/netlink v0.0.0-20230125113930-88977c3ff4b3 h1:y05BDqZ6q3if6pYBHJcnQRUd92ihzBEJde/S4fpKEAM=
 github.com/monogon-dev/netlink v0.0.0-20230125113930-88977c3ff4b3/go.mod h1:cAAsePK2e15YDAMJNyOpGYEWNe4sIghTY7gpz4cX/Ik=
+github.com/monogon-dev/packngo v0.0.0-20240122175436-ecbd9eb00ddb h1:sxSnvzB4iDBNhUBqXME/ETqjF4vX0mURE85T/I/Mr0o=
+github.com/monogon-dev/packngo v0.0.0-20240122175436-ecbd9eb00ddb/go.mod h1:Io6VJqzkiqmIEQbpOjeIw9v8q9PfcTEq8TEY/tMQsfw=
 github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
@@ -2457,8 +2461,6 @@
 github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
 github.com/outcaste-io/ristretto v0.2.1 h1:KCItuNIGJZcursqHr3ghO7fc5ddZLEHspL9UR0cQM64=
 github.com/outcaste-io/ristretto v0.2.1/go.mod h1:W8HywhmtlopSB1jeMg3JtdIhf+DYkLAr0VN/s4+MHac=
-github.com/packethost/packngo v0.29.0 h1:gRIhciVZQ/zLNrIdIdbOUyB/Tw5IgoaXyhP4bvE+D2s=
-github.com/packethost/packngo v0.29.0/go.mod h1:/UHguFdPs6Lf6FOkkSEPnRY5tgS0fsVM+Zv/bvBrmt0=
 github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
 github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
diff --git a/third_party/go/repositories.bzl b/third_party/go/repositories.bzl
index 5c099f4..7dcb1cc 100644
--- a/third_party/go/repositories.bzl
+++ b/third_party/go/repositories.bzl
@@ -1421,8 +1421,8 @@
     go_repository(
         name = "com_github_dnaeon_go_vcr",
         importpath = "github.com/dnaeon/go-vcr",
-        sum = "h1:r8L/HqC0Hje5AXMu1ooW8oyQyOFv4GxqpL0nRP7SLLY=",
-        version = "v1.0.1",
+        sum = "h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=",
+        version = "v1.2.0",
     )
     go_repository(
         name = "com_github_dnstap_golang_dnstap",
@@ -3816,6 +3816,12 @@
         version = "v1.0.2",
     )
     go_repository(
+        name = "com_github_modocache_gover",
+        importpath = "github.com/modocache/gover",
+        sum = "h1:8Q0qkMVC/MmWkpIdlvZgcv2o2jrlF6zqVOh7W5YHdMA=",
+        version = "v0.0.0-20171022184752-b58185e213c5",
+    )
+    go_repository(
         name = "com_github_mohae_deepcopy",
         importpath = "github.com/mohae/deepcopy",
         sum = "h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=",
@@ -4136,8 +4142,9 @@
     go_repository(
         name = "com_github_packethost_packngo",
         importpath = "github.com/packethost/packngo",
-        sum = "h1:gRIhciVZQ/zLNrIdIdbOUyB/Tw5IgoaXyhP4bvE+D2s=",
-        version = "v0.29.0",
+        replace = "github.com/monogon-dev/packngo",
+        sum = "h1:sxSnvzB4iDBNhUBqXME/ETqjF4vX0mURE85T/I/Mr0o=",
+        version = "v0.0.0-20240122175436-ecbd9eb00ddb",
     )
     go_repository(
         name = "com_github_pact_foundation_pact_go",