satellite/admin: Create separate server for new back-office

Create a separate server for implementing the new satellite
administration web app.

This server is in a new package that will implement all the
functionality for the new satellite administration back-end and when it
be completed with all the functionality that the current one offer, it
will replace it.

For now, the new server only exposes the static assets as they were
exposed by the current server.

A main sub-package is added with an example endpoint to scaffold where
we'll define the API through the API generator and to locate the several
generated files.

Change-Id: I172c43b2c180553876ef7ce137cc778b94723451
This commit is contained in:
Ivan Fraixedes 2023-10-24 20:17:03 +02:00
parent 14b83bb390
commit ae945b993a
No known key found for this signature in database
GPG Key ID: FB6101AFB5CB5AD5
11 changed files with 490 additions and 20 deletions

View File

@ -0,0 +1,26 @@
# API Docs
**Description:**
**Version:** `v1`
<h2 id='list-of-endpoints'>List of Endpoints</h2>
* Example
* [Get examples](#example-get-examples)
<h3 id='example-get-examples'>Get examples (<a href='#list-of-endpoints'>go to full list</a>)</h3>
Get a list with the names of the all available examples
`GET /api/v1/example/examples`
**Response body:**
```typescript
[
string
]
```

View File

@ -0,0 +1,63 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
// Package main defines the satellite administration API through the API generator and generates
// source code of the API server handlers and clients and the documentation markdown document.
package main
import (
"os"
"path/filepath"
"storj.io/storj/private/apigen"
)
func main() {
api := &apigen.API{PackageName: "admin", Version: "v1", BasePath: "/api"}
// This is an example and must be deleted when we define the first real endpoint.
group := api.Group("Example", "example")
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,
})
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"))
}
func findModuleRootDir() string {
dir, err := os.Getwd()
if err != nil {
panic("unable to find current working directory")
}
start := dir
for i := 0; i < 100; i++ {
if fileExists(filepath.Join(dir, "go.mod")) {
return dir
}
next := filepath.Dir(dir)
if next == dir {
break
}
dir = next
}
panic("unable to find go.mod starting from " + start)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

View File

@ -0,0 +1,71 @@
// AUTOGENERATED BY private/apigen
// DO NOT EDIT.
package admin
import (
"context"
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/spacemonkeygo/monkit/v3"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/storj/private/api"
)
var ErrExampleAPI = errs.Class("admin example api")
type ExampleService interface {
GetExamples(ctx context.Context) ([]string, api.HTTPError)
}
// ExampleHandler is an api handler that implements all Example API endpoints functionality.
type ExampleHandler struct {
log *zap.Logger
mon *monkit.Scope
service ExampleService
auth api.Auth
}
func NewExample(log *zap.Logger, mon *monkit.Scope, service ExampleService, router *mux.Router, auth api.Auth) *ExampleHandler {
handler := &ExampleHandler{
log: log,
mon: mon,
service: service,
auth: auth,
}
exampleRouter := router.PathPrefix("/api/v1/example").Subrouter()
exampleRouter.HandleFunc("/examples", handler.handleGetExamples).Methods("GET")
return handler
}
func (h *ExampleHandler) handleGetExamples(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)
if httpErr.Err != nil {
api.ServeError(h.log, w, httpErr.Status, httpErr.Err)
return
}
err = json.NewEncoder(w).Encode(retVal)
if err != nil {
h.log.Debug("failed to write json GetExamples response", zap.Error(ErrExampleAPI.Wrap(err)))
}
}

View File

@ -0,0 +1,127 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
// Package admin implements a server which serves a REST API and a web application to allow
// performing satellite administration tasks.
//
// NOTE this is work in progress and will eventually replace the current satellite administration
// server implemented in the parent package, hence this package name is the same than its parent
// because it will simplify the replace once it's ready.
package admin
import (
"context"
"errors"
"net"
"net/http"
"github.com/gorilla/mux"
"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"
)
// Error is the error class that wraps all the errors returned by this package.
var Error = errs.Class("satellite-admin")
// Config defines configuration for the satellite administration server.
type Config struct {
StaticDir string `help:"an alternate directory path which contains the static assets for the satellite administration web app. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""`
}
// 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
config Config
}
// ParentRouter is mux.Router with its full path prefix.
type ParentRouter struct {
Router *mux.Router
PathPrefix string
}
// NewServer creates a satellite administration server instance with the provided dependencies and
// configurations.
//
// When listener is nil, Server.Run is a noop.
//
// When parentRouter is nil it creates a new Router to attach the server endpoints, otherwise , it
// attaches them to the provided one, allowing to expose its functionality through another server.
func NewServer(log *zap.Logger, listener net.Listener, parentRouter *ParentRouter, config Config) *Server {
server := &Server{
log: log,
listener: listener,
config: config,
}
if parentRouter == nil {
parentRouter = &ParentRouter{}
}
root := parentRouter.Router
if root == nil {
root = mux.NewRouter()
}
// API endpoints.
// api := root.PathPrefix("/api/").Subrouter()
// Static assets for the web interface.
// This handler must be the last one because it uses the root as prefix, otherwise, it will serve
// all the paths defined by the handlers set after this one.
var staticHandler http.Handler
if config.StaticDir == "" {
if parentRouter.PathPrefix != "" {
staticHandler = http.StripPrefix("/back-office/", http.FileServer(http.FS(ui.Assets)))
} else {
staticHandler = http.FileServer(http.FS(ui.Assets))
}
} else {
if parentRouter.PathPrefix != "" {
staticHandler = http.StripPrefix("/back-office/", http.FileServer(http.Dir(config.StaticDir)))
} else {
staticHandler = http.FileServer(http.Dir(config.StaticDir))
}
}
root.PathPrefix("/").Handler(staticHandler).Methods("GET")
return server
}
// Run starts the administration HTTP server using the provided listener.
// If listener is nil, it does nothing and return nil.
func (server *Server) Run(ctx context.Context) error {
if server.listener == nil {
return nil
}
ctx, cancel := context.WithCancel(ctx)
var group errgroup.Group
group.Go(func() error {
<-ctx.Done()
return Error.Wrap(server.server.Shutdown(context.Background()))
})
group.Go(func() error {
defer cancel()
err := server.server.Serve(server.listener)
if errs2.IsCanceled(err) || errors.Is(err, http.ErrServerClosed) {
err = nil
}
return Error.Wrap(err)
})
return group.Wait()
}
// Close closes server and underlying listener.
func (server *Server) Close() error {
return Error.Wrap(server.server.Close())
}

View File

@ -0,0 +1,39 @@
// 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

@ -0,0 +1,28 @@
// AUTOGENERATED BY private/apigen
// DO NOT EDIT.
import { HttpClient } from '@/utils/httpClient';
class APIError extends Error {
constructor(
public readonly msg: string,
public readonly responseStatusCode?: number,
) {
super(msg);
}
}
export class ExampleHttpApiV1 {
private readonly http: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/api/v1/example';
public async getExamples(): Promise<string[]> {
const fullPath = `${this.ROOT_PATH}/examples`;
const response = await this.http.get(fullPath);
if (response.ok) {
return response.json().then((body) => body as string[]);
}
const err = await response.json();
throw new APIError(err.error, response.status);
}
}

View File

@ -0,0 +1,11 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
/**
* ErrorUnauthorized is a custom error type for performing unauthorized operations.
*/
export class ErrorUnauthorized extends Error {
public constructor(message = 'Authorization required') {
super(message);
}
}

View File

@ -0,0 +1,106 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
/**
* HttpClient is a custom wrapper around fetch api.
* Exposes get, post and delete methods for JSON strings.
*/
export class HttpClient {
/**
*
* @param method holds http method type
* @param path
* @param body serialized JSON
*/
private async sendJSON(method: string, path: string, body: string | null): Promise<Response> {
const request: RequestInit = {
method: method,
body: body,
};
request.headers = {
'Content-Type': 'application/json',
};
const response = await fetch(path, request);
if (response.status === 401) {
await this.handleUnauthorized();
throw new ErrorUnauthorized();
}
return response;
}
/**
* Performs POST http request with JSON body.
* @param path
* @param body serialized JSON
*/
public async post(path: string, body: string | null): Promise<Response> {
return this.sendJSON('POST', path, body);
}
/**
* Performs PATCH http request with JSON body.
* @param path
* @param body serialized JSON
*/
public async patch(path: string, body: string | null): Promise<Response> {
return this.sendJSON('PATCH', path, body);
}
/**
* Performs PUT http request with JSON body.
* @param path
* @param body serialized JSON
*/
public async put(path: string, body: string | null): Promise<Response> {
return this.sendJSON('PUT', path, body);
}
/**
* Performs GET http request.
* @param path
*/
public async get(path: string): Promise<Response> {
return this.sendJSON('GET', path, null);
}
/**
* Performs DELETE http request.
* @param path
* @param body serialized JSON
*/
public async delete(path: string, body: string | null = null): Promise<Response> {
return this.sendJSON('DELETE', path, body);
}
/**
* Handles unauthorized actions.
* Call logout and redirect to login.
*/
private async handleUnauthorized(): Promise<void> {
try {
const logoutPath = '/api/v0/auth/logout';
const request: RequestInit = {
method: 'POST',
body: null,
};
request.headers = {
'Content-Type': 'application/json',
};
await fetch(logoutPath, request);
// eslint-disable-next-line no-empty
} catch (error) {}
setTimeout(() => {
if (!window.location.href.includes('/login')) {
window.location.href = window.location.origin + '/login';
}
}, 2000);
}
}

View File

@ -20,7 +20,7 @@ import (
"storj.io/common/errs2" "storj.io/common/errs2"
"storj.io/storj/satellite/accounting" "storj.io/storj/satellite/accounting"
backofficeui "storj.io/storj/satellite/admin/back-office/ui" backoffice "storj.io/storj/satellite/admin/back-office"
adminui "storj.io/storj/satellite/admin/ui" adminui "storj.io/storj/satellite/admin/ui"
"storj.io/storj/satellite/analytics" "storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/attribution" "storj.io/storj/satellite/attribution"
@ -44,13 +44,13 @@ const (
// Config defines configuration for debug server. // Config defines configuration for debug server.
type Config struct { type Config struct {
Address string `help:"admin peer http listening address" releaseDefault:"" devDefault:""` Address string `help:"admin peer http listening address" releaseDefault:"" devDefault:""`
StaticDir string `help:"an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""` StaticDir string `help:"an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""`
StaticDirBackOffice string `help:"an alternate directory path which contains the static assets for the currently in development back-office. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""` AllowedOauthHost string `help:"the oauth host allowed to bypass token authentication."`
AllowedOauthHost string `help:"the oauth host allowed to bypass token authentication."` Groups Groups
Groups Groups
AuthorizationToken string `internal:"true"` AuthorizationToken string `internal:"true"`
BackOffice backoffice.Config
} }
// Groups defines permission groups. // Groups defines permission groups.
@ -178,16 +178,15 @@ func NewServer(
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET") limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET")
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST") limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST")
// Temporary path until the new back-office is implemented and we can remove the current admin UI. _ = backoffice.NewServer(
if config.StaticDirBackOffice == "" { log.Named("back-office"),
root.PathPrefix("/back-office").Handler( nil,
http.StripPrefix("/back-office/", http.FileServer(http.FS(backofficeui.Assets))), &backoffice.ParentRouter{
).Methods("GET") Router: root.PathPrefix("/back-office/").Subrouter(),
} else { PathPrefix: "/back-office",
root.PathPrefix("/back-office").Handler( },
http.StripPrefix("/back-office/", http.FileServer(http.Dir(config.StaticDirBackOffice))), config.BackOffice,
).Methods("GET") )
}
// This handler must be the last one because it uses the root as prefix, // 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. // otherwise will try to serve all the handlers set after this one.

View File

@ -28,7 +28,7 @@ func TestBasic(t *testing.T) {
Satellite: func(_ *zap.Logger, _ int, config *satellite.Config) { Satellite: func(_ *zap.Logger, _ int, config *satellite.Config) {
config.Admin.Address = "127.0.0.1:0" config.Admin.Address = "127.0.0.1:0"
config.Admin.StaticDir = "ui" config.Admin.StaticDir = "ui"
config.Admin.StaticDirBackOffice = "back-office/ui" config.Admin.BackOffice.StaticDir = "back-office/ui"
}, },
}, },
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {

View File

@ -16,15 +16,15 @@
# the oauth host allowed to bypass token authentication. # the oauth host allowed to bypass token authentication.
# admin.allowed-oauth-host: "" # admin.allowed-oauth-host: ""
# an alternate directory path which contains the static assets for the satellite administration web app. When empty, it uses the embedded assets
# admin.back-office.static-dir: ""
# the group which is only allowed to update user and project limits and freeze and unfreeze accounts. # the group which is only allowed to update user and project limits and freeze and unfreeze accounts.
# admin.groups.limit-update: "" # admin.groups.limit-update: ""
# an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets # an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets
# admin.static-dir: "" # admin.static-dir: ""
# an alternate directory path which contains the static assets for the currently in development back-office. When empty, it uses the embedded assets
# admin.static-dir-back-office: ""
# enable analytics reporting # enable analytics reporting
# analytics.enabled: false # analytics.enabled: false