satellite/admin/back-office: add endpoint to get placement info

This change adds an endpoint to the back office API that returns
placement IDs and their locations.

References #6503

Change-Id: I20ee1c82dcb647d6d264317beceeb5e70f7a8e87
This commit is contained in:
Jeremy Wharton 2023-11-14 20:19:17 -06:00 committed by Storj Robot
parent e67691d51c
commit 1ea81c8887
9 changed files with 140 additions and 95 deletions

View File

@ -215,10 +215,28 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m
return nil, err
}
placement, err := config.Placement.Parse()
if err != nil {
return nil, err
}
adminConfig := config.Admin
adminConfig.AuthorizationToken = config.Console.AuthToken
peer.Admin.Server = admin.NewServer(log.Named("admin"), peer.Admin.Listener, peer.DB, peer.Buckets.Service, peer.REST.Keys, peer.FreezeAccounts.Service, peer.Analytics.Service, peer.Payments.Accounts, config.Console, adminConfig)
peer.Admin.Server = admin.NewServer(
log.Named("admin"),
peer.Admin.Listener,
peer.DB,
peer.Buckets.Service,
peer.REST.Keys,
peer.FreezeAccounts.Service,
peer.Analytics.Service,
peer.Payments.Accounts,
placement,
config.Console,
adminConfig,
)
peer.Servers.Add(lifecycle.Item{
Name: "admin",
Run: peer.Admin.Server.Run,

View File

@ -6,20 +6,24 @@
<h2 id='list-of-endpoints'>List of Endpoints</h2>
* Example
* [Get examples](#example-get-examples)
* PlacementManagement
* [Get placements](#placementmanagement-get-placements)
<h3 id='example-get-examples'>Get examples (<a href='#list-of-endpoints'>go to full list</a>)</h3>
<h3 id='placementmanagement-get-placements'>Get placements (<a href='#list-of-endpoints'>go to full list</a>)</h3>
Get a list with the names of the all available examples
Gets placement rule IDs and their locations
`GET /back-office/api/v1/example/examples`
`GET /back-office/api/v1/placements/`
**Response body:**
```typescript
[
string
{
id: number
location: string
}
]
```

View File

@ -5,6 +5,8 @@
// source code of the API server handlers and clients and the documentation markdown document.
package main
//go:generate go run $GOFILE
import (
"os"
"path"
@ -22,24 +24,21 @@ func main() {
BasePath: path.Join(backoffice.PathPrefix, "/api"),
}
// This is an example and must be deleted when we define the first real endpoint.
group := api.Group("Example", "example")
group := api.Group("PlacementManagement", "placements")
group.Get("/examples", &apigen.Endpoint{
Name: "Get examples",
Description: "Get a list with the names of the all available examples",
GoName: "GetExamples",
TypeScriptName: "getExamples",
Response: []string{},
ResponseMock: []string{"example-1", "example-2", "example-3"},
NoCookieAuth: false,
NoAPIAuth: false,
group.Get("/", &apigen.Endpoint{
Name: "Get placements",
Description: "Gets placement rule IDs and their locations",
GoName: "GetPlacements",
TypeScriptName: "getPlacements",
Response: []backoffice.PlacementInfo{},
NoCookieAuth: true,
NoAPIAuth: true,
})
modroot := findModuleRootDir()
api.MustWriteGo(filepath.Join(modroot, "satellite", "admin", "back-office", "handlers.gen.go"))
api.MustWriteTS(filepath.Join(modroot, "satellite", "admin", "back-office", "ui", "src", "api", "client.gen.ts"))
api.MustWriteTSMock(filepath.Join(modroot, "satellite", "admin", "back-office", "ui", "src", "api", "client-mock.gen.ts"))
api.MustWriteDocs(filepath.Join(modroot, "satellite", "admin", "back-office", "api-docs.gen.md"))
}

View File

@ -16,49 +16,42 @@ import (
"storj.io/storj/private/api"
)
var ErrExampleAPI = errs.Class("admin example api")
var ErrPlacementsAPI = errs.Class("admin placements api")
type ExampleService interface {
GetExamples(ctx context.Context) ([]string, api.HTTPError)
type PlacementManagementService interface {
GetPlacements(ctx context.Context) ([]PlacementInfo, api.HTTPError)
}
// ExampleHandler is an api handler that implements all Example API endpoints functionality.
type ExampleHandler struct {
// PlacementManagementHandler is an api handler that implements all PlacementManagement API endpoints functionality.
type PlacementManagementHandler struct {
log *zap.Logger
mon *monkit.Scope
service ExampleService
service PlacementManagementService
auth api.Auth
}
func NewExample(log *zap.Logger, mon *monkit.Scope, service ExampleService, router *mux.Router, auth api.Auth) *ExampleHandler {
handler := &ExampleHandler{
func NewPlacementManagement(log *zap.Logger, mon *monkit.Scope, service PlacementManagementService, router *mux.Router, auth api.Auth) *PlacementManagementHandler {
handler := &PlacementManagementHandler{
log: log,
mon: mon,
service: service,
auth: auth,
}
exampleRouter := router.PathPrefix("/back-office/api/v1/example").Subrouter()
exampleRouter.HandleFunc("/examples", handler.handleGetExamples).Methods("GET")
placementsRouter := router.PathPrefix("/back-office/api/v1/placements").Subrouter()
placementsRouter.HandleFunc("/", handler.handleGetPlacements).Methods("GET")
return handler
}
func (h *ExampleHandler) handleGetExamples(w http.ResponseWriter, r *http.Request) {
func (h *PlacementManagementHandler) handleGetPlacements(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer h.mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
ctx, err = h.auth.IsAuthenticated(ctx, r, true, true)
if err != nil {
h.auth.RemoveAuthCookie(w)
api.ServeError(h.log, w, http.StatusUnauthorized, err)
return
}
retVal, httpErr := h.service.GetExamples(ctx)
retVal, httpErr := h.service.GetPlacements(ctx)
if httpErr.Err != nil {
api.ServeError(h.log, w, httpErr.Status, httpErr.Err)
return
@ -66,6 +59,6 @@ func (h *ExampleHandler) handleGetExamples(w http.ResponseWriter, r *http.Reques
err = json.NewEncoder(w).Encode(retVal)
if err != nil {
h.log.Debug("failed to write json GetExamples response", zap.Error(ErrExampleAPI.Wrap(err)))
h.log.Debug("failed to write json GetPlacements response", zap.Error(ErrPlacementsAPI.Wrap(err)))
}
}

View File

@ -0,0 +1,36 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
package admin
import (
"context"
"storj.io/common/storj"
"storj.io/storj/private/api"
"storj.io/storj/satellite/nodeselection"
)
// PlacementInfo contains the ID and location of a placement rule.
type PlacementInfo struct {
ID storj.PlacementConstraint `json:"id"`
Location string `json:"location"`
}
// GetPlacements returns IDs and locations of placement rules.
func (s *Server) GetPlacements(ctx context.Context) ([]PlacementInfo, api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
placements := s.placement.SupportedPlacements()
infos := make([]PlacementInfo, 0, len(placements))
for _, placement := range placements {
filter := s.placement.CreateFilters(placement)
infos = append(infos, PlacementInfo{
ID: placement,
Location: nodeselection.GetAnnotation(filter, nodeselection.Location),
})
}
return infos, api.HTTPError{}
}

View File

@ -10,20 +10,26 @@ import (
"net/http"
"github.com/gorilla/mux"
"github.com/spacemonkeygo/monkit/v3"
"github.com/zeebo/errs"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"storj.io/common/errs2"
ui "storj.io/storj/satellite/admin/back-office/ui"
"storj.io/storj/satellite/overlay"
)
// PathPrefix is the path that will be prefixed to the router passed to the NewServer constructor.
// This is temporary until this server will replace the storj.io/storj/satellite/admin/server.go.
const PathPrefix = "/back-office/"
// Error is the error class that wraps all the errors returned by this package.
var Error = errs.Class("satellite-admin")
var (
// Error is the error class that wraps all the errors returned by this package.
Error = errs.Class("satellite-admin")
mon = monkit.Package()
)
// Config defines configuration for the satellite administration server.
type Config struct {
@ -38,23 +44,25 @@ type Config struct {
// Server serves the API endpoints and the web application to allow preforming satellite
// administration tasks.
type Server struct {
log *zap.Logger
listener net.Listener
server http.Server
log *zap.Logger
listener net.Listener
placement *overlay.PlacementDefinitions
config Config
server http.Server
}
// NewServer creates a satellite administration server instance with the provided dependencies and
// configurations.
//
// When listener is nil, Server.Run is a noop.
func NewServer(log *zap.Logger, listener net.Listener, root *mux.Router, config Config) *Server {
func NewServer(log *zap.Logger, listener net.Listener, placement *overlay.PlacementDefinitions, root *mux.Router, config Config) *Server {
server := &Server{
log: log,
listener: listener,
config: config,
log: log,
listener: listener,
placement: placement,
config: config,
}
if root == nil {
@ -63,7 +71,8 @@ func NewServer(log *zap.Logger, listener net.Listener, root *mux.Router, config
// API endpoints.
// API generator already add the PathPrefix.
// _ := NewExample(log, mon, nil, root, nil)
auth := &apiAuth{server}
NewPlacementManagement(log, mon, server, root, auth)
root = root.PathPrefix(PathPrefix).Subrouter()
// Static assets for the web interface.
@ -108,3 +117,18 @@ func (server *Server) Run(ctx context.Context) error {
func (server *Server) Close() error {
return Error.Wrap(server.server.Close())
}
// apiAuth exposes methods to control the authentication process for each generated API endpoint.
type apiAuth struct {
server *Server
}
// IsAuthenticated checks if request is performed with all needed authorization credentials.
// This method is inert because the satellite admin back office uses neither cookies nor API keys to authenticate.
func (a *apiAuth) IsAuthenticated(ctx context.Context, r *http.Request, isCookieAuth, isKeyAuth bool) (_ context.Context, err error) {
return ctx, nil
}
// RemoveAuthCookie indicates to the client that the authentication cookie should be removed.
// This method is inert because the satellite admin back office doesn't use authentication cookies.
func (a *apiAuth) RemoveAuthCookie(w http.ResponseWriter) {}

View File

@ -1,39 +0,0 @@
// AUTOGENERATED BY private/apigen
// DO NOT EDIT.
class APIError extends Error {
constructor(
public readonly msg: string,
public readonly responseStatusCode?: number,
) {
super(msg);
}
}
export class ExampleHttpApiV1 {
public readonly respStatusCode: number;
// When respStatuscode is passed, the client throws an APIError on each method call
// with respStatusCode as HTTP status code.
// respStatuscode must be equal or greater than 400
constructor(respStatusCode?: number) {
if (typeof respStatusCode === 'undefined') {
this.respStatusCode = 0;
return;
}
if (respStatusCode < 400) {
throw new Error('invalid response status code for API Error, it must be greater or equal than 400');
}
this.respStatusCode = respStatusCode;
}
public async getExamples(): Promise<string[]> {
if (this.respStatusCode !== 0) {
throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);
}
return JSON.parse('["example-1","example-2","example-3"]') as string[];
}
}

View File

@ -3,6 +3,11 @@
import { HttpClient } from '@/utils/httpClient';
export class PlacementInfo {
id: number;
location: string;
}
class APIError extends Error {
constructor(
public readonly msg: string,
@ -12,15 +17,15 @@ class APIError extends Error {
}
}
export class ExampleHttpApiV1 {
export class PlacementManagementHttpApiV1 {
private readonly http: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/back-office/api/v1/example';
private readonly ROOT_PATH: string = '/back-office/api/v1/placements';
public async getExamples(): Promise<string[]> {
const fullPath = `${this.ROOT_PATH}/examples`;
public async getPlacements(): Promise<PlacementInfo[]> {
const fullPath = `${this.ROOT_PATH}/`;
const response = await this.http.get(fullPath);
if (response.ok) {
return response.json().then((body) => body as string[]);
return response.json().then((body) => body as PlacementInfo[]);
}
const err = await response.json();
throw new APIError(err.error, response.status);

View File

@ -29,6 +29,7 @@ import (
"storj.io/storj/satellite/console/consoleweb"
"storj.io/storj/satellite/console/restkeys"
"storj.io/storj/satellite/oidc"
"storj.io/storj/satellite/overlay"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/stripe"
)
@ -38,6 +39,9 @@ const (
UnauthorizedNotInGroup = "User must be a member of one of these groups to conduct this operation: %s"
// AuthorizationNotEnabled - message for when authorization is disabled.
AuthorizationNotEnabled = "Authorization not enabled."
// BackOfficePathPrefix is the path prefix used for the back office router.
BackOfficePathPrefix = "/back-office"
)
// Config defines configuration for debug server.
@ -102,6 +106,7 @@ func NewServer(
freezeAccounts *console.AccountFreezeService,
analyticsService *analytics.Service,
accounts payments.Accounts,
placement *overlay.PlacementDefinitions,
console consoleweb.Config,
config Config,
) *Server {
@ -178,7 +183,7 @@ func NewServer(
// NewServer adds the backoffice.PahtPrefix for the static assets, but not for the API because the
// generator already add the PathPrefix to router when the API handlers are hooked.
_ = backoffice.NewServer(log.Named("back-office"), nil, root, config.BackOffice)
_ = backoffice.NewServer(log.Named("back-office"), nil, placement, root, config.BackOffice)
// This handler must be the last one because it uses the root as prefix,
// otherwise will try to serve all the handlers set after this one.