diff --git a/cli/command_server.go b/cli/command_server.go new file mode 100644 index 000000000..315d87634 --- /dev/null +++ b/cli/command_server.go @@ -0,0 +1,27 @@ +package cli + +import ( + "context" + "net/http" + + "github.com/kopia/kopia/internal/server" + "github.com/kopia/kopia/repo" + "github.com/rs/zerolog/log" +) + +var ( + serverCommand = app.Command("server", "Start Kopia server") + serverCommandListenAddress = serverCommand.Flag("--listen", "Listen address").Default("127.0.0.1:51515").String() +) + +func init() { + serverCommand.Action(repositoryAction(runServer)) +} + +func runServer(ctx context.Context, rep *repo.Repository) error { + srv := server.New(rep) + url := "http://" + *serverCommandListenAddress + log.Info().Msgf("starting server on %v", url) + http.Handle("/api/", srv.APIHandlers()) + return http.ListenAndServe(*serverCommandListenAddress, nil) +} diff --git a/internal/server/api_error.go b/internal/server/api_error.go new file mode 100644 index 000000000..7817ba4d7 --- /dev/null +++ b/internal/server/api_error.go @@ -0,0 +1,18 @@ +package server + +import ( + "fmt" +) + +type apiError struct { + code int + message string +} + +func malformedRequestError() *apiError { + return &apiError{400, "malformed request"} +} + +func internalServerError(err error) *apiError { + return &apiError{500, fmt.Sprintf("internal server error: %v", err)} +} diff --git a/internal/server/api_snapshot_list.go b/internal/server/api_snapshot_list.go new file mode 100644 index 000000000..bd331e13d --- /dev/null +++ b/internal/server/api_snapshot_list.go @@ -0,0 +1,66 @@ +package server + +import ( + "net/http" + "time" + + "github.com/kopia/kopia/fs" + "github.com/kopia/kopia/snapshot" +) + +type snapshotListEntry struct { + ID string `json:"id"` + Source snapshot.SourceInfo `json:"source"` + Description string `json:"description"` + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + IncompleteReason string `json:"incomplete,omitempty"` + Summary *fs.DirectorySummary `json:"summary"` + RootEntry string `json:"rootID"` + RetentionReasons []string `json:"retention"` +} + +type snapshotListResponse struct { + Snapshots []*snapshotListEntry `json:"snapshots"` +} + +func (s *Server) handleSourceSnapshotList(r *http.Request) (interface{}, *apiError) { + mgr := snapshot.NewManager(s.rep) + pmgr := snapshot.NewPolicyManager(s.rep) + + manifestIDs := mgr.ListSnapshotManifests(nil) + manifests, err := mgr.LoadSnapshots(manifestIDs) + if err != nil { + return nil, internalServerError(err) + } + + resp := &snapshotListResponse{} + + groups := snapshot.GroupBySource(manifests) + + for _, grp := range groups { + pol, err := pmgr.GetEffectivePolicy(grp[0].Source) + if err == nil { + pol.RetentionPolicy.ComputeRetentionReasons(grp) + } + for _, m := range grp { + resp.Snapshots = append(resp.Snapshots, convertSnapshotManifest(m)) + } + } + + return resp, nil +} + +func convertSnapshotManifest(m *snapshot.Manifest) *snapshotListEntry { + return &snapshotListEntry{ + ID: m.ID, + Source: m.Source, + Description: m.Description, + StartTime: m.StartTime, + EndTime: m.EndTime, + IncompleteReason: m.IncompleteReason, + RootEntry: m.RootObjectID().String(), + Summary: m.RootEntry.DirSummary, + RetentionReasons: m.RetentionReasons, + } +} diff --git a/internal/server/api_sources_list.go b/internal/server/api_sources_list.go new file mode 100644 index 000000000..99399efcd --- /dev/null +++ b/internal/server/api_sources_list.go @@ -0,0 +1,26 @@ +package server + +import ( + "net/http" + "sort" + + "github.com/kopia/kopia/snapshot" +) + +type sourcesListResponse struct { + Sources []snapshot.SourceInfo `json:"sources"` +} + +func (s *Server) handleSourcesList(r *http.Request) (interface{}, *apiError) { + mgr := snapshot.NewManager(s.rep) + + resp := &sourcesListResponse{ + Sources: mgr.ListSources(), + } + + sort.Slice(resp.Sources, func(i, j int) bool { + return resp.Sources[i].String() < resp.Sources[j].String() + }) + + return resp, nil +} diff --git a/internal/server/api_status.go b/internal/server/api_status.go new file mode 100644 index 000000000..8f7605334 --- /dev/null +++ b/internal/server/api_status.go @@ -0,0 +1,27 @@ +package server + +import ( + "net/http" + + "github.com/kopia/kopia/block" +) + +type statusResponse struct { + ConfigFile string `json:"configFile"` + CacheDir string `json:"cacheDir"` + BlockFormatting block.FormattingOptions `json:"blockFormatting"` + Storage string `json:"storage"` +} + +func (s *Server) handleStatus(r *http.Request) (interface{}, *apiError) { + bf := s.rep.Blocks.Format + bf.HMACSecret = nil + bf.MasterKey = nil + + return &statusResponse{ + ConfigFile: s.rep.ConfigFile, + CacheDir: s.rep.CacheDirectory, + BlockFormatting: bf, + Storage: s.rep.Storage.ConnectionInfo().Type, + }, nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 000000000..942ec0e68 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,44 @@ +package server + +import ( + "encoding/json" + "net/http" + "sync" + + "github.com/kopia/kopia/repo" + + "github.com/bmizerany/pat" +) + +type Server struct { + mu sync.RWMutex + + rep *repo.Repository +} + +func (s *Server) APIHandlers() http.Handler { + p := pat.New() + p.Get("/api/v1/status", s.handleAPI(s.handleStatus)) + p.Get("/api/v1/sources", s.handleAPI(s.handleSourcesList)) + p.Get("/api/v1/snapshots", s.handleAPI(s.handleSourceSnapshotList)) + return p +} + +func (s *Server) handleAPI(f func(r *http.Request) (interface{}, *apiError)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + v, err := f(r) + w.Header().Set("Content-Type", "application/json") + e := json.NewEncoder(w) + e.SetIndent("", " ") + if err == nil { + e.Encode(v) + return + } + + http.Error(w, err.message, err.code) + }) +} + +func New(rep *repo.Repository) *Server { + return &Server{rep: rep} +} diff --git a/snapshot/retention_policy.go b/snapshot/retention_policy.go index 08444ca5b..5876c0255 100644 --- a/snapshot/retention_policy.go +++ b/snapshot/retention_policy.go @@ -50,7 +50,7 @@ func (r *RetentionPolicy) getRetentionReasons(i int, s *Manifest, cutoff cutoffT return nil } - var keepReasons []string + keepReasons := []string{} var zeroTime time.Time yyyy, wk := s.StartTime.ISOWeek()