// source_groups.go — REST API for /api/hosts/{id}/source-groups. // // A source group is "what gets backed up": a named bundle of include // + exclude paths, a retention policy, and retry knobs. Group name // doubles as the snapshot tag (restic --tag ). package http import ( "encoding/json" "errors" stdhttp "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // sourceGroupView is the JSON shape returned by GET endpoints. type sourceGroupView struct { ID string `json:"id"` HostID string `json:"host_id"` Name string `json:"name"` Includes []string `json:"includes"` Excludes []string `json:"excludes"` RetentionPolicy store.RetentionPolicy `json:"retention_policy"` RetryMax int `json:"retry_max"` RetryBackoffSeconds int `json:"retry_backoff_seconds"` ConflictDimension string `json:"conflict_dimension,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func toSourceGroupView(g store.SourceGroup) sourceGroupView { includes := g.Includes if includes == nil { includes = []string{} } excludes := g.Excludes if excludes == nil { excludes = []string{} } return sourceGroupView{ ID: g.ID, HostID: g.HostID, Name: g.Name, Includes: includes, Excludes: excludes, RetentionPolicy: g.RetentionPolicy, RetryMax: g.RetryMax, RetryBackoffSeconds: g.RetryBackoffSeconds, ConflictDimension: g.ConflictDimension, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } } // sourceGroupWriteRequest is the body of POST and PUT. type sourceGroupWriteRequest struct { Name string `json:"name"` Includes []string `json:"includes"` Excludes []string `json:"excludes"` RetentionPolicy store.RetentionPolicy `json:"retention_policy"` RetryMax int `json:"retry_max"` RetryBackoffSeconds int `json:"retry_backoff_seconds"` } func (s *Server) handleListSourceGroups(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") return } hostID := chi.URLParam(r, "id") if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") return } rows, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), hostID) if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } out := make([]sourceGroupView, 0, len(rows)) for _, g := range rows { out = append(out, toSourceGroupView(g)) } writeJSON(w, stdhttp.StatusOK, struct { SourceGroups []sourceGroupView `json:"source_groups"` }{SourceGroups: out}) } func (s *Server) handleGetSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") return } hostID := chi.URLParam(r, "id") groupID := chi.URLParam(r, "gid") g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID) if err != nil { if errors.Is(err, store.ErrNotFound) { writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "") return } writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } writeJSON(w, stdhttp.StatusOK, toSourceGroupView(*g)) } func (s *Server) handleCreateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") return } hostID := chi.URLParam(r, "id") if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") return } var req sourceGroupWriteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "name required") return } // Name must be unique per host (the store has a UNIQUE constraint // but pre-check gives a friendlier error than a 500). if existing, err := s.deps.Store.GetSourceGroupByName(r.Context(), hostID, req.Name); err == nil && existing != nil { writeJSONError(w, stdhttp.StatusConflict, "name_taken", "a source group named "+req.Name+" already exists on this host") return } g := store.SourceGroup{ ID: ulid.Make().String(), HostID: hostID, Name: req.Name, Includes: req.Includes, Excludes: req.Excludes, RetentionPolicy: req.RetentionPolicy, RetryMax: req.RetryMax, RetryBackoffSeconds: req.RetryBackoffSeconds, } if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } s.pushScheduleSetAsync(hostID) writeJSON(w, stdhttp.StatusCreated, toSourceGroupView(g)) } func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") return } hostID := chi.URLParam(r, "id") groupID := chi.URLParam(r, "gid") if _, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID); err != nil { if errors.Is(err, store.ErrNotFound) { writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "") return } writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } var req sourceGroupWriteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "name required") return } // If renaming, ensure the new name doesn't collide with another group. if existing, err := s.deps.Store.GetSourceGroupByName(r.Context(), hostID, req.Name); err == nil && existing != nil && existing.ID != groupID { writeJSONError(w, stdhttp.StatusConflict, "name_taken", "a source group named "+req.Name+" already exists on this host") return } g := store.SourceGroup{ ID: groupID, HostID: hostID, Name: req.Name, Includes: req.Includes, Excludes: req.Excludes, RetentionPolicy: req.RetentionPolicy, RetryMax: req.RetryMax, RetryBackoffSeconds: req.RetryBackoffSeconds, } if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } s.pushScheduleSetAsync(hostID) out, _ := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID) if out != nil { writeJSON(w, stdhttp.StatusOK, toSourceGroupView(*out)) return } writeJSON(w, stdhttp.StatusOK, toSourceGroupView(g)) } // handleDeleteSourceGroup refuses to delete a group that is still // referenced by any schedule. Returns 409 with the schedule list so // the UI can offer "remove from these schedules first." func (s *Server) handleDeleteSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") return } hostID := chi.URLParam(r, "id") groupID := chi.URLParam(r, "gid") using, err := s.deps.Store.SchedulesUsingGroup(r.Context(), groupID) if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } if len(using) > 0 { writeJSON(w, stdhttp.StatusConflict, struct { Code string `json:"code"` Message string `json:"message"` Schedules []string `json:"schedules"` }{ Code: "group_in_use", Message: "remove this group from the listed schedules before deleting", Schedules: using, }) return } if err := s.deps.Store.DeleteSourceGroup(r.Context(), hostID, groupID); err != nil { if errors.Is(err, store.ErrNotFound) { writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "") return } writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } s.pushScheduleSetAsync(hostID) w.WriteHeader(stdhttp.StatusNoContent) }