cloud/shepherd/equinix/wrapngo: init

This adds a wrapper extending packngo for use with the upcoming
Shepherd implementation.

Supersedes: https://review.monogon.dev/c/monogon/+/989
Change-Id: I55d1a609a8b5241704c5fe4ce8c2294122cfa0c8
Reviewed-on: https://review.monogon.dev/c/monogon/+/1128
Reviewed-by: Leopold Schabel <leo@monogon.tech>
Tested-by: Jenkins CI
Reviewed-by: Mateusz Zalega <mateusz@monogon.tech>
diff --git a/cloud/shepherd/equinix/wrapngo/duct_tape.go b/cloud/shepherd/equinix/wrapngo/duct_tape.go
new file mode 100644
index 0000000..c9e156b
--- /dev/null
+++ b/cloud/shepherd/equinix/wrapngo/duct_tape.go
@@ -0,0 +1,119 @@
+package wrapngo
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/cenkalti/backoff/v4"
+	"github.com/packethost/packngo"
+	"k8s.io/klog/v2"
+)
+
+// wrap a given fn in some reliability-increasing duct tape: context support and
+// exponential backoff retries for intermittent connectivity issues. This allows
+// us to use packngo code instead of writing our own API stub for Equinix Metal.
+//
+// The given fn will be retried until it returns a 'permanent' Equinix error (see
+// isPermanentEquinixError) or the given context expires. Additionally, fn will
+// be called with a brand new packngo client tied to the context of the wrap
+// call. Finally, the given client will also have some logging middleware
+// attached to it which can be activated by setting verbosity 5 (or greater) on
+// this file.
+//
+// The wrapped fn can be either just a plain packngo method or some complicated
+// idempotent logic, as long as it cooperates with the above contract.
+func wrap[U any](ctx context.Context, cl *client, fn func(*packngo.Client) (U, error)) (U, error) {
+	var zero U
+	select {
+	case cl.serializer <- struct{}{}:
+	case <-ctx.Done():
+		return zero, ctx.Err()
+	}
+	defer func() {
+		<-cl.serializer
+	}()
+
+	bc := backoff.WithContext(cl.o.BackOff(), ctx)
+	pngo, err := cl.clientForContext(ctx)
+	if err != nil {
+		// Generally this shouldn't happen other than with programming errors, so we
+		// don't back this off.
+		return zero, fmt.Errorf("could not crate equinix client: %w", err)
+	}
+
+	var res U
+	err = backoff.Retry(func() error {
+		res, err = fn(pngo)
+		if isPermanentEquinixError(err) {
+			return backoff.Permanent(err)
+		}
+		return err
+	}, bc)
+	if err != nil {
+		return zero, err
+	}
+	return res, nil
+}
+
+type injectContextRoundTripper struct {
+	ctx      context.Context
+	original http.RoundTripper
+}
+
+func (r *injectContextRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+	klog.V(5).Infof("Request -> %v", req.URL.String())
+	res, err := r.original.RoundTrip(req.WithContext(r.ctx))
+	klog.V(5).Infof("Response <- %v", res.Status)
+	return res, err
+}
+
+func (c *client) clientForContext(ctx context.Context) (*packngo.Client, error) {
+	httpcl := &http.Client{
+		Transport: &injectContextRoundTripper{
+			ctx:      ctx,
+			original: http.DefaultTransport,
+		},
+	}
+	return packngo.NewClient(packngo.WithAuth(c.username, c.token), packngo.WithHTTPClient(httpcl))
+}
+
+// httpStatusCode extracts the status code from error values returned by
+// packngo methods.
+func httpStatusCode(err error) int {
+	var er *packngo.ErrorResponse
+	if err != nil && errors.As(err, &er) {
+		return er.Response.StatusCode
+	}
+	return -1
+}
+
+// IsNotFound returns true if the given error is an Equinix packngo/wrapngo 'not
+// found' error.
+func IsNotFound(err error) bool {
+	return httpStatusCode(err) == http.StatusNotFound
+}
+
+func isPermanentEquinixError(err error) bool {
+	// Invalid argument/state errors from wrapping.
+	if errors.Is(err, ErrRaceLost) {
+		return true
+	}
+	if errors.Is(err, ErrNoReservationProvided) {
+		return true
+	}
+	// Real errors returned from equinix.
+	st := httpStatusCode(err)
+	switch st {
+	case http.StatusUnauthorized:
+		return true
+	case http.StatusForbidden:
+		return true
+	case http.StatusNotFound:
+		return true
+	case http.StatusUnprocessableEntity:
+		return true
+	}
+	return false
+}