Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
136 lines
5.0 KiB
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
|
|
}
|