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