From 9683a561e5c26541036978ca89e7162cac1795c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Tue, 8 Aug 2023 16:31:40 +0200 Subject: [PATCH] app/obolapi: create cluster launchpad URL (#2518) Add the `LaunchpadURLForLock` method, which generates a Launchpad cluster dashboard URL for a given cluster lock. Reference this method in `dkg` and `create cluster` commands so that at the end of those processes, if the user has specified `--publish`, the Launchpad dashboard URL will be printed on the console. category: feature ticket: #2423 Closes https://github.com/ObolNetwork/charon/issues/2423. --- app/obolapi/api.go | 55 +++++++++++++++++++++++++++++++---------- app/obolapi/api_test.go | 49 ++++++++++++++++++++++++++++++++++-- cmd/createcluster.go | 30 ++++++++++++++++------ dkg/dkg.go | 24 +++++++++++++----- 4 files changed, 130 insertions(+), 28 deletions(-) diff --git a/app/obolapi/api.go b/app/obolapi/api.go index 40c88608b..02ef3d18e 100644 --- a/app/obolapi/api.go +++ b/app/obolapi/api.go @@ -5,6 +5,7 @@ package obolapi import ( "bytes" "context" + "fmt" "io" "net/http" "net/url" @@ -15,11 +16,21 @@ import ( "github.com/obolnetwork/charon/cluster" ) +const ( + // launchpadReturnPathFmt is the URL path format string at which one can find details for a given cluster lock hash. + launchpadReturnPathFmt = "/lock/0x%X/launchpad" +) + // New returns a new Client. -func New(url string) Client { - return Client{ - baseURL: url, +func New(urlStr string) (Client, error) { + _, err := url.ParseRequestURI(urlStr) // check that urlStr is valid + if err != nil { + return Client{}, errors.Wrap(err, "could not parse Obol API URL") } + + return Client{ + baseURL: urlStr, + }, nil } // Client is the REST client for obol-api requests. @@ -27,27 +38,31 @@ type Client struct { baseURL string // Base obol-api URL } +// url returns a *url.URL from the baseURL stored in c. +// Will panic if somehow c.baseURL got corrupted, and it's not a valid URL anymore. +func (c Client) url() *url.URL { + baseURL, err := url.ParseRequestURI(c.baseURL) + if err != nil { + panic(errors.Wrap(err, "could not parse Obol API URL, this should never happen")) + } + + return baseURL +} + // PublishLock posts the lockfile to obol-api. func (c Client) PublishLock(ctx context.Context, lock cluster.Lock) error { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - addr, err := url.JoinPath(c.baseURL, "lock") - if err != nil { - return errors.Wrap(err, "invalid address") - } - - url, err := url.Parse(addr) - if err != nil { - return errors.Wrap(err, "invalid endpoint") - } + addr := c.url() + addr.Path = "lock" b, err := lock.MarshalJSON() if err != nil { return errors.Wrap(err, "marshal lock") } - err = httpPost(ctx, url, b) + err = httpPost(ctx, addr, b) if err != nil { return err } @@ -55,6 +70,20 @@ func (c Client) PublishLock(ctx context.Context, lock cluster.Lock) error { return nil } +// LaunchpadURLForLock returns the Launchpad cluster dashboard page for a given lock, on the given +// Obol API client. +func (c Client) LaunchpadURLForLock(lock cluster.Lock) string { + lURL := c.url() + + lURL.Path = launchpadURLPath(lock) + + return lURL.String() +} + +func launchpadURLPath(lock cluster.Lock) string { + return fmt.Sprintf(launchpadReturnPathFmt, lock.LockHash) +} + func httpPost(ctx context.Context, url *url.URL, b []byte) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewReader(b)) if err != nil { diff --git a/app/obolapi/api_test.go b/app/obolapi/api_test.go index 2e11be9b6..275eb8571 100644 --- a/app/obolapi/api_test.go +++ b/app/obolapi/api_test.go @@ -3,11 +3,13 @@ package obolapi_test import ( + "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/stretchr/testify/require" @@ -45,8 +47,51 @@ func TestLockPublish(t *testing.T) { lock, _, _ := cluster.NewForT(t, 3, 3, 4, 0, opts...) - cl := obolapi.New(srv.URL) - err := cl.PublishLock(ctx, lock) + cl, err := obolapi.New(srv.URL) require.NoError(t, err) + err = cl.PublishLock(ctx, lock) + require.NoError(t, err) + }) +} + +func TestURLParsing(t *testing.T) { + t.Run("invalid url", func(t *testing.T) { + cl, err := obolapi.New("badURL") + require.Error(t, err) + require.Empty(t, cl) + }) + + t.Run("http url", func(t *testing.T) { + cl, err := obolapi.New("http://unsafe.today") + require.NoError(t, err) + require.NotEmpty(t, cl) + }) + + t.Run("https url", func(t *testing.T) { + cl, err := obolapi.New("https://safe.today") + require.NoError(t, err) + require.NotEmpty(t, cl) + }) +} + +func TestLaunchpadDashURL(t *testing.T) { + t.Run("produced url is what we expect", func(t *testing.T) { + cl, err := obolapi.New("https://safe.today") + require.NoError(t, err) + require.NotEmpty(t, cl) + + result := cl.LaunchpadURLForLock(cluster.Lock{LockHash: bytes.Repeat([]byte{0x42}, 32)}) + + require.NotEmpty(t, result) + + parsedRes, err := url.ParseRequestURI(result) + require.NoError(t, err) + + require.Equal(t, "safe.today", parsedRes.Host) + require.Equal( + t, + "/lock/0x4242424242424242424242424242424242424242424242424242424242424242/launchpad", + parsedRes.Path, + ) }) } diff --git a/cmd/createcluster.go b/cmd/createcluster.go index 950e2d50e..10f52a57f 100644 --- a/cmd/createcluster.go +++ b/cmd/createcluster.go @@ -257,9 +257,14 @@ func runCreateCluster(ctx context.Context, w io.Writer, conf clusterConfig) erro lock.NodeSignatures = append(lock.NodeSignatures, nodeSig) } + // dashboardURL is the Launchpad dashboard url for a given lock file. + // If empty, either conf.Publish wasn't specified or there was a processing error in publishing + // the generated lock file. + var dashboardURL string + // Write cluster-lock file if conf.Publish { - if err = writeLockToAPI(ctx, conf.PublishAddr, lock); err != nil { + if dashboardURL, err = writeLockToAPI(ctx, conf.PublishAddr, lock); err != nil { log.Warn(ctx, "Couldn't publish lock file to Obol API", err) } } @@ -272,7 +277,15 @@ func runCreateCluster(ctx context.Context, w io.Writer, conf clusterConfig) erro writeWarning(w) } - return writeOutput(w, conf.SplitKeys, conf.ClusterDir, numNodes, keysToDisk) + if err := writeOutput(w, conf.SplitKeys, conf.ClusterDir, numNodes, keysToDisk); err != nil { + return err + } + + if dashboardURL != "" { + log.Info(ctx, fmt.Sprintf("You can find your newly-created cluster dashboard here: %s", dashboardURL)) + } + + return nil } // validateCreateConfig returns an error if any of the provided config parameters are invalid. @@ -943,17 +956,20 @@ func randomHex64() (string, error) { return hex.EncodeToString(b), nil } -// writeLockToAPI posts the lock file to obol-api. -func writeLockToAPI(ctx context.Context, publishAddr string, lock cluster.Lock) error { - cl := obolapi.New(publishAddr) +// writeLockToAPI posts the lock file to obol-api and returns the Launchpad dashboard URL. +func writeLockToAPI(ctx context.Context, publishAddr string, lock cluster.Lock) (string, error) { + cl, err := obolapi.New(publishAddr) + if err != nil { + return "", err + } if err := cl.PublishLock(ctx, lock); err != nil { - return err + return "", err } log.Info(ctx, "Published lock file", z.Str("addr", publishAddr)) - return nil + return cl.LaunchpadURLForLock(lock), nil } // validateAddresses checks if we have sufficient addresses. It also fills addresses slices if only one is provided. diff --git a/dkg/dkg.go b/dkg/dkg.go index 4e3b07628..48d3e8177 100644 --- a/dkg/dkg.go +++ b/dkg/dkg.go @@ -304,8 +304,13 @@ func Run(ctx context.Context, conf Config) (err error) { log.Debug(ctx, "Saved keyshares to disk") } + // dashboardURL is the Launchpad dashboard url for a given lock file. + // If empty, either conf.Publish wasn't specified or there was a processing error in publishing + // the generated lock file. + var dashboardURL string + if conf.Publish { - if err = writeLockToAPI(ctx, conf.PublishAddr, lock); err != nil { + if dashboardURL, err = writeLockToAPI(ctx, conf.PublishAddr, lock); err != nil { log.Warn(ctx, "Couldn't publish lock file to Obol API", err) } } @@ -329,6 +334,10 @@ func Run(ctx context.Context, conf Config) (err error) { log.Info(ctx, "Successfully completed DKG ceremony 🎉") + if dashboardURL != "" { + log.Info(ctx, fmt.Sprintf("You can find your newly-created cluster dashboard here: %s", dashboardURL)) + } + return nil } @@ -966,17 +975,20 @@ func createDistValidators(shares []share, depositDatas []eth2p0.DepositData, val return dvs, nil } -// writeLockToAPI posts the lock file to obol-api. -func writeLockToAPI(ctx context.Context, publishAddr string, lock cluster.Lock) error { - cl := obolapi.New(publishAddr) +// writeLockToAPI posts the lock file to obol-api and returns the Launchpad dashboard URL. +func writeLockToAPI(ctx context.Context, publishAddr string, lock cluster.Lock) (string, error) { + cl, err := obolapi.New(publishAddr) + if err != nil { + return "", err + } if err := cl.PublishLock(ctx, lock); err != nil { - return err + return "", err } log.Debug(ctx, "Published lock file to api") - return nil + return cl.LaunchpadURLForLock(lock), nil } // validateKeymanagerFlags returns an error if one keymanager flag is present but the other is not.