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 }