diff --git a/go.work.sum b/go.work.sum index b66d6fc..732a963 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,10 +1,12 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/btcsuite/btcd v0.22.0-beta/go.mod h1:9n5ntfhhHQBIhUvlhDvD3Qg6fRUj4jkN0VB8L8svzOA= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= diff --git a/node-registrar/client/README.md b/node-registrar/client/README.md new file mode 100644 index 0000000..6220604 --- /dev/null +++ b/node-registrar/client/README.md @@ -0,0 +1,296 @@ +# ThreeFold Grid Node Registrar Client + +A Go client for interacting with the ThreeFold Grid Node Registrar service. Facilitates node registration and management on the ThreeFold Grid. + +## Overview + +The Node Registrar Client enables communication with the ThreeFold Grid's node registration service. It provides methods to: + +* Register nodes +* Manage node metadata +* Retrieve node information +* Delete node registrations + +## Features + +### Version + +* **Get Zos Version**: Loads zos version for the current network +* **Set Zos Version**: Set zos version to specific version (can only be done by the network admin) + +### Accounts + +* **Create Account**: Create new account on the registrar with uniqe key. +* **Update Account**: Update the account configuration (relays & rmbEncKey). +* **Ensure Account**: Ensures that an account is created with specific seed/mnemonic. +* **Get Account**: Get an account using either its twin\_id or its public\_key. + +### Farms + +* **Create Farm**: Create new farm on the registrar with uniqe name. +* **update Farm**: Update farm configuration (farm\_id, dedicated). +* **Get Farm**: Get a farm using its farm\_id. + +### Node + +* **Register Node**: Register physical/virtual nodes with on TFGrid. +* **Update Node**: Update node configuration (farm\_id, interfaces, resources, location, secure\_boot, virtualized). +* **Get Node**: Fetch registered node details using (node\_id, twin\_id, farm\_id). +* **Update Node Uptime**: Update node Uptime. + +### API Methods + +#### Version Operations + +| Method | Description | Parameters | Returns | +|-----------------|----------------------------------|------------------------------------|---------------------| +| GetZosVersion | Get current zos version | None | (ZosVersion, error) | +| SetZosVersion | Update zos version (admin-only) | version string, safeToUpgrade bool | error | + +#### Account Management + +| Method | Description | Parameters | Returns | +|----------------|---------------------------|-----------------------------------|------------------| +| CreateAccount | Create new account | relays []string, rmbEncKey string | (Account, error) | +| EnsureAccount | Create account if missing | relays []string, rmbEncKey string | (Account, error) | +| GetAccount | Get account by twin ID | twinID uint64 | (Account, error) | +| GetAccountByPK | Get account by public key | publicKey string | (Account, error) | +| UpdateAccount | Modify account config | ...UpdateOption | error | + +#### Farm Operations + +| Method | Description | Parameters | Returns | +|-------------|------------------------|--------------------------------------------|-----------------| +| CreateFarm | Register new farm | name string, twinID uint64, dedicated bool | (uint64, error) | +| UpdateFarm | Modify farm properties | farmID uint64, ...UpdateOption | error | +| GetFarm | Get farm by ID | farmID uint64 | (Farm, error) | +| ListFarms | List farms | ...ListOption | ([]Farm, error) | + +#### Node Operations + +| Method | Description | Parameters | Returns | +|-----------------|-----------------------|------------------------------------------------|-----------------| +| RegisterNode | Register new node | farmID uint64, twinID uint64, interfaces []Interface, location Location, resources Resources, serial string, secureBoot bool, virtualized bool | (uint64, error) | +| UpdateNode | Modify node config | ...UpdateOption | error | +| GetNode | Get node by node ID | nodeID uint64 | (Node, error) | +| GetNodeByTwinID | Get node by twin ID | twinID uint64 | (Node, error) | +| ListNodes | List nodes | ...ListOption | ([]Node, error) | +| ReportUptime | Submit uptime metrics | report UptimeReport | error | + +## Installation + +```bash +go get github.com/threefoldtech/tfgrid4-sdk-go/node-registrar/client +``` + +## Usage + +### Initialize Client + +```go +import ( + "github.com/threefoldtech/tfgrid4-sdk-go/node-registrar/client" +) + +func main() { + registrarURL := "https://registrar.dev4.grid.tf/v1" + + // Generate 128-bit entropy (12-word mnemonic) + entropy, err := bip39.NewEntropy(128) + if err != nil { + panic(err) + } + + // Generate mnemonic from entropy + mnemonic, err = bip39.NewMnemonic(entropy) + if err != nil { + panic(err) + } + fmt.Println("New Mnemonic:", mnemonic) + + cli, err := client.NewRegistrarClient(registrarURL, mnemonic) + if err != nil { + panic(err) + } +} +``` + +### Get Zos Version + +```go + version, err := c.GetZosVersion() + if err != nil { + log.Fatal().Err(err).Msg("failed to set registrar version") + } + + log.Info().Msgf("%s version is: %+v", network, version) +``` + +### Set Zos Version (ONLY for network admin) + +```go + err := c.SetZosVersion("v0.1.8", true) + if err != nil { + log.Fatal().Err(err).Msg("failed to set registrar version") + } + + log.Info().Msg("version is updated successfully") +``` + +### Create Account + +```go + account, err := c.CreateAccount(relays, rmbEncKey) + if err != nil { + log.Fatal().Err(err).Msg("failed to create new account on registrar") + } + +log.Info().Uint64("twinID", account.TwinID).Msg("account created successfully") + +``` + +### Get Account + +#### Get Account By Public Key + +```go + account, err := c.GetAccountByPK(pk) + if err != nil { + log.Fatal().Err(err).Msg("failed to get account from registrar") + } + log.Info().Any("account", account).Send() +``` + +#### Get Account By Twin ID + +```go + account, err := c.GetAccount(id) + if err != nil { + log.Fatal().Err(err).Msg("failed to get account from registrar") + } + log.Info().Any("account", account).Send() + +``` + +### Update Account + +```go + err := c.UpdateAccount(client.UpdateAccountWithRelays(relays), client.UpdateAccountWithRMBEncKey(rmbEncKey)) + if err != nil { + log.Fatal().Err(err).Msg("failed to get account from registrar") + } + log.Info().Msg("account updated successfully") +``` + +### Ensure Account + +```go + account, err := c.EnsureAccount(relays, rmbEncKey) + if err != nil { + log.Fatal().Err(err).Msg("failed to ensure account account from registrar") + } + log.Info().Any("account", account).Send() +``` + +### Create Farm + +```go + id, err := c.CreateFarm(farmName, twinID, false) + if err != nil { + log.Fatal().Err(err).Msg("failed to create new farm on registrar") + } + + log.Info().Uint64("farmID", id).Msg("farm created successfully") +``` + +### Get Farm + +```go + farm, err := c.GetFarm(id) + if err != nil { + log.Fatal().Err(err).Msg("failed to get farm from registrar") + } + log.Info().Any("farm", farm).Send() +``` + +### List Farms + +```go + farms, err := c.ListFarms(ListFarmWithName(name)) + if err != nil { + log.Fatal().Err(err).Msg("failed to list farms from registrar") + } + log.Info().Any("farm", farms[0]).Send() +``` + +### Update Farm + +```go + err := c.UpdateFarm(farmID, client.UpdateFarmWithName(name)) + if err != nil { + log.Fatal().Err(err).Msg("failed to get farm from registrar") + } + log.Info().Msg("farm updated successfully") +``` + +### Register a Node + +```go + id, err := c.RegisterNode(farmID, twinID, interfaces, location, resources, serialNumber, secureBoot, virtualized) + if err != nil { + log.Fatal().Err(err).Msg("failed to register a new node on registrar") + } + log.Info().Uint64("nodeID", id).Msg("node registered successfully") +``` + +### Get Node + +#### Get Node With Node ID + +```go + node, err := c.GetNode(id) + if err != nil { + log.Fatal().Err(err).Msg("failed to get node from registrar") + } + log.Info().Any("node", node).Send() +``` + +#### Get Node With Twin ID + +```go + node, err := c.GetNodeByTwinID(id) + if err != nil { + log.Fatal().Err(err).Msg("failed to get node from registrar") + } + log.Info().Any("node", node).Send() +``` + +### List Nodes + +```go + nodes, err := c.ListNodes(client.ListNodesWithFarmID(id)) + if err != nil { + log.Fatal().Err(err).Msg("failed to list nodes from registrar") + } + log.Info().Any("node", node[0]).Send() +``` + +### Update Node + +```go + err := c.UpdateNode(client.UpdateNodesWithLocation(location)) + if err != nil { + log.Fatal().Err(err).Msg("failed to update node location on the registrar") + } + log.Info().Msg("node updated successfully") +``` + +### Update Node Uptime + +```go + err := c.ReportUptime(report) + if err != nil { + log.Fatal().Err(err).Msg("failed to update node uptime in the registrar") + } + log.Info().Msg("node uptime is updated successfully") +``` diff --git a/node-registrar/client/account.go b/node-registrar/client/account.go new file mode 100644 index 0000000..808fc52 --- /dev/null +++ b/node-registrar/client/account.go @@ -0,0 +1,296 @@ +package client + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/pkg/errors" + "github.com/vedhavyas/go-subkey/v2" +) + +var ErrorAccountNotFound = fmt.Errorf("failed to get requested account from node registrar") + +// CreateAccount create new account on the registrar with uniqe mnemonic. +func (c *RegistrarClient) CreateAccount(relays []string, rmbEncKey string) (account Account, mnemonic string, err error) { + return c.createAccount(relays, rmbEncKey) +} + +// GetAccount get an account using either its twinID +func (c *RegistrarClient) GetAccount(twinID uint64) (account Account, err error) { + return c.getAccount(twinID) +} + +// GetAccountByPK get an account using either its its publicKey. +func (c *RegistrarClient) GetAccountByPK(publicKey []byte) (account Account, err error) { + return c.getAccountByPK(publicKey) +} + +// UpdateAccount update the account configuration (relays or rmbEncKey). +func (c *RegistrarClient) UpdateAccount(opts ...UpdateAccountOpts) (err error) { + return c.updateAccount(opts) +} + +type accountCfg struct { + relays []string + rmbEncKey string +} + +type ( + UpdateAccountOpts func(*accountCfg) +) + +// UpdateAccountWithRelays update the account relays +func UpdateAccountWithRelays(relays []string) UpdateAccountOpts { + return func(n *accountCfg) { + n.relays = relays + } +} + +// UpdateAccountWithRMBEncKey update the account rmb encryption key +func UpdateAccountWithRMBEncKey(rmbEncKey string) UpdateAccountOpts { + return func(n *accountCfg) { + n.rmbEncKey = rmbEncKey + } +} + +// EnsureAccount ensures that an account is created with specific seed/mnemonic. +func (c *RegistrarClient) EnsureAccount(relays []string, rmbEncKey string) (account Account, err error) { + return c.ensureAccount(relays, rmbEncKey) +} + +func (c *RegistrarClient) createAccount(relays []string, rmbEncKey string) (account Account, mnemonic string, err error) { + url, err := url.JoinPath(c.baseURL, "accounts") + if err != nil { + return account, mnemonic, errors.Wrap(err, "failed to construct registrar url") + } + + var keyPair subkey.KeyPair + if len(c.mnemonic) != 0 { + mnemonic = c.mnemonic + keyPair, err = parseKeysFromMnemonicOrSeed(c.mnemonic) + } else { + mnemonic, keyPair, err = generateNewMnemonic() + } + if err != nil { + return account, mnemonic, err + } + + c.keyPair = keyPair + c.mnemonic = mnemonic + + publicKeyBase64 := base64.StdEncoding.EncodeToString(c.keyPair.Public()) + + timestamp := time.Now().Unix() + challenge := []byte(fmt.Sprintf("%d:%v", timestamp, publicKeyBase64)) + signature, err := keyPair.Sign(challenge) + if err != nil { + return account, mnemonic, errors.Wrap(err, "failed to sign account creation request") + } + + data := map[string]any{ + "public_key": base64.StdEncoding.EncodeToString(c.keyPair.Public()), + "signature": base64.StdEncoding.EncodeToString(signature), + "timestamp": timestamp, + "rmb_enc_key": rmbEncKey, + "relays": relays, + } + + var body bytes.Buffer + err = json.NewEncoder(&body).Encode(data) + if err != nil { + return account, mnemonic, errors.Wrap(err, "failed to parse request body") + } + + resp, err := c.httpClient.Post(url, "application/json", &body) + if err != nil { + return account, mnemonic, errors.Wrap(err, "failed to send request to the registrar") + } + + if resp.StatusCode != http.StatusCreated { + err = parseResponseError(resp.Body) + return account, mnemonic, errors.Wrapf(err, "failed to create account with status %s", resp.Status) + } + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&account) + + c.twinID = account.TwinID + return +} + +func (c *RegistrarClient) getAccount(id uint64) (account Account, err error) { + url, err := url.JoinPath(c.baseURL, "accounts") + if err != nil { + return account, errors.Wrap(err, "failed to construct registrar url") + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return + } + + q := req.URL.Query() + q.Add("twin_id", fmt.Sprint(id)) + req.URL.RawQuery = q.Encode() + + resp, err := c.httpClient.Do(req) + if err != nil { + return + } + + if resp == nil { + return account, errors.New("failed to get account, no response received") + } + + if resp.StatusCode == http.StatusNotFound { + return account, ErrorAccountNotFound + } + + if resp.StatusCode != http.StatusOK { + err = parseResponseError(resp.Body) + return account, errors.Wrapf(err, "failed to get account by twin id with status code %s", resp.Status) + } + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&account) + return +} + +func (c *RegistrarClient) getAccountByPK(pk []byte) (account Account, err error) { + url, err := url.JoinPath(c.baseURL, "accounts") + if err != nil { + return account, errors.Wrap(err, "failed to construct registrar url") + } + + publicKeyBase64 := base64.StdEncoding.EncodeToString(pk) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return account, err + } + + q := req.URL.Query() + q.Add("public_key", publicKeyBase64) + req.URL.RawQuery = q.Encode() + + resp, err := c.httpClient.Do(req) + if err != nil { + return account, err + } + + if resp == nil { + return account, errors.New("no response received") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return account, ErrorAccountNotFound + } + + if resp.StatusCode != http.StatusOK { + err = parseResponseError(resp.Body) + return account, errors.Wrapf(err, "failed to get account by public_key with status code %s", resp.Status) + } + + err = json.NewDecoder(resp.Body).Decode(&account) + + return account, err +} + +func (c *RegistrarClient) updateAccount(opts []UpdateAccountOpts) (err error) { + err = c.ensureTwinID() + if err != nil { + return errors.Wrap(err, "failed to ensure twin id") + } + url, err := url.JoinPath(c.baseURL, "accounts", fmt.Sprint(c.twinID)) + if err != nil { + return errors.Wrap(err, "failed to construct registrar url") + } + + var body bytes.Buffer + data := parseUpdateAccountOpts(opts) + + err = json.NewEncoder(&body).Encode(data) + if err != nil { + return errors.Wrap(err, "failed to parse request body") + } + + req, err := http.NewRequest("PATCH", url, &body) + if err != nil { + return + } + + authHeader, err := c.signRequest(time.Now().Unix()) + if err != nil { + return errors.Wrap(err, "failed to sign request") + } + req.Header.Set("X-Auth", authHeader) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return + } + + if resp == nil { + return errors.New("failed to update account, no response received") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return parseResponseError(resp.Body) + } + + return +} + +func (c *RegistrarClient) ensureAccount(relays []string, rmbEncKey string) (account Account, err error) { + account, err = c.GetAccountByPK(c.keyPair.Public()) + if errors.Is(err, ErrorAccountNotFound) { + account, _, err = c.CreateAccount(relays, rmbEncKey) + } + return account, err +} + +// ensureTwinID ensures that the RegistrarClient is set up properly with a valid public key representing an account on the registrar +func (c *RegistrarClient) ensureTwinID() error { + if c.twinID != 0 { + return nil + } + + twin, err := c.getAccountByPK(c.keyPair.Public()) + if err != nil { + return errors.Wrap(err, "failed to get the account of the node, registrar client was not set up properly") + } + + c.twinID = twin.TwinID + return nil +} + +func parseUpdateAccountOpts(opts []UpdateAccountOpts) map[string]any { + cfg := accountCfg{ + rmbEncKey: "", + relays: []string{}, + } + + for _, opt := range opts { + opt(&cfg) + } + + data := map[string]any{} + + if len(cfg.relays) != 0 { + data["relays"] = cfg.relays + } + + if len(cfg.rmbEncKey) != 0 { + data["rmb_enc_key"] = cfg.rmbEncKey + } + + return data +} diff --git a/node-registrar/client/account_test.go b/node-registrar/client/account_test.go new file mode 100644 index 0000000..798ae4f --- /dev/null +++ b/node-registrar/client/account_test.go @@ -0,0 +1,137 @@ +package client + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateAccount(t *testing.T) { + var request int + var count int + require := require.New(t) + + keyPair, err := parseKeysFromMnemonicOrSeed(testMnemonic) + require.NoError(err) + account.PublicKey = base64.StdEncoding.EncodeToString(keyPair.Public()) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + statusCode, body := serverHandler(r, request, count, require) + w.WriteHeader(statusCode) + _, err := w.Write(body) + require.NoError(err) + count++ + })) + defer testServer.Close() + + baseURL, err := url.JoinPath(testServer.URL, "v1") + require.NoError(err) + + request = newClientWithNoAccount + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + t.Run("test create account created successfully", func(t *testing.T) { + request = createAccountStatusCreated + result, _, err := c.CreateAccount(account.Relays, account.RMBEncKey) + require.NoError(err) + require.Equal(account, result) + }) +} + +func TestUpdateAccount(t *testing.T) { + var request int + var count int + require := require.New(t) + + keyPair, err := parseKeysFromMnemonicOrSeed(testMnemonic) + require.NoError(err) + account.PublicKey = base64.StdEncoding.EncodeToString(keyPair.Public()) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + statusCode, body := serverHandler(r, request, count, require) + w.WriteHeader(statusCode) + _, err := w.Write(body) + require.NoError(err) + + count++ + })) + defer testServer.Close() + + baseURL, err := url.JoinPath(testServer.URL, "v1") + require.NoError(err) + + t.Run("test update account updated successfully", func(t *testing.T) { + count = 0 + request = newClientWithAccountNoNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + + require.NoError(err) + require.Equal(c.twinID, account.TwinID) + require.Equal(c.keyPair, keyPair) + + request = updateAccountWithStatusOK + relays := []string{"relay1"} + err = c.UpdateAccount(UpdateAccountWithRelays(relays)) + require.NoError(err) + }) + + t.Run("test update account account not found", func(t *testing.T) { + request = newClientWithNoAccount + c, err := NewRegistrarClient(baseURL, testMnemonic) + + require.NoError(err) + require.Equal(c.keyPair, keyPair) + + request = updateAccountWithNoAccount + relays := []string{"relay1"} + err = c.UpdateAccount(UpdateAccountWithRelays(relays)) + require.Error(err) + }) +} + +func TestGetAccount(t *testing.T) { + var request int + var count int + require := require.New(t) + + keyPair, err := parseKeysFromMnemonicOrSeed(testMnemonic) + require.NoError(err) + account.PublicKey = base64.StdEncoding.EncodeToString(keyPair.Public()) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + statusCode, body := serverHandler(r, request, count, require) + w.WriteHeader(statusCode) + _, err := w.Write(body) + require.NoError(err) + count++ + })) + defer testServer.Close() + + baseURL, err := url.JoinPath(testServer.URL, "v1") + require.NoError(err) + + count = 0 + request = newClientWithAccountNoNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + require.Equal(account.TwinID, c.twinID) + require.Equal(keyPair, c.keyPair) + + t.Run("test get account with id account not found", func(t *testing.T) { + request = getAccountWithIDStatusNotFount + _, err := c.GetAccount(account.TwinID) + require.Error(err) + }) + + t.Run("test get account account not found", func(t *testing.T) { + request = getAccountWithIDStatusOK + acc, err := c.GetAccount(account.TwinID) + require.NoError(err) + require.Equal(account, acc) + }) +} diff --git a/node-registrar/client/client.go b/node-registrar/client/client.go new file mode 100644 index 0000000..3da00ef --- /dev/null +++ b/node-registrar/client/client.go @@ -0,0 +1,57 @@ +package client + +import ( + "net/http" + + "github.com/pkg/errors" + "github.com/vedhavyas/go-subkey" +) + +type RegistrarClient struct { + httpClient http.Client + baseURL string + keyPair subkey.KeyPair + mnemonic string + nodeID uint64 + twinID uint64 +} + +// NewRegistrarClient creates a new client with optional seed or mnemonic +func NewRegistrarClient(baseURL string, mnemonicOrSeed ...string) (cli RegistrarClient, err error) { + client := http.DefaultClient + + cli = RegistrarClient{ + httpClient: *client, + baseURL: baseURL, + } + + if len(mnemonicOrSeed) == 0 { + return cli, nil + } + + keyPair, err := parseKeysFromMnemonicOrSeed(mnemonicOrSeed[0]) + if err != nil { + return cli, errors.Wrapf(err, "Failed to derive key pair from mnemonic/seed phrase %s", mnemonicOrSeed[0]) + } + + cli.keyPair = keyPair + cli.mnemonic = mnemonicOrSeed[0] + + account, err := cli.GetAccountByPK(keyPair.Public()) + if errors.Is(err, ErrorAccountNotFound) { + return cli, nil + } else if err != nil { + return cli, errors.Wrap(err, "failed to get account with public key") + } + + cli.twinID = account.TwinID + node, err := cli.GetNodeByTwinID(account.TwinID) + if errors.Is(err, ErrorNodeNotFound) { + return cli, nil + } else if err != nil { + return cli, errors.Wrapf(err, "failed to get node with twin id %d", account.TwinID) + } + + cli.nodeID = node.NodeID + return +} diff --git a/node-registrar/client/client_test.go b/node-registrar/client/client_test.go new file mode 100644 index 0000000..0a2a973 --- /dev/null +++ b/node-registrar/client/client_test.go @@ -0,0 +1,63 @@ +package client + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewRegistrarClient(t *testing.T) { + var request int + var count int + require := require.New(t) + + keyPair, err := parseKeysFromMnemonicOrSeed(testMnemonic) + require.NoError(err) + account.PublicKey = base64.StdEncoding.EncodeToString(keyPair.Public()) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + statusCode, body := serverHandler(r, request, count, require) + w.WriteHeader(statusCode) + _, err := w.Write(body) + require.NoError(err) + count++ + })) + defer testServer.Close() + + baseURL, err := url.JoinPath(testServer.URL, "v1") + require.NoError(err) + + t.Run("test new registrar client with no account", func(t *testing.T) { + count = 0 + request = newClientWithNoAccount + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + require.Equal(uint64(0), c.twinID) + require.Equal(uint64(0), c.nodeID) + require.Equal(keyPair, c.keyPair) + }) + + t.Run("test new registrar client with account and no node", func(t *testing.T) { + count = 0 + request = newClientWithAccountNoNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + require.Equal(account.TwinID, c.twinID) + require.Equal(uint64(0), c.nodeID) + require.Equal(keyPair, c.keyPair) + }) + + t.Run("test new registrar client with account and node", func(t *testing.T) { + count = 0 + request = newClientWithAccountAndNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + require.Equal(account.TwinID, c.twinID) + require.Equal(nodeID, c.nodeID) + require.Equal(keyPair, c.keyPair) + }) +} diff --git a/node-registrar/client/farm.go b/node-registrar/client/farm.go new file mode 100644 index 0000000..e416fd4 --- /dev/null +++ b/node-registrar/client/farm.go @@ -0,0 +1,374 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + "unicode" + + "github.com/pkg/errors" +) + +var ErrorFarmNotFound = fmt.Errorf("failed to get requested farm from node registrar") + +// CreateFarm create new farm on the registrar with uniqe name. +func (c *RegistrarClient) CreateFarm(farmName, stellarAddr string, dedicated bool) (farmID uint64, err error) { + return c.createFarm(farmName, stellarAddr, dedicated) +} + +// UpdateFarm update farm configuration (farmName, stellarAddress, dedicated). +func (c *RegistrarClient) UpdateFarm(farmID uint64, opts ...UpdateFarmOpts) (err error) { + return c.updateFarm(farmID, opts) +} + +// GetFarm get a farm using its farmID +func (c *RegistrarClient) GetFarm(farmID uint64) (farm Farm, err error) { + return c.getFarm(farmID) +} + +// ListFarms get a list of farm using ListFarmOpts +func (c *RegistrarClient) ListFarms(opts ...ListFarmOpts) (farms []Farm, err error) { + return c.listFarms(opts...) +} + +type farmCfg struct { + farmName string + farmID uint64 + twinID uint64 + dedicated bool + stellarAddress string + page uint32 + size uint32 +} + +type ( + ListFarmOpts func(*farmCfg) + UpdateFarmOpts func(*farmCfg) +) + +// ListFarmWithName lists farms with farm name +func ListFarmWithName(name string) ListFarmOpts { + return func(n *farmCfg) { + n.farmName = name + } +} + +// ListFarmWithFarmID lists farms with farmID +func ListFarmWithFarmID(id uint64) ListFarmOpts { + return func(n *farmCfg) { + n.farmID = id + } +} + +// ListFarmWithTwinID lists farms with twinID +func ListFarmWithTwinID(id uint64) ListFarmOpts { + return func(n *farmCfg) { + n.twinID = id + } +} + +// ListFarmWithDedicated lists dedicated farms +func ListFarmWithDedicated() ListFarmOpts { + return func(n *farmCfg) { + n.dedicated = true + } +} + +// ListFarmWithPage lists farms in a certain page +func ListFarmWithPage(page uint32) ListFarmOpts { + return func(n *farmCfg) { + n.page = page + } +} + +// ListFarmWithPage lists size number of farms +func ListFarmWithSize(size uint32) ListFarmOpts { + return func(n *farmCfg) { + n.size = size + } +} + +// UpdateFarmWithName update farm name +func UpdateFarmWithName(name string) UpdateFarmOpts { + return func(n *farmCfg) { + n.farmName = name + } +} + +// UpdateFarmWithName set farm status to dedicated +func UpdateFarmWithDedicated() UpdateFarmOpts { + return func(n *farmCfg) { + n.dedicated = true + } +} + +// UpdateFarmWithName set farm status to dedicated +func UpdateFarmWithStellarAddress(address string) UpdateFarmOpts { + return func(n *farmCfg) { + n.stellarAddress = address + } +} + +func (c *RegistrarClient) createFarm(farmName, stellarAddr string, dedicated bool) (farmID uint64, err error) { + if err := c.ensureTwinID(); err != nil { + return farmID, errors.Wrap(err, "failed to ensure twin id") + } + + if err = validateStellarAddress(stellarAddr); err != nil { + return + } + + url, err := url.JoinPath(c.baseURL, "farms") + if err != nil { + return farmID, errors.Wrap(err, "failed to construct registrar url") + } + + data := Farm{ + FarmName: farmName, + TwinID: c.twinID, + Dedicated: dedicated, + StellarAddress: stellarAddr, + } + + var body bytes.Buffer + if err = json.NewEncoder(&body).Encode(data); err != nil { + return farmID, errors.Wrap(err, "failed to encode request body") + } + + req, err := http.NewRequest("POST", url, &body) + if err != nil { + return farmID, errors.Wrap(err, "failed to construct http request to the registrar") + } + + authHeader, err := c.signRequest(time.Now().Unix()) + if err != nil { + return farmID, errors.Wrap(err, "failed to sign request") + } + req.Header.Set("X-Auth", authHeader) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return farmID, errors.Wrap(err, "failed to send request to create farm") + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + err = parseResponseError(resp.Body) + return farmID, fmt.Errorf("failed to create farm with status code %s", resp.Status) + } + + result := struct { + FarmID uint64 `json:"farm_id"` + }{} + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return farmID, errors.Wrap(err, "failed to decode response body") + } + + return result.FarmID, nil +} + +func (c *RegistrarClient) updateFarm(farmID uint64, opts []UpdateFarmOpts) (err error) { + if err = c.ensureTwinID(); err != nil { + return errors.Wrap(err, "failed to ensure twin id") + } + + url, err := url.JoinPath(c.baseURL, "farms", fmt.Sprint(farmID)) + if err != nil { + return errors.Wrap(err, "failed to construct registrar url") + } + + var body bytes.Buffer + data := parseUpdateFarmOpts(opts) + + if stellarAddr, ok := data["stellar_address"]; ok { + if err = validateStellarAddress(stellarAddr.(string)); err != nil { + return + } + } + + if err = json.NewEncoder(&body).Encode(data); err != nil { + return errors.Wrap(err, "failed to encode request body") + } + + req, err := http.NewRequest("PATCH", url, &body) + if err != nil { + return errors.Wrap(err, "failed to construct http request to the registrar") + } + + authHeader, err := c.signRequest(time.Now().Unix()) + if err != nil { + return errors.Wrap(err, "failed to sign request") + } + req.Header.Set("X-Auth", authHeader) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return errors.Wrap(err, "failed to send request to update farm") + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err = parseResponseError(resp.Body) + return errors.Wrapf(err, "failed to create farm with status code %s", resp.Status) + } + + return +} + +func (c *RegistrarClient) getFarm(id uint64) (farm Farm, err error) { + url, err := url.JoinPath(c.baseURL, "farms", fmt.Sprint(id)) + if err != nil { + return farm, errors.Wrap(err, "failed to construct registrar url") + } + resp, err := c.httpClient.Get(url) + if err != nil { + return farm, err + } + + if resp.StatusCode == http.StatusNotFound { + return farm, ErrorFarmNotFound + } + + if resp.StatusCode != http.StatusOK { + err = parseResponseError(resp.Body) + return farm, errors.Wrapf(err, "failed to get farm with status code %s", resp.Status) + } + defer resp.Body.Close() + + if err = json.NewDecoder(resp.Body).Decode(&farm); err != nil { + return farm, err + } + + return +} + +func (c *RegistrarClient) listFarms(opts ...ListFarmOpts) (farms []Farm, err error) { + url, err := url.JoinPath(c.baseURL, "farms") + if err != nil { + return farms, errors.Wrap(err, "failed to construct registrar url") + } + + data := parseListFarmOpts(opts) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return farms, errors.Wrap(err, "failed to construct http request to the registrar") + } + + q := req.URL.Query() + + for key, val := range data { + q.Add(key, fmt.Sprint(val)) + } + req.URL.RawQuery = q.Encode() + + resp, err := c.httpClient.Do(req) + if err != nil { + return farms, errors.Wrap(err, "failed to send request to list farm") + } + + if resp.StatusCode != http.StatusOK { + err = parseResponseError(resp.Body) + return farms, errors.Wrapf(err, "failed to get list farms with status code %s", resp.Status) + } + defer resp.Body.Close() + if err = json.NewDecoder(resp.Body).Decode(&farms); err != nil { + return farms, errors.Wrap(err, "failed to decode response body") + } + + return +} + +func parseListFarmOpts(opts []ListFarmOpts) map[string]any { + cfg := farmCfg{ + farmName: "", + farmID: 0, + twinID: 0, + dedicated: false, + page: 1, + size: 50, + } + + for _, opt := range opts { + opt(&cfg) + } + + data := map[string]any{} + + if len(cfg.farmName) != 0 { + data["farm_name"] = cfg.farmName + } + + if cfg.farmID != 0 { + data["farm_id"] = cfg.farmID + } + + if cfg.twinID != 0 { + data["twin_id"] = cfg.twinID + } + + if cfg.dedicated { + data["dedicated"] = true + } + + data["page"] = cfg.page + data["size"] = cfg.size + + return data +} + +func parseUpdateFarmOpts(opts []UpdateFarmOpts) map[string]any { + cfg := farmCfg{} + + for _, opt := range opts { + opt(&cfg) + } + + data := map[string]any{} + + if len(cfg.farmName) != 0 { + data["farm_name"] = cfg.farmName + } + + if cfg.dedicated { + data["dedicated"] = true + } + + if len(cfg.stellarAddress) != 0 { + data["stellar_address"] = cfg.stellarAddress + } + + return data +} + +// validateStellarAddress ensures that the address is valid stellar address +func validateStellarAddress(stellarAddr string) error { + stellarAddr = strings.TrimSpace(stellarAddr) + if len(stellarAddr) != 56 { + return fmt.Errorf("invalid stellar address %s, address length should be 56 characters", stellarAddr) + } + if stellarAddr[0] != 'G' { + return fmt.Errorf("invalid stellar address %s, address should should start with 'G'", stellarAddr) + } + + if strings.Compare(stellarAddr, strings.ToUpper(stellarAddr)) != 0 { + return fmt.Errorf("invalid stellar address %s, address should be all uppercase", stellarAddr) + } + + // check if not alphanumeric + for _, c := range stellarAddr { + if !unicode.IsLetter(c) && !unicode.IsNumber(c) { + return fmt.Errorf("invalid stellar address %s, address character should be alphanumeric only", stellarAddr) + } + } + return nil +} diff --git a/node-registrar/client/farm_test.go b/node-registrar/client/farm_test.go new file mode 100644 index 0000000..c75efac --- /dev/null +++ b/node-registrar/client/farm_test.go @@ -0,0 +1,134 @@ +package client + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateFarm(t *testing.T) { + var request int + var count int + require := require.New(t) + + keyPair, err := parseKeysFromMnemonicOrSeed(testMnemonic) + require.NoError(err) + account.PublicKey = base64.StdEncoding.EncodeToString(keyPair.Public()) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + statusCode, body := serverHandler(r, request, count, require) + w.WriteHeader(statusCode) + _, err := w.Write(body) + require.NoError(err) + count++ + })) + defer testServer.Close() + + baseURL, err := url.JoinPath(testServer.URL, "v1") + require.NoError(err) + + request = newClientWithAccountNoNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + stellarAddr := "GBB3H4F7N3R26I6XU2V2P5WYJZQ5N7E4FQH6E5D2X3OGJ2KLTGZXQW34" + t.Run("test create farm with status conflict", func(t *testing.T) { + request = createFarmStatusConflict + _, err = c.CreateFarm(farm.FarmName, stellarAddr, farm.Dedicated) + require.Error(err) + }) + + t.Run("test create farm with status ok", func(t *testing.T) { + request = createFarmStatusCreated + result, err := c.CreateFarm(farm.FarmName, stellarAddr, farm.Dedicated) + require.NoError(err) + require.Equal(farm.FarmID, result) + }) +} + +func TestUpdateFarm(t *testing.T) { + var request int + var count int + require := require.New(t) + + keyPair, err := parseKeysFromMnemonicOrSeed(testMnemonic) + require.NoError(err) + account.PublicKey = base64.StdEncoding.EncodeToString(keyPair.Public()) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + statusCode, body := serverHandler(r, request, count, require) + w.WriteHeader(statusCode) + _, err := w.Write(body) + require.NoError(err) + count++ + })) + defer testServer.Close() + + baseURL, err := url.JoinPath(testServer.URL, "v1") + require.NoError(err) + + t.Run("test update farm with status unauthorzed", func(t *testing.T) { + request = newClientWithNoAccount + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + request = updateFarmWithStatusUnauthorized + err = c.UpdateFarm(farmID, UpdateFarmWithName("notFreeFarm")) + require.Error(err) + }) + + t.Run("test update farm with status ok", func(t *testing.T) { + count = 0 + request = newClientWithAccountNoNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + request = updateFarmWithStatusOK + err = c.UpdateFarm(farmID, UpdateFarmWithName("notFreeFarm")) + require.NoError(err) + }) +} + +func TestGetFarm(t *testing.T) { + var request int + var count int + require := require.New(t) + + keyPair, err := parseKeysFromMnemonicOrSeed(testMnemonic) + require.NoError(err) + account.PublicKey = base64.StdEncoding.EncodeToString(keyPair.Public()) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + statusCode, body := serverHandler(r, request, count, require) + w.WriteHeader(statusCode) + _, err := w.Write(body) + require.NoError(err) + count++ + })) + defer testServer.Close() + + baseURL, err := url.JoinPath(testServer.URL, "v1") + require.NoError(err) + + count = 0 + request = newClientWithAccountNoNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + t.Run("test get farm with status not found", func(t *testing.T) { + request = getFarmWithStatusNotfound + _, err = c.GetFarm(farmID) + require.Error(err) + }) + + t.Run("test get farm with status ok", func(t *testing.T) { + request = getFarmWithStatusOK + result, err := c.GetFarm(farmID) + require.NoError(err) + require.Equal(result, farm) + }) +} diff --git a/node-registrar/client/mnemonic.go b/node-registrar/client/mnemonic.go new file mode 100644 index 0000000..ea0ef5b --- /dev/null +++ b/node-registrar/client/mnemonic.go @@ -0,0 +1,46 @@ +package client + +import ( + "github.com/cosmos/go-bip39" + "github.com/pkg/errors" + subkeyEd25519 "github.com/vedhavyas/go-subkey/v2/ed25519" + + "github.com/vedhavyas/go-subkey/v2" +) + +func (c *RegistrarClient) Mnemonic() string { + return c.mnemonic +} + +// parseKeysFromMnemonicOrSeed drives keypair from mnemonic or seed +func parseKeysFromMnemonicOrSeed(mnemonicOrSeed string) (keypair subkey.KeyPair, err error) { + // otherwise drive key pair from seed + keypair, err = subkey.DeriveKeyPair(subkeyEd25519.Scheme{}, mnemonicOrSeed) + if err != nil { + return keypair, errors.Wrapf(err, "failed to drive key pair from seed %s", mnemonicOrSeed) + } + + return keypair, nil +} + +// generateNewMnemonic generates new mnemonic and keypair +func generateNewMnemonic() (mnemonic string, keypair subkey.KeyPair, err error) { + // Generate 128-bit entropy (12-word mnemonic) + entropy, err := bip39.NewEntropy(128) + if err != nil { + return mnemonic, keypair, errors.Wrap(err, "failed to generate entropy") + } + + // Generate mnemonic from entropy + mnemonic, err = bip39.NewMnemonic(entropy) + if err != nil { + return mnemonic, keypair, errors.Wrap(err, "failed to generate mnemonic") + } + + // Drive key pair from mnemonic + keypair, err = subkey.DeriveKeyPair(subkeyEd25519.Scheme{}, mnemonic) + if err != nil { + return mnemonic, keypair, errors.Wrapf(err, "failed to derive key pair from mnemonic phrase %s", mnemonic) + } + return +} diff --git a/node-registrar/client/node.go b/node-registrar/client/node.go new file mode 100644 index 0000000..b2ba5a8 --- /dev/null +++ b/node-registrar/client/node.go @@ -0,0 +1,525 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "reflect" + "time" + + "github.com/pkg/errors" +) + +var ErrorNodeNotFound = fmt.Errorf("failed to get requested node from node registrar") + +// RegisterNode register physical/virtual nodes with on TFGrid. +func (c *RegistrarClient) RegisterNode( + farmID uint64, + twinID uint64, + interfaces []Interface, + location Location, + resources Resources, + serialNumber string, + secureBoot, + virtualized bool, +) (nodeID uint64, err error) { + return c.registerNode(farmID, twinID, interfaces, location, resources, serialNumber, secureBoot, virtualized) +} + +// UpdateNode update node configuration (farmID, interfaces, resources, location, secureBoot, virtualized). +func (c *RegistrarClient) UpdateNode(opts ...UpdateNodeOpts) (err error) { + return c.updateNode(opts) +} + +// ReportUptime update node Uptime. +func (c *RegistrarClient) ReportUptime(report UptimeReport) (err error) { + return c.reportUptime(report) +} + +// GetNode gets registered node details using nodeID +func (c *RegistrarClient) GetNode(id uint64) (node Node, err error) { + return c.getNode(id) +} + +// GetNodeByTwinID gets registered node details using twinID +func (c *RegistrarClient) GetNodeByTwinID(id uint64) (node Node, err error) { + return c.getNodeByTwinID(id) +} + +// ListNodes lists registered nodes details using (nodeID, twinID, farmID). +func (c *RegistrarClient) ListNodes(opts ...ListNodeOpts) (nodes []Node, err error) { + return c.listNodes(opts) +} + +type nodeCfg struct { + nodeID uint64 + farmID uint64 + twinID uint64 + status string + healthy bool + Location Location + Resources Resources + Interfaces []Interface + SecureBoot bool + Virtualized bool + SerialNumber string + UptimeReports []UptimeReport + Approved bool + page uint32 + size uint32 +} + +type ( + ListNodeOpts func(*nodeCfg) + UpdateNodeOpts func(*nodeCfg) +) + +func ListNodesWithNodeID(id uint64) ListNodeOpts { + return func(n *nodeCfg) { + n.nodeID = id + } +} + +func ListNodesWithFarmID(id uint64) ListNodeOpts { + return func(n *nodeCfg) { + n.farmID = id + } +} + +func ListNodesWithStatus(status string) ListNodeOpts { + return func(n *nodeCfg) { + n.status = status + } +} + +func ListNodesWithHealthy() ListNodeOpts { + return func(n *nodeCfg) { + n.healthy = true + } +} + +func ListNodesWithTwinID(id uint64) ListNodeOpts { + return func(n *nodeCfg) { + n.twinID = id + } +} + +func ListNodesWithPage(page uint32) ListNodeOpts { + return func(n *nodeCfg) { + n.page = page + } +} + +func ListNodesWithSize(size uint32) ListNodeOpts { + return func(n *nodeCfg) { + n.size = size + } +} + +func UpdateNodesWithFarmID(id uint64) UpdateNodeOpts { + return func(n *nodeCfg) { + n.farmID = id + } +} + +func UpdateNodesWithInterfaces(interfaces []Interface) UpdateNodeOpts { + return func(n *nodeCfg) { + n.Interfaces = interfaces + } +} + +func UpdateNodesWithLocation(location Location) UpdateNodeOpts { + return func(n *nodeCfg) { + n.Location = location + } +} + +func UpdateNodesWithResources(resources Resources) UpdateNodeOpts { + return func(n *nodeCfg) { + n.Resources = resources + } +} + +func UpdateNodesWithSerialNumber(serialNumbe string) UpdateNodeOpts { + return func(n *nodeCfg) { + n.SerialNumber = serialNumbe + } +} + +func UpdateNodesWithSecureBoot() UpdateNodeOpts { + return func(n *nodeCfg) { + n.SecureBoot = true + } +} + +func UpdateNodesWithVirtualized() UpdateNodeOpts { + return func(n *nodeCfg) { + n.Virtualized = true + } +} + +func UpdateNodeWithStatus(status string) UpdateNodeOpts { + return func(n *nodeCfg) { + n.status = status + } +} + +func UpdateNodeWithHealthy() UpdateNodeOpts { + return func(n *nodeCfg) { + n.healthy = true + } +} + +func (c *RegistrarClient) registerNode( + farmID uint64, + twinID uint64, + interfaces []Interface, + location Location, + resources Resources, + serialNumber string, + secureBoot, + virtualized bool, +) (nodeID uint64, err error) { + err = c.ensureTwinID() + if err != nil { + return nodeID, errors.Wrap(err, "failed to ensure twin id") + } + url, err := url.JoinPath(c.baseURL, "nodes") + if err != nil { + return nodeID, errors.Wrap(err, "failed to construct registrar url") + } + + data := Node{ + FarmID: farmID, + TwinID: twinID, + Location: location, + Resources: resources, + Interfaces: interfaces, + SecureBoot: secureBoot, + Virtualized: virtualized, + SerialNumber: serialNumber, + } + + var body bytes.Buffer + err = json.NewEncoder(&body).Encode(data) + if err != nil { + return nodeID, errors.Wrap(err, "failed to encode request body") + } + + req, err := http.NewRequest("POST", url, &body) + if err != nil { + return nodeID, errors.Wrap(err, "failed to construct http request to the registrar") + } + + authHeader, err := c.signRequest(time.Now().Unix()) + if err != nil { + return nodeID, errors.Wrap(err, "failed to sign request") + } + req.Header.Set("X-Auth", authHeader) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nodeID, errors.Wrap(err, "failed to send request to registrer the node") + } + + if resp == nil { + return 0, errors.New("no response received") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + err = parseResponseError(resp.Body) + return 0, errors.Wrapf(err, "failed to create node on the registrar with status code %s", resp.Status) + } + + result := struct { + NodeID uint64 `json:"node_id"` + }{} + + err = json.NewDecoder(resp.Body).Decode(&result) + + c.nodeID = result.NodeID + return result.NodeID, err +} + +func (c *RegistrarClient) updateNode(opts []UpdateNodeOpts) (err error) { + err = c.ensureNodeID() + if err != nil { + return err + } + + node, err := c.getNode(c.nodeID) + if err != nil { + return err + } + + url, err := url.JoinPath(c.baseURL, "nodes", fmt.Sprint(c.nodeID)) + if err != nil { + return errors.Wrap(err, "failed to construct registrar url") + } + + node = c.parseUpdateNodeOpts(node, opts) + + var body bytes.Buffer + err = json.NewEncoder(&body).Encode(node) + if err != nil { + return errors.Wrap(err, "failed to encode request body") + } + + req, err := http.NewRequest("PATCH", url, &body) + if err != nil { + return errors.Wrap(err, "failed to construct http request to the registrar") + } + + authHeader, err := c.signRequest(time.Now().Unix()) + if err != nil { + return errors.Wrap(err, "failed to sign request") + } + req.Header.Set("X-Auth", authHeader) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return errors.Wrap(err, "failed to send request to update node") + } + + if resp.StatusCode != http.StatusOK { + err = parseResponseError(resp.Body) + return errors.Wrapf(err, "failed to update node with twin id %d with status code %s", c.twinID, resp.Status) + } + defer resp.Body.Close() + + return +} + +func (c *RegistrarClient) reportUptime(report UptimeReport) (err error) { + err = c.ensureNodeID() + if err != nil { + return err + } + + url, err := url.JoinPath(c.baseURL, "nodes", fmt.Sprint(c.nodeID), "uptime") + if err != nil { + return errors.Wrap(err, "failed to construct registrar url") + } + + var body bytes.Buffer + + err = json.NewEncoder(&body).Encode(report) + if err != nil { + return errors.Wrap(err, "failed to encode request body") + } + + req, err := http.NewRequest("POST", url, &body) + if err != nil { + return errors.Wrap(err, "failed to construct http request to the registrar") + } + + authHeader, err := c.signRequest(time.Now().Unix()) + if err != nil { + return errors.Wrap(err, "failed to sign request") + } + req.Header.Set("X-Auth", authHeader) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return errors.Wrap(err, "failed to send request to update uptime of the node") + } + + if resp.StatusCode != http.StatusCreated { + err = parseResponseError(resp.Body) + return errors.Wrapf(err, "failed to update node uptime for node with id %d with status code %s", c.nodeID, resp.Status) + } + defer resp.Body.Close() + + return +} + +func (c *RegistrarClient) getNode(id uint64) (node Node, err error) { + url, err := url.JoinPath(c.baseURL, "nodes", fmt.Sprint(id)) + if err != nil { + return node, errors.Wrap(err, "failed to construct registrar url") + } + + resp, err := c.httpClient.Get(url) + if err != nil { + return + } + + if resp.StatusCode == http.StatusNotFound { + return node, ErrorNodeNotFound + } + + if resp.StatusCode != http.StatusOK { + err = parseResponseError(resp.Body) + return node, errors.Wrapf(err, "failed to get node with status code %s", resp.Status) + } + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&node) + if err != nil { + return + } + + return +} + +func (c *RegistrarClient) getNodeByTwinID(id uint64) (node Node, err error) { + nodes, err := c.ListNodes(ListNodesWithTwinID(id)) + if err != nil { + return + } + + if len(nodes) == 0 { + return node, ErrorNodeNotFound + } + + return nodes[0], nil +} + +func (c *RegistrarClient) listNodes(opts []ListNodeOpts) (nodes []Node, err error) { + url, err := url.JoinPath(c.baseURL, "nodes") + if err != nil { + return nodes, errors.Wrap(err, "failed to construct registrar url") + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nodes, errors.Wrap(err, "failed to construct http request to the registrar") + } + + q := req.URL.Query() + data := parseListNodeOpts(opts) + + for key, val := range data { + q.Add(key, fmt.Sprint(val)) + } + + req.URL.RawQuery = q.Encode() + + resp, err := c.httpClient.Do(req) + if err != nil { + return + } + + if resp == nil { + return nodes, errors.New("no response received") + } + + if resp.StatusCode == http.StatusNotFound { + return nodes, ErrorNodeNotFound + } + + if resp.StatusCode != http.StatusOK { + err = parseResponseError(resp.Body) + return nodes, errors.Wrapf(err, "failed to list nodes with with status code %s", resp.Status) + } + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&nodes) + if err != nil { + return + } + + return nodes, nil +} + +func (c *RegistrarClient) ensureNodeID() error { + if c.nodeID != 0 { + return nil + } + + err := c.ensureTwinID() + if err != nil { + return err + } + + node, err := c.getNodeByTwinID(c.twinID) + if err != nil { + return errors.Wrapf(err, "failed to get the node id, registrar client was set up with a normal account not a node") + } + + c.nodeID = node.NodeID + return nil +} + +func (c *RegistrarClient) parseUpdateNodeOpts(node Node, opts []UpdateNodeOpts) Node { + cfg := nodeCfg{ + farmID: 0, + Location: Location{}, + Resources: Resources{}, + Interfaces: []Interface{}, + SecureBoot: false, + Virtualized: false, + Approved: false, + } + + for _, opt := range opts { + opt(&cfg) + } + + if cfg.farmID != 0 { + node.FarmID = cfg.farmID + } + + if !reflect.DeepEqual(cfg.Location, Location{}) { + node.Location = cfg.Location + } + + if !reflect.DeepEqual(cfg.Resources, Resources{}) { + node.Resources = cfg.Resources + } + + if len(cfg.Interfaces) != 0 { + node.Interfaces = cfg.Interfaces + } + + return node +} + +func parseListNodeOpts(opts []ListNodeOpts) map[string]any { + cfg := nodeCfg{ + nodeID: 0, + twinID: 0, + farmID: 0, + status: "", + healthy: false, + size: 50, + page: 1, + } + + for _, opt := range opts { + opt(&cfg) + } + + data := map[string]any{} + + if cfg.nodeID != 0 { + data["node_id"] = cfg.nodeID + } + + if cfg.twinID != 0 { + data["twin_id"] = cfg.twinID + } + + if cfg.farmID != 0 { + data["farm_id"] = cfg.farmID + } + + if len(cfg.status) != 0 { + data["status"] = cfg.status + } + + if cfg.healthy { + data["healthy"] = cfg.healthy + } + + data["size"] = cfg.size + data["page"] = cfg.page + + return data +} diff --git a/node-registrar/client/node_test.go b/node-registrar/client/node_test.go new file mode 100644 index 0000000..1028054 --- /dev/null +++ b/node-registrar/client/node_test.go @@ -0,0 +1,161 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRegistarNode(t *testing.T) { + var request int + var count int + require := require.New(t) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + statusCode, body := serverHandler(r, request, count, require) + w.WriteHeader(statusCode) + _, err := w.Write(body) + require.NoError(err) + count++ + })) + defer testServer.Close() + + baseURL, err := url.JoinPath(testServer.URL, "v1") + require.NoError(err) + + t.Run("test registar node no account", func(t *testing.T) { + request = newClientWithNoAccount + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + request = registerNodeWithNoAccount + _, err = c.RegisterNode(farmID, twinID, []Interface{}, Location{}, Resources{}, "", false, false) + require.Error(err) + }) + + t.Run("test registar node, node already exist", func(t *testing.T) { + count = 0 + request = newClientWithAccountAndNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + count = 0 + request = registerNodeStatusConflict + _, err = c.RegisterNode(farmID, twinID, []Interface{}, Location{}, Resources{}, "", false, false) + require.Error(err) + }) + + t.Run("test registar node, created successfully", func(t *testing.T) { + count = 0 + request = newClientWithAccountNoNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + count = 0 + request = registerNodeStatusCreated + result, err := c.RegisterNode(farmID, twinID, []Interface{}, Location{}, Resources{}, "", false, false) + require.NoError(err) + require.Equal(nodeID, result) + }) +} + +func TestUpdateNode(t *testing.T) { + var request int + var count int + require := require.New(t) + + // publicKey, privateKey, err := aliceKeys() + // require.NoError(err) + // account.PublicKey = base64.StdEncoding.EncodeToString(publicKey) + // + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + statusCode, body := serverHandler(r, request, count, require) + w.WriteHeader(statusCode) + _, err := w.Write(body) + require.NoError(err) + count++ + })) + defer testServer.Close() + + baseURL, err := url.JoinPath(testServer.URL, "v1") + require.NoError(err) + + request = newClientWithAccountAndNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + t.Run("test update node with status ok", func(t *testing.T) { + count = 0 + request = updateNodeStatusOK + err = c.UpdateNode(UpdateNodesWithFarmID(2)) + require.NoError(err) + }) + + t.Run("test update node uptime", func(t *testing.T) { + request = updateNodeSendUptimeReport + + report := UptimeReport{ + Uptime: 40 * time.Minute, + Timestamp: time.Now(), + } + err = c.ReportUptime(report) + require.NoError(err) + }) +} + +func TestGetNode(t *testing.T) { + var request int + var count int + require := require.New(t) + + // publicKey, privateKey, err := aliceKeys() + // require.NoError(err) + // account.PublicKey = base64.StdEncoding.EncodeToString(publicKey) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + statusCode, body := serverHandler(r, request, count, require) + w.WriteHeader(statusCode) + _, err := w.Write(body) + require.NoError(err) + count++ + })) + defer testServer.Close() + + baseURL, err := url.JoinPath(testServer.URL, "v1") + require.NoError(err) + + request = newClientWithAccountAndNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + t.Run("test get node status not found", func(t *testing.T) { + request = getNodeWithIDStatusNotFound + _, err = c.GetNode(nodeID) + require.Error(err) + }) + + t.Run("test get node, status ok", func(t *testing.T) { + request = getNodeWithIDStatusOK + result, err := c.GetNode(nodeID) + require.NoError(err) + require.Equal(node, result) + }) + + t.Run("test get node with twin id", func(t *testing.T) { + request = getNodeWithTwinID + result, err := c.GetNodeByTwinID(twinID) + require.NoError(err) + require.Equal(node, result) + }) + + t.Run("test list nodes of specific farm", func(t *testing.T) { + request = listNodesInFarm + result, err := c.ListNodes(ListNodesWithFarmID(farmID)) + require.NoError(err) + require.Equal([]Node{node}, result) + }) +} diff --git a/node-registrar/client/types.go b/node-registrar/client/types.go new file mode 100644 index 0000000..2007195 --- /dev/null +++ b/node-registrar/client/types.go @@ -0,0 +1,64 @@ +package client + +import ( + "time" +) + +type Account struct { + TwinID uint64 `json:"twin_id"` + Relays []string `json:"relays"` // Optional list of relay domains + RMBEncKey string `json:"rmb_enc_key"` // Optional base64 encoded public key for rmb communication + PublicKey string `json:"public_key"` +} + +type Farm struct { + FarmID uint64 `json:"farm_id"` + FarmName string `json:"farm_name"` + TwinID uint64 `json:"twin_id"` + Dedicated bool `json:"dedicated"` + StellarAddress string `json:"stellar_address"` +} + +type Node struct { + NodeID uint64 `json:"node_id"` + FarmID uint64 `json:"farm_id"` + TwinID uint64 `json:"twin_id"` + Location Location `json:"location"` + Resources Resources `json:"resources"` + Interfaces []Interface `json:"interfaces"` + SecureBoot bool `json:"secure_boot"` + Virtualized bool `json:"virtualized"` + SerialNumber string `json:"serial_number"` + UptimeReports []UptimeReport `json:"uptime"` + Approved bool +} + +type UptimeReport struct { + Uptime time.Duration `json:"uptime"` + Timestamp time.Time `json:"timestamp"` +} + +type ZosVersion struct { + Version string `json:"version"` + SafeToUpgrade bool `json:"safe_to_upgrade"` +} + +type Interface struct { + Name string `json:"name"` + Mac string `json:"mac"` + IPs string `json:"ips"` +} + +type Resources struct { + HRU uint64 `json:"hru"` + SRU uint64 `json:"sru"` + CRU uint64 `json:"cru"` + MRU uint64 `json:"mru"` +} + +type Location struct { + Country string `json:"country"` + City string `json:"city"` + Longitude string `json:"longitude"` + Latitude string `json:"latitude"` +} diff --git a/node-registrar/client/utils.go b/node-registrar/client/utils.go new file mode 100644 index 0000000..27652c6 --- /dev/null +++ b/node-registrar/client/utils.go @@ -0,0 +1,40 @@ +package client + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + + "github.com/pkg/errors" +) + +// signRequest signs request with challenge with format timestamp:twinID +func (c *RegistrarClient) signRequest(timestamp int64) (authHeader string, err error) { + challenge := []byte(fmt.Sprintf("%d:%v", timestamp, c.twinID)) + signature, err := c.keyPair.Sign(challenge) + if err != nil { + return "", err + } + + authHeader = fmt.Sprintf( + "%s:%s", + base64.StdEncoding.EncodeToString(challenge), + base64.StdEncoding.EncodeToString(signature), + ) + return +} + +// parseResponseError parse json response error +func parseResponseError(body io.Reader) (err error) { + errResp := struct { + Error string `json:"error"` + }{} + + err = json.NewDecoder(body).Decode(&errResp) + if err != nil { + return errors.Wrap(err, "failed to parse response error") + } + + return errors.New(errResp.Error) +} diff --git a/node-registrar/client/utils_test.go b/node-registrar/client/utils_test.go new file mode 100644 index 0000000..80b5d1a --- /dev/null +++ b/node-registrar/client/utils_test.go @@ -0,0 +1,237 @@ +package client + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/stretchr/testify/require" +) + +var ( + account = Account{TwinID: 1, Relays: []string{}, RMBEncKey: ""} + farm = Farm{FarmID: 1, FarmName: "freeFarm", TwinID: 1} + node = Node{NodeID: 1, FarmID: farmID, TwinID: twinID} +) + +const ( + newClientWithNoAccount = iota + newClientWithAccountNoNode + newClientWithAccountAndNode + + createAccountStatusCreated + updateAccountWithNoAccount + updateAccountWithStatusOK + getAccountWithPKStatusOK + getAccountWithPKStatusNotFount + getAccountWithIDStatusOK + getAccountWithIDStatusNotFount + + createFarmStatusCreated + createFarmStatusConflict + updateFarmWithStatusOK + updateFarmWithStatusUnauthorized + getFarmWithStatusNotfound + getFarmWithStatusOK + + registerNodeStatusCreated + registerNodeWithNoAccount + registerNodeStatusConflict + updateNodeStatusOK + updateNodeSendUptimeReport + getNodeWithIDStatusOK + getNodeWithIDStatusNotFound + getNodeWithTwinID + listNodesInFarm + + testMnemonic = "bottom drive obey lake curtain smoke basket hold race lonely fit walk" + + farmID uint64 = 1 + nodeID uint64 = 1 + twinID uint64 = 1 +) + +func serverHandler(r *http.Request, request, count int, require *require.Assertions) (statusCode int, body []byte) { + switch request { + // NewRegistrarClient handlers + case newClientWithAccountNoNode: + switch count { + case 0: + require.Equal("/v1/accounts", r.URL.Path) + require.Equal(account.PublicKey, r.URL.Query().Get("public_key")) + require.Equal(http.MethodGet, r.Method) + resp, err := json.Marshal(account) + require.NoError(err) + return http.StatusOK, resp + case 1: + require.Equal("/v1/nodes", r.URL.Path) + require.Equal(fmt.Sprint(account.TwinID), r.URL.Query().Get("twin_id")) + require.Equal(http.MethodGet, r.Method) + return http.StatusNotFound, nil + } + + case newClientWithAccountAndNode: + switch count { + case 0: + require.Equal("/v1/accounts", r.URL.Path) + require.Equal(account.PublicKey, r.URL.Query().Get("public_key")) + require.Equal(http.MethodGet, r.Method) + resp, err := json.Marshal(account) + require.NoError(err) + return http.StatusOK, resp + case 1: + require.Equal("/v1/nodes", r.URL.Path) + require.Equal(fmt.Sprint(account.TwinID), r.URL.Query().Get("twin_id")) + require.Equal(http.MethodGet, r.Method) + resp, err := json.Marshal([]Node{node}) + require.NoError(err) + return http.StatusOK, resp + } + + // Accounts routes handlers + case createAccountStatusCreated: + require.Equal("/v1/accounts", r.URL.Path) + require.Equal(http.MethodPost, r.Method) + require.NotEmpty(r.Body) + resp, err := json.Marshal(account) + require.NoError(err) + return http.StatusCreated, resp + + case getAccountWithPKStatusOK: + require.Equal("/v1/accounts", r.URL.Path) + require.Equal(account.PublicKey, r.URL.Query().Get("public_key")) + require.Equal(http.MethodGet, r.Method) + resp, err := json.Marshal(account) + require.NoError(err) + return http.StatusOK, resp + + case getAccountWithIDStatusNotFount: + require.Equal("/v1/accounts", r.URL.Path) + require.Equal(fmt.Sprint(account.TwinID), r.URL.Query().Get("twin_id")) + require.Equal(http.MethodGet, r.Method) + return http.StatusNotFound, nil + + case getAccountWithIDStatusOK: + require.Equal("/v1/accounts", r.URL.Path) + require.Equal(fmt.Sprint(account.TwinID), r.URL.Query().Get("twin_id")) + require.Equal(http.MethodGet, r.Method) + resp, err := json.Marshal(account) + require.NoError(err) + return http.StatusOK, resp + + case updateAccountWithStatusOK: + require.Equal("/v1/accounts/1", r.URL.Path) + require.Equal(http.MethodPatch, r.Method) + return http.StatusOK, nil + + // Farm routes handlers + case createFarmStatusConflict: + require.Equal("/v1/farms", r.URL.Path) + require.Equal(http.MethodPost, r.Method) + require.NotEmpty(r.Body) + return http.StatusConflict, nil + + case createFarmStatusCreated: + require.Equal("/v1/farms", r.URL.Path) + require.Equal(http.MethodPost, r.Method) + require.NotEmpty(r.Body) + resp, err := json.Marshal(map[string]uint64{"farm_id": farmID}) + require.NoError(err) + return http.StatusCreated, resp + + case updateFarmWithStatusOK: + require.Equal("/v1/farms/1", r.URL.Path) + require.Equal(http.MethodPatch, r.Method) + require.NotEmpty(r.Body) + return http.StatusOK, nil + + case getFarmWithStatusOK: + require.Equal("/v1/farms/1", r.URL.Path) + require.Equal(http.MethodGet, r.Method) + resp, err := json.Marshal(farm) + require.NoError(err) + return http.StatusOK, resp + + case getFarmWithStatusNotfound: + require.Equal("/v1/farms/1", r.URL.Path) + require.Equal(http.MethodGet, r.Method) + return http.StatusNotFound, nil + + // Node routes handlers + case registerNodeStatusConflict: + require.Equal("/v1/nodes", r.URL.Path) + require.Equal(http.MethodPost, r.Method) + return http.StatusConflict, nil + + case registerNodeStatusCreated: + require.Equal("/v1/nodes", r.URL.Path) + require.Equal(http.MethodPost, r.Method) + require.NotEmpty(r.Body) + resp, err := json.Marshal(map[string]uint64{"node_id": nodeID}) + require.NoError(err) + return http.StatusCreated, resp + + case updateNodeStatusOK: + switch count { + case 0: + require.Equal("/v1/nodes/1", r.URL.Path) + require.Equal(http.MethodGet, r.Method) + resp, err := json.Marshal(node) + require.NoError(err) + return http.StatusOK, resp + case 1: + require.Equal("/v1/nodes/1", r.URL.Path) + require.Equal(http.MethodPatch, r.Method) + require.NotEmpty(r.Body) + return http.StatusOK, nil + } + + case updateNodeSendUptimeReport: + require.Equal("/v1/nodes/1/uptime", r.URL.Path) + require.Equal(http.MethodPost, r.Method) + require.NotEmpty(r.Body) + return http.StatusCreated, nil + + case getNodeWithIDStatusOK: + require.Equal("/v1/nodes/1", r.URL.Path) + require.Equal(http.MethodGet, r.Method) + resp, err := json.Marshal(node) + require.NoError(err) + return http.StatusOK, resp + + case getNodeWithIDStatusNotFound: + require.Equal("/v1/nodes/1", r.URL.Path) + require.Equal(http.MethodGet, r.Method) + return http.StatusNotFound, nil + + case getNodeWithTwinID: + require.Equal("/v1/nodes", r.URL.Path) + require.Equal(fmt.Sprint(account.TwinID), r.URL.Query().Get("twin_id")) + require.Equal(http.MethodGet, r.Method) + resp, err := json.Marshal([]Node{node}) + require.NoError(err) + return http.StatusOK, resp + + case listNodesInFarm: + require.Equal("/v1/nodes", r.URL.Path) + require.Equal(fmt.Sprint(farmID), r.URL.Query().Get("farm_id")) + require.Equal(http.MethodGet, r.Method) + resp, err := json.Marshal([]Node{node}) + require.NoError(err) + return http.StatusOK, resp + + // unauthorized requests + case newClientWithNoAccount, + getAccountWithPKStatusNotFount, + updateAccountWithNoAccount, + updateFarmWithStatusUnauthorized, + registerNodeWithNoAccount: + require.Equal("/v1/accounts", r.URL.Path) + require.Equal(account.PublicKey, r.URL.Query().Get("public_key")) + require.Equal(http.MethodGet, r.Method) + return http.StatusNotFound, nil + + } + + return http.StatusNotAcceptable, nil +} diff --git a/node-registrar/client/zos_version.go b/node-registrar/client/zos_version.go new file mode 100644 index 0000000..9ce848d --- /dev/null +++ b/node-registrar/client/zos_version.go @@ -0,0 +1,120 @@ +package client + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "strings" + "time" + + "github.com/pkg/errors" +) + +// GetZosVersion gets zos version for specific network +func (c *RegistrarClient) GetZosVersion() (version ZosVersion, err error) { + return c.getZosVersion() +} + +// SetZosVersion sets zos version for specific network only valid for network admin +func (c *RegistrarClient) SetZosVersion(v string, safeToUpgrade bool) (err error) { + return c.setZosVersion(v, safeToUpgrade) +} + +func (c *RegistrarClient) getZosVersion() (version ZosVersion, err error) { + url, err := url.JoinPath(c.baseURL, "zos", "version") + if err != nil { + return version, errors.Wrap(err, "failed to construct registrar url") + } + + resp, err := c.httpClient.Get(url) + if err != nil { + return version, err + } + + if resp.StatusCode != http.StatusOK { + err = parseResponseError(resp.Body) + return version, errors.Wrapf(err, "failed to get zos version with status code %s", resp.Status) + } + + defer resp.Body.Close() + + var versionString string + err = json.NewDecoder(resp.Body).Decode(&versionString) + if err != nil { + return version, err + } + + versionBytes, err := base64.StdEncoding.DecodeString(versionString) + if err != nil { + return version, err + } + + correctedJSON := strings.ReplaceAll(string(versionBytes), "'", "\"") + + err = json.NewDecoder(strings.NewReader(correctedJSON)).Decode(&version) + if err != nil { + return version, err + } + + return +} + +func (c *RegistrarClient) setZosVersion(v string, safeToUpgrade bool) (err error) { + err = c.ensureTwinID() + if err != nil { + return errors.Wrap(err, "failed to ensure twin id") + } + + url, err := url.JoinPath(c.baseURL, "zos", "version") + if err != nil { + return errors.Wrap(err, "failed to construct registrar url") + } + + version := ZosVersion{ + Version: v, + SafeToUpgrade: safeToUpgrade, + } + + jsonData, err := json.Marshal(version) + if err != nil { + return errors.Wrap(err, "failed to marshal zos version") + } + + encodedVersion := struct { + Version string `json:"version"` + }{ + Version: base64.StdEncoding.EncodeToString(jsonData), + } + + jsonData, err = json.Marshal(encodedVersion) + if err != nil { + return errors.Wrap(err, "failed to marshal zos version in hex format") + } + + req, err := http.NewRequest("PUT", url, bytes.NewReader(jsonData)) + if err != nil { + return errors.Wrap(err, "failed to construct http request to the registrar") + } + + authHeader, err := c.signRequest(time.Now().Unix()) + if err != nil { + return errors.Wrap(err, "failed to sign request") + } + req.Header.Set("X-Auth", authHeader) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return errors.Wrap(err, "failed to send request to get zos version from the registrar") + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return parseResponseError(resp.Body) + } + + return +} diff --git a/node-registrar/go.mod b/node-registrar/go.mod index d9198e8..bbd37d0 100644 --- a/node-registrar/go.mod +++ b/node-registrar/go.mod @@ -24,6 +24,7 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/cosmos/go-bip39 v1.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/base58 v1.0.4 // indirect github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect github.com/gabriel-vasile/mimetype v1.4.7 // indirect @@ -55,9 +56,12 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/vedhavyas/go-subkey v1.0.4 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/crypto v0.31.0 // indirect golang.org/x/net v0.33.0 // indirect diff --git a/node-registrar/go.sum b/node-registrar/go.sum index 281a665..d24bbde 100644 --- a/node-registrar/go.sum +++ b/node-registrar/go.sum @@ -146,6 +146,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= @@ -156,6 +158,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/vedhavyas/go-subkey v1.0.4 h1:QwjBZx4w7qXC2lmqol2jJfhaNXPI9BsgLZiMiCwqGDU= +github.com/vedhavyas/go-subkey v1.0.4/go.mod h1:aOIil/KS9hJlnr9ZSQKSoXdu/MbnkCxG4x9IOlLsMtI= github.com/vedhavyas/go-subkey/v2 v2.0.0 h1:LemDIsrVtRSOkp0FA8HxP6ynfKjeOj3BY2U9UNfeDMA= github.com/vedhavyas/go-subkey/v2 v2.0.0/go.mod h1:95aZ+XDCWAUUynjlmi7BtPExjXgXxByE0WfBwbmIRH4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/node-registrar/pkg/db/models.go b/node-registrar/pkg/db/models.go index dcf56fa..3540ef3 100644 --- a/node-registrar/pkg/db/models.go +++ b/node-registrar/pkg/db/models.go @@ -24,7 +24,7 @@ type Farm struct { FarmID uint64 `gorm:"primaryKey;autoIncrement" json:"farm_id"` FarmName string `gorm:"size:40;not null;unique;check:farm_name <> ''" json:"farm_name" binding:"alphanum,required"` TwinID uint64 `json:"twin_id" gorm:"not null;check:twin_id > 0"` // Farmer account reference - StellarAddress string `json:"stellar_address" binding:"max=56,startswith=G,len=56,alphanum,uppercase"` + StellarAddress string `json:"stellar_address" binding:"required,startswith=G,len=56,alphanum,uppercase"` Dedicated bool `json:"dedicated"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/node-registrar/pkg/server/handlers.go b/node-registrar/pkg/server/handlers.go index 80369ea..d4cc2e2 100644 --- a/node-registrar/pkg/server/handlers.go +++ b/node-registrar/pkg/server/handlers.go @@ -132,7 +132,7 @@ func (s Server) createFarmHandler(c *gin.Context) { type UpdateFarmRequest struct { FarmName string `json:"farm_name" binding:"max=40"` - StellarAddress string `json:"stellar_address" binding:"max=56,startswith=G,len=56,alphanum,uppercase"` + StellarAddress string `json:"stellar_address" binding:"startswith=G,len=56,alphanum,uppercase"` } // @Summary Update farm @@ -377,7 +377,6 @@ func (s *Server) updateNodeHandler(c *gin.Context) { return } - log.Info().Any("req is", c.Request.Body) var req UpdateNodeRequest if err := c.ShouldBindJSON(&req); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) @@ -557,6 +556,8 @@ func (s *Server) createAccountHandler(c *gin.Context) { // Now we can create new account account := &db.Account{ PublicKey: req.PublicKey, + Relays: req.Relays, + RMBEncKey: req.RMBEncKey, } if err := s.db.CreateAccount(account); err != nil { @@ -690,7 +691,6 @@ func (s *Server) getAccountHandler(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get account"}) return } - log.Info().Any("account", account).Send() c.JSON(http.StatusOK, account) return }