Files
nmon/internal/api/admin.go

136 lines
5.0 KiB
Go

package api
import (
"context"
"errors"
"io"
"net/http"
"os"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/v10networks/nmon/internal/vmsup"
)
// ErrBackupRefused means a backup was refused because the disk is below the reserve.
var ErrBackupRefused = errors.New("backup refused")
// BackupFile is a finished backup waiting to be streamed to the caller.
type BackupFile struct {
Path string
KeyIncluded bool
// Done deletes the file once it has been sent.
Done func()
}
// BackupRunner runs one online backup into a temporary file.
type BackupRunner interface {
Create(ctx context.Context, includeKey bool) (BackupFile, error)
}
// StoreRestarter resumes a metric store that gave up after repeated crashes. *vmsup.Supervisor implements it.
type StoreRestarter interface{ Restart(name string) error }
// backupOut streams the archive. KeyIncluded repeats what the archive's manifest says, so a client can print it.
type backupOut struct {
ContentType string `header:"Content-Type"`
Disposition string `header:"Content-Disposition"`
KeyIncluded string `header:"X-Nmon-Key-Included"`
Body func(ctx huma.Context)
}
func (s *server) registerAdmin(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "create-backup", Method: http.MethodPost, Path: "/api/v1/admin/backup", Tags: []string{"admin"},
Summary: "Take a backup and download it",
Description: "Admin only. Runs nmon's online backup (inventory snapshot, both metric stores' snapshots, config and profiles) " +
"and streams the tar. The X-Nmon-Key-Included header says whether the encryption key is inside. `nmon backup` is the client.",
Responses: map[string]*huma.Response{"200": {
Description: "The backup archive.",
Content: map[string]*huma.MediaType{"application/x-tar": {}},
}},
Middlewares: huma.Middlewares{noWriteDeadline},
Errors: []int{http.StatusInsufficientStorage, http.StatusServiceUnavailable},
}, s.createBackup)
huma.Register(api, huma.Operation{
OperationID: "restart-store", Method: http.MethodPost, Path: "/api/v1/admin/stores/{name}/restart", Tags: []string{"admin"},
Summary: "Resume a failed metric store", DefaultStatus: http.StatusNoContent,
Description: "Admin only. After store_failed the supervisor stopped restarting the store; this starts it again. " +
"A store that has not failed is a 409.",
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable},
}, s.restartStore)
}
// noWriteDeadline lifts the server's write timeout for one request: a backup can take longer than it.
func noWriteDeadline(ctx huma.Context, next func(huma.Context)) {
if _, w := humago.Unwrap(ctx); w != nil {
_ = http.NewResponseController(w).SetWriteDeadline(time.Time{}) // unsupported writers keep the default
}
next(ctx)
}
func (s *server) createBackup(ctx context.Context, in *struct {
IncludeKey bool `query:"include_key" doc:"Put the encryption key in the archive. Default false."`
}) (*backupOut, error) {
if s.Backups == nil {
return nil, errNotConfigured("backup")
}
bf, err := s.Backups.Create(ctx, in.IncludeKey)
if errors.Is(err, ErrBackupRefused) {
return nil, huma.NewError(http.StatusInsufficientStorage, trimSentinel(err, ErrBackupRefused))
}
if err != nil {
s.Logger.Error("backup", "err", err)
return nil, huma.Error500InternalServerError("backup failed; see the log")
}
detail := map[string]bool{"key_included": bf.KeyIncluded}
if err := s.Store.AppendAudit(ctx, actorOf(ctx), "backup.create", "backup", nil, detail); err != nil {
s.Logger.Warn("audit backup.create", "err", err)
}
key := "false"
if bf.KeyIncluded {
key = "true"
}
return &backupOut{
ContentType: "application/x-tar", Disposition: `attachment; filename="nmon-backup.tar"`, KeyIncluded: key,
Body: func(hc huma.Context) { s.streamBackup(hc, bf) },
}, nil
}
func (s *server) streamBackup(hc huma.Context, bf BackupFile) {
defer bf.Done()
f, err := os.Open(bf.Path)
if err != nil {
s.Logger.Error("open backup for download", "err", err)
return
}
defer func() { _ = f.Close() }()
if _, err := io.Copy(hc.BodyWriter(), f); err != nil {
s.Logger.Warn("stream backup", "err", err)
}
}
func (s *server) restartStore(ctx context.Context, in *struct {
Name string `path:"name" enum:"hot,long" doc:"Which metric store."`
}) (*struct{}, error) {
if s.Stores == nil {
return nil, errNotConfigured("metric store supervisor")
}
err := s.Stores.Restart(in.Name)
switch {
case errors.Is(err, vmsup.ErrUnknownStore):
return nil, huma.Error404NotFound("no such store")
case errors.Is(err, vmsup.ErrNotFailed), errors.Is(err, vmsup.ErrExternalStore):
return nil, huma.Error409Conflict(err.Error())
case err != nil:
s.Logger.Error("restart store", "err", err)
return nil, huma.Error500InternalServerError("restart failed; see the log")
}
if err := s.Store.AppendAudit(ctx, actorOf(ctx), "store.restart", "store:"+in.Name, nil, nil); err != nil {
s.Logger.Warn("audit store.restart", "err", err)
}
return &struct{}{}, nil
}