diff --git a/node-registrar/README.md b/node-registrar/README.md index 12be537..7d5654c 100644 --- a/node-registrar/README.md +++ b/node-registrar/README.md @@ -1,4 +1,3 @@ - # Node Registrar Service [![Go Report Card](https://goreportcard.com/badge/github.com/threefoldtech/tfgrid-sdk-go/node-registrar)](https://goreportcard.com/report/github.com/threefoldtech/tfgrid-sdk-go/node-registrar) @@ -40,6 +39,7 @@ It offers operations like registring, listing, and updating farms and nodes, as | GET | `/farms/:farm_id` | Get a specific farm by ID | | POST | `/farms/` | Create a new farm | | PATCH | `/farms/` | Update an existing farm | +| POST | `/farms/:farm_id/approve` | Approve multiple nodes for a specific farm | ### Nodes Endpoints @@ -51,6 +51,27 @@ It offers operations like registring, listing, and updating farms and nodes, as | POST | `/nodes/:node_id/uptime` | Report uptime for a specific node | | POST | `/nodes/:node_id/consumption` | Report consumption for a specific node | +### Node Approval + +The `/farms/:farm_id/approve` allows farmers to approve multiple nodes at once. This endpoint: + +- Requires farm owner authentication +- Accepts a list of node IDs in the request body +- Only approves nodes that: + - Belong to the specified farm + - Are currently not approved + +Example request: + +```json +POST /farms/123/approve +{ + "node_ids": [1, 2, 3] +} +``` + +The operation is atomic - either all nodes are approved or none are. If any node cannot be approved (not found, already approved, or doesn't belong to the farm), the entire operation fails. + ## Setup Instructions 1. **Start PostgreSQL:** @@ -94,7 +115,7 @@ Replace `` and `` with the appropriate values. docker build -t registrar:latest -f node-registrar/Dockerfile . ``` -2. run the image +1. run the image ```bash docker run -d \ diff --git a/node-registrar/client/account_test.go b/node-registrar/client/account_test.go index 798ae4f..1e2b98d 100644 --- a/node-registrar/client/account_test.go +++ b/node-registrar/client/account_test.go @@ -28,7 +28,7 @@ func TestCreateAccount(t *testing.T) { })) defer testServer.Close() - baseURL, err := url.JoinPath(testServer.URL, "v1") + baseURL, err := url.JoinPath(testServer.URL, "api", "v1") require.NoError(err) request = newClientWithNoAccount @@ -62,7 +62,7 @@ func TestUpdateAccount(t *testing.T) { })) defer testServer.Close() - baseURL, err := url.JoinPath(testServer.URL, "v1") + baseURL, err := url.JoinPath(testServer.URL, "api", "v1") require.NoError(err) t.Run("test update account updated successfully", func(t *testing.T) { @@ -112,7 +112,7 @@ func TestGetAccount(t *testing.T) { })) defer testServer.Close() - baseURL, err := url.JoinPath(testServer.URL, "v1") + baseURL, err := url.JoinPath(testServer.URL, "api", "v1") require.NoError(err) count = 0 diff --git a/node-registrar/client/client_test.go b/node-registrar/client/client_test.go index 0a2a973..a5cea2f 100644 --- a/node-registrar/client/client_test.go +++ b/node-registrar/client/client_test.go @@ -28,7 +28,7 @@ func TestNewRegistrarClient(t *testing.T) { })) defer testServer.Close() - baseURL, err := url.JoinPath(testServer.URL, "v1") + baseURL, err := url.JoinPath(testServer.URL, "api", "v1") require.NoError(err) t.Run("test new registrar client with no account", func(t *testing.T) { diff --git a/node-registrar/client/farm.go b/node-registrar/client/farm.go index 88fc1a8..fa12eec 100644 --- a/node-registrar/client/farm.go +++ b/node-registrar/client/farm.go @@ -35,6 +35,62 @@ func (c *RegistrarClient) ListFarms(filter FarmFilter) (farms []Farm, err error) return c.listFarms(filter) } +// ApproveNodes approves multiple nodes for a specific farm +func (c *RegistrarClient) ApproveNodes(farmID uint64, nodeIDs []uint64) error { + return c.approveNodes(farmID, nodeIDs) +} + +func (c *RegistrarClient) approveNodes(farmID uint64, nodeIDs []uint64) 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), "approve") + if err != nil { + return errors.Wrap(err, "failed to construct registrar url") + } + + data := struct { + NodeIDs []uint64 `json:"node_ids"` + }{ + NodeIDs: nodeIDs, + } + + var body bytes.Buffer + if err = json.NewEncoder(&body).Encode(data); 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 approve nodes") + } + + if resp == nil { + return errors.New("failed to approve nodes, no response received") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err = parseResponseError(resp.Body) + return errors.Wrapf(err, "failed to approve nodes with status code %s", resp.Status) + } + + return nil +} + // FarmUpdate represents the data needed to update an existing farm type FarmUpdate struct { FarmName *string diff --git a/node-registrar/client/farm_test.go b/node-registrar/client/farm_test.go index d02baf6..93e026e 100644 --- a/node-registrar/client/farm_test.go +++ b/node-registrar/client/farm_test.go @@ -28,7 +28,7 @@ func TestCreateFarm(t *testing.T) { })) defer testServer.Close() - baseURL, err := url.JoinPath(testServer.URL, "v1") + baseURL, err := url.JoinPath(testServer.URL, "api", "v1") require.NoError(err) request = newClientWithAccountNoNode @@ -68,7 +68,7 @@ func TestUpdateFarm(t *testing.T) { })) defer testServer.Close() - baseURL, err := url.JoinPath(testServer.URL, "v1") + baseURL, err := url.JoinPath(testServer.URL, "api", "v1") require.NoError(err) t.Run("test update farm with status unauthorzed", func(t *testing.T) { @@ -113,7 +113,7 @@ func TestGetFarm(t *testing.T) { })) defer testServer.Close() - baseURL, err := url.JoinPath(testServer.URL, "v1") + baseURL, err := url.JoinPath(testServer.URL, "api", "v1") require.NoError(err) count = 0 @@ -134,3 +134,47 @@ func TestGetFarm(t *testing.T) { require.Equal(result, farm) }) } + +func TestApproveNodes(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, "api", "v1") + require.NoError(err) + + t.Run("test approve nodes with status unauthorized", func(t *testing.T) { + count = 0 + request = newClientWithAccountNoNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + request = approveNodesWithStatusUnauthorized + err = c.ApproveNodes(farmID, []uint64{1, 2, 3}) + require.Error(err) + }) + + t.Run("test approve nodes with status ok", func(t *testing.T) { + count = 0 + request = newClientWithAccountNoNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + request = approveNodesWithStatusOK + err = c.ApproveNodes(farmID, []uint64{1, 2, 3}) + require.NoError(err) + }) +} diff --git a/node-registrar/client/node.go b/node-registrar/client/node.go index 5d5eade..64f00c6 100644 --- a/node-registrar/client/node.go +++ b/node-registrar/client/node.go @@ -45,6 +45,17 @@ func (c *RegistrarClient) ListNodes(opts NodeFilter) (nodes []Node, err error) { return c.listNodesWithFilter(opts) } +// ListUnapprovedNodes gets a list of unapproved nodes for a specific farm +func (c *RegistrarClient) ListUnapprovedNodes(farmID uint64) ([]Node, error) { + falseVal := false + filter := NodeFilter{ + FarmID: &farmID, + Approved: &falseVal, + } + + return c.ListNodes(filter) +} + func (c *RegistrarClient) registerNode(node Node) (nodeID uint64, err error) { err = c.ensureTwinID() if err != nil { @@ -149,7 +160,7 @@ func (c *RegistrarClient) updateNode(opts NodeUpdate) (err error) { return errors.Wrap(err, "failed to construct registrar url") } - node = c.parseUpdateNodeOpts(node, opts) + node = parseUpdateNodeOpts(node, opts) handler := func(body bytes.Buffer) error { req, err := http.NewRequest("PATCH", url, &body) @@ -331,6 +342,7 @@ type NodeFilter struct { LastSeen *int64 Page *uint32 Size *uint32 + Approved *bool } func (c *RegistrarClient) listNodesWithFilter(filter NodeFilter) (nodes []Node, err error) { @@ -425,7 +437,7 @@ func (c *RegistrarClient) ensureNodeID() error { return nil } -func (c *RegistrarClient) parseUpdateNodeOpts(node Node, update NodeUpdate) Node { +func parseUpdateNodeOpts(node Node, update NodeUpdate) Node { if update.FarmID != nil { node.FarmID = *update.FarmID } @@ -484,6 +496,9 @@ func parseListNodeOpts(filter NodeFilter) map[string]any { if filter.LastSeen != nil { data["last_seen"] = *filter.LastSeen } + if filter.Approved != nil { + data["approved"] = *filter.Approved + } page := uint32(1) if filter.Page != nil { diff --git a/node-registrar/client/node_test.go b/node-registrar/client/node_test.go index b59d356..d77bae4 100644 --- a/node-registrar/client/node_test.go +++ b/node-registrar/client/node_test.go @@ -1,6 +1,7 @@ package client import ( + "encoding/base64" "net/http" "net/http/httptest" "net/url" @@ -20,6 +21,10 @@ func TestRegistarNode(t *testing.T) { FarmID: farmID, } + 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) @@ -29,7 +34,7 @@ func TestRegistarNode(t *testing.T) { })) defer testServer.Close() - baseURL, err := url.JoinPath(testServer.URL, "v1") + baseURL, err := url.JoinPath(testServer.URL, "api", "v1") require.NoError(err) t.Run("test registar node no account", func(t *testing.T) { @@ -73,10 +78,10 @@ func TestUpdateNode(t *testing.T) { var count int require := require.New(t) - // publicKey, privateKey, err := aliceKeys() - // require.NoError(err) - // account.PublicKey = base64.StdEncoding.EncodeToString(publicKey) - // + 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) @@ -86,7 +91,7 @@ func TestUpdateNode(t *testing.T) { })) defer testServer.Close() - baseURL, err := url.JoinPath(testServer.URL, "v1") + baseURL, err := url.JoinPath(testServer.URL, "api", "v1") require.NoError(err) request = newClientWithAccountAndNode @@ -118,9 +123,9 @@ func TestGetNode(t *testing.T) { var count int require := require.New(t) - // publicKey, privateKey, err := aliceKeys() - // require.NoError(err) - // account.PublicKey = base64.StdEncoding.EncodeToString(publicKey) + 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) @@ -131,7 +136,7 @@ func TestGetNode(t *testing.T) { })) defer testServer.Close() - baseURL, err := url.JoinPath(testServer.URL, "v1") + baseURL, err := url.JoinPath(testServer.URL, "api", "v1") require.NoError(err) request = newClientWithAccountAndNode @@ -166,3 +171,36 @@ func TestGetNode(t *testing.T) { require.Equal([]Node{node}, result) }) } + +func TestListUnapprovedNodes(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, "api", "v1") + require.NoError(err) + + t.Run("test list unapproved nodes in farm", func(t *testing.T) { + request = newClientWithAccountNoNode + c, err := NewRegistrarClient(baseURL, testMnemonic) + require.NoError(err) + + request = listUnapprovedNodesInFarm + nodes, err := c.ListUnapprovedNodes(farmID) + require.NoError(err) + require.Equal([]Node{node}, nodes) + }) +} diff --git a/node-registrar/client/utils_test.go b/node-registrar/client/utils_test.go index 80b5d1a..ec65fe3 100644 --- a/node-registrar/client/utils_test.go +++ b/node-registrar/client/utils_test.go @@ -33,6 +33,8 @@ const ( updateFarmWithStatusUnauthorized getFarmWithStatusNotfound getFarmWithStatusOK + approveNodesWithStatusOK + approveNodesWithStatusUnauthorized registerNodeStatusCreated registerNodeWithNoAccount @@ -43,6 +45,7 @@ const ( getNodeWithIDStatusNotFound getNodeWithTwinID listNodesInFarm + listUnapprovedNodesInFarm testMnemonic = "bottom drive obey lake curtain smoke basket hold race lonely fit walk" @@ -57,14 +60,14 @@ func serverHandler(r *http.Request, request, count int, require *require.Asserti case newClientWithAccountNoNode: switch count { case 0: - require.Equal("/v1/accounts", r.URL.Path) + require.Equal("/api/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("/api/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 @@ -73,14 +76,14 @@ func serverHandler(r *http.Request, request, count int, require *require.Asserti case newClientWithAccountAndNode: switch count { case 0: - require.Equal("/v1/accounts", r.URL.Path) + require.Equal("/api/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("/api/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}) @@ -90,7 +93,7 @@ func serverHandler(r *http.Request, request, count int, require *require.Asserti // Accounts routes handlers case createAccountStatusCreated: - require.Equal("/v1/accounts", r.URL.Path) + require.Equal("/api/v1/accounts", r.URL.Path) require.Equal(http.MethodPost, r.Method) require.NotEmpty(r.Body) resp, err := json.Marshal(account) @@ -98,7 +101,7 @@ func serverHandler(r *http.Request, request, count int, require *require.Asserti return http.StatusCreated, resp case getAccountWithPKStatusOK: - require.Equal("/v1/accounts", r.URL.Path) + require.Equal("/api/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) @@ -106,13 +109,13 @@ func serverHandler(r *http.Request, request, count int, require *require.Asserti return http.StatusOK, resp case getAccountWithIDStatusNotFount: - require.Equal("/v1/accounts", r.URL.Path) + require.Equal("/api/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("/api/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) @@ -120,19 +123,19 @@ func serverHandler(r *http.Request, request, count int, require *require.Asserti return http.StatusOK, resp case updateAccountWithStatusOK: - require.Equal("/v1/accounts/1", r.URL.Path) + require.Equal("/api/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("/api/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("/api/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}) @@ -140,31 +143,31 @@ func serverHandler(r *http.Request, request, count int, require *require.Asserti return http.StatusCreated, resp case updateFarmWithStatusOK: - require.Equal("/v1/farms/1", r.URL.Path) + require.Equal("/api/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("/api/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("/api/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("/api/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("/api/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}) @@ -174,38 +177,38 @@ func serverHandler(r *http.Request, request, count int, require *require.Asserti case updateNodeStatusOK: switch count { case 0: - require.Equal("/v1/nodes/1", r.URL.Path) + require.Equal("/api/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("/api/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("/api/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("/api/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("/api/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("/api/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}) @@ -213,20 +216,53 @@ func serverHandler(r *http.Request, request, count int, require *require.Asserti return http.StatusOK, resp case listNodesInFarm: - require.Equal("/v1/nodes", r.URL.Path) + require.Equal("/api/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 + case approveNodesWithStatusOK: + require.Equal("/api/v1/farms/1/approve", r.URL.Path) + require.Equal(http.MethodPost, r.Method) + require.NotEmpty(r.Body) + var body struct { + NodeIDs []uint64 `json:"node_ids"` + } + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(err) + require.Equal([]uint64{1, 2, 3}, body.NodeIDs) + return http.StatusOK, nil + + case approveNodesWithStatusUnauthorized: + require.Equal("/api/v1/farms/1/approve", r.URL.Path) + require.Equal(http.MethodPost, r.Method) + require.NotEmpty(r.Body) + var body struct { + NodeIDs []uint64 `json:"node_ids"` + } + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(err) + require.Equal([]uint64{1, 2, 3}, body.NodeIDs) + return http.StatusUnauthorized, nil + + case listUnapprovedNodesInFarm: + require.Equal("/api/v1/nodes", r.URL.Path) + require.Equal(fmt.Sprint(farmID), r.URL.Query().Get("farm_id")) + require.Equal("false", r.URL.Query().Get("approved")) + 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("/api/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 diff --git a/node-registrar/pkg/db/farms.go b/node-registrar/pkg/db/farms.go index e4057bb..04bcf7e 100644 --- a/node-registrar/pkg/db/farms.go +++ b/node-registrar/pkg/db/farms.go @@ -1,6 +1,7 @@ package db import ( + "fmt" "strings" "github.com/pkg/errors" @@ -71,3 +72,30 @@ func (db *Database) UpdateFarm(farmID uint64, name string, stellarAddr string) ( } return nil } + +// ApproveNodes approves multiple nodes for a specific farm +func (db *Database) ApproveNodes(farmID uint64, nodeIDs []uint64) error { + // Start a transaction to ensure all updates are atomic + tx := db.gormDB.Begin() + if tx.Error != nil { + return tx.Error + } + + result := tx.Model(&Node{}). + Where("farm_id = ? AND node_id IN ? AND approved = ?", farmID, nodeIDs, false). + Update("approved", true) + + if result.Error != nil { + tx.Rollback() + return result.Error + } + + // Check if all nodes were found, belong to the requested farm and updated + if int(result.RowsAffected) != len(nodeIDs) { + tx.Rollback() + return fmt.Errorf("some nodes were not found, do not belong to farm %d, or are already approved", farmID) + } + + // Commit the transaction + return tx.Commit().Error +} diff --git a/node-registrar/pkg/db/models.go b/node-registrar/pkg/db/models.go index ee98059..dc412a9 100644 --- a/node-registrar/pkg/db/models.go +++ b/node-registrar/pkg/db/models.go @@ -102,10 +102,11 @@ type NodeFilter struct { NodeID *uint64 `form:"node_id"` FarmID *uint64 `form:"farm_id"` TwinID *uint64 `form:"twin_id"` - Status string `form:"status"` - Healthy bool `form:"healthy"` + Status *string `form:"status"` + Healthy *bool `form:"healthy"` Online *bool `form:"online"` // Filter by online status (true = online, false = offline, nil = both) LastSeen *int64 `form:"last_seen"` // Filter nodes last seen within this many minutes + Approved *bool `form:"approved"` } type FarmFilter struct { diff --git a/node-registrar/pkg/db/nodes.go b/node-registrar/pkg/db/nodes.go index abb6ee1..e008406 100644 --- a/node-registrar/pkg/db/nodes.go +++ b/node-registrar/pkg/db/nodes.go @@ -22,6 +22,15 @@ func (db *Database) ListNodes(filter NodeFilter, limit Limit) (nodes []Node, err if filter.TwinID != nil { query = query.Where("twin_id = ?", *filter.TwinID) } + if filter.Healthy != nil { + query = query.Where("healthy= ?", *filter.Healthy) + } + if filter.Status != nil { + query = query.Where("status= ?", *filter.Status) + } + if filter.Approved != nil { + query = query.Where("approved= ?", *filter.Approved) + } // Filter by online status (node sent an uptime report in the last 30 minutes) if filter.Online != nil { diff --git a/node-registrar/pkg/server/handlers.go b/node-registrar/pkg/server/handlers.go index 86b6dff..5d7b553 100644 --- a/node-registrar/pkg/server/handlers.go +++ b/node-registrar/pkg/server/handlers.go @@ -825,3 +825,65 @@ func validateTimestampHint(timestampHint int64) error { return nil } + +type ApproveNodesRequest struct { + NodeIDs []uint64 `json:"node_ids" binding:"required,min=1"` +} + +// @Summary Approve nodes for a farm +// @Description Approve a list of nodes for a specific farm +// @Tags farms +// @Accept json +// @Produce json +// @Param X-Auth header string true "Authentication format: Base64(:):Base64(signature)" +// @Param farm_id path int true "Farm ID" +// @Param request body ApproveNodesRequest true "List of node IDs to approve" +// @Success 200 {object} map[string]any "Nodes approved successfully" +// @Failure 400 {object} map[string]any "Invalid request" +// @Failure 401 {object} map[string]any "Unauthorized" +// @Failure 404 {object} map[string]any "Farm not found" +// @Router /farms/{farm_id}/approve [post] +func (s *Server) approveNodesHandler(c *gin.Context) { + farmID := c.Param("farm_id") + + id, err := strconv.ParseUint(farmID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid farm_id: %v", err.Error())}) + return + } + + // Verify farm exists and get owner + farm, err := s.db.GetFarm(id) + if err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "Farm not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Ensure the farmer approving the nodes is the owner of the farm + ensureOwner(c, farm.TwinID) + if c.IsAborted() { + return + } + + var req ApproveNodesRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if len(req.NodeIDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "could not find any nodes to approve"}) + return + } + + if err := s.db.ApproveNodes(id, req.NodeIDs); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Nodes approved successfully"}) +} diff --git a/node-registrar/pkg/server/routes.go b/node-registrar/pkg/server/routes.go index 0e4b547..a422df3 100644 --- a/node-registrar/pkg/server/routes.go +++ b/node-registrar/pkg/server/routes.go @@ -38,6 +38,7 @@ func (s *Server) registerRoutes(r *gin.RouterGroup) { // added to stop redirecting when creating a farm with extra / protectedFarmRoutes.POST("/", s.createFarmHandler) protectedFarmRoutes.PATCH("/:farm_id", s.updateFarmHandler) + protectedFarmRoutes.POST("/:farm_id/approve", s.approveNodesHandler) // nodes routes publicNodeRoutes := r.Group("nodes")