Fix source-group API, backup validation, and orphaned jobs #57
@@ -153,6 +153,8 @@ func TestSourceGroupsCRUD(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"retry_max": 3,
|
"retry_max": 3,
|
||||||
"retry_backoff_seconds": 60,
|
"retry_backoff_seconds": 60,
|
||||||
|
"pre_hook": "prepare-db",
|
||||||
|
"post_hook": "resume-db",
|
||||||
}, cookie)
|
}, cookie)
|
||||||
if status != 201 {
|
if status != 201 {
|
||||||
t.Fatalf("create status: %d, body: %+v", status, body)
|
t.Fatalf("create status: %d, body: %+v", status, body)
|
||||||
@@ -161,6 +163,16 @@ func TestSourceGroupsCRUD(t *testing.T) {
|
|||||||
if gid == "" {
|
if gid == "" {
|
||||||
t.Fatalf("create: no id returned: %+v", body)
|
t.Fatalf("create: no id returned: %+v", body)
|
||||||
}
|
}
|
||||||
|
if body["has_pre_hook"] != true || body["has_post_hook"] != true {
|
||||||
|
t.Fatalf("create hook indicators: %+v", body)
|
||||||
|
}
|
||||||
|
stored, err := st.GetSourceGroup(context.Background(), hostID, gid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get stored group: %v", err)
|
||||||
|
}
|
||||||
|
if stored.PreHook == "prepare-db" || stored.PostHook == "resume-db" || stored.PreHook == "" || stored.PostHook == "" {
|
||||||
|
t.Fatalf("hooks not encrypted at rest: pre=%q post=%q", stored.PreHook, stored.PostHook)
|
||||||
|
}
|
||||||
|
|
||||||
// Duplicate name → 409.
|
// Duplicate name → 409.
|
||||||
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
|
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
|
||||||
@@ -185,6 +197,20 @@ func TestSourceGroupsCRUD(t *testing.T) {
|
|||||||
if got := body["name"]; got != "system" {
|
if got := body["name"]; got != "system" {
|
||||||
t.Errorf("rename: got %v want system", got)
|
t.Errorf("rename: got %v want system", got)
|
||||||
}
|
}
|
||||||
|
stored, _ = st.GetSourceGroup(context.Background(), hostID, gid)
|
||||||
|
if stored.PreHook == "" || stored.PostHook == "" {
|
||||||
|
t.Fatal("PUT without hook fields cleared existing hooks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit empty hook clears only that hook; plaintext is never returned.
|
||||||
|
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/source-groups/"+gid,
|
||||||
|
map[string]any{"name": "system", "includes": []string{"/etc"}, "pre_hook": ""}, cookie)
|
||||||
|
if status != 200 || body["has_pre_hook"] != false || body["has_post_hook"] != true {
|
||||||
|
t.Fatalf("clear hook status=%d body=%+v", status, body)
|
||||||
|
}
|
||||||
|
if _, exposed := body["post_hook"]; exposed {
|
||||||
|
t.Fatal("source-group response exposed hook plaintext field")
|
||||||
|
}
|
||||||
|
|
||||||
// Delete.
|
// Delete.
|
||||||
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
|
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type sourceGroupView struct {
|
|||||||
RetryMax int `json:"retry_max"`
|
RetryMax int `json:"retry_max"`
|
||||||
RetryBackoffSeconds int `json:"retry_backoff_seconds"`
|
RetryBackoffSeconds int `json:"retry_backoff_seconds"`
|
||||||
ConflictDimension string `json:"conflict_dimension,omitempty"`
|
ConflictDimension string `json:"conflict_dimension,omitempty"`
|
||||||
|
HasPreHook bool `json:"has_pre_hook"`
|
||||||
|
HasPostHook bool `json:"has_post_hook"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -49,6 +51,8 @@ func toSourceGroupView(g store.SourceGroup) sourceGroupView {
|
|||||||
RetryMax: g.RetryMax,
|
RetryMax: g.RetryMax,
|
||||||
RetryBackoffSeconds: g.RetryBackoffSeconds,
|
RetryBackoffSeconds: g.RetryBackoffSeconds,
|
||||||
ConflictDimension: g.ConflictDimension,
|
ConflictDimension: g.ConflictDimension,
|
||||||
|
HasPreHook: g.PreHook != "",
|
||||||
|
HasPostHook: g.PostHook != "",
|
||||||
CreatedAt: g.CreatedAt,
|
CreatedAt: g.CreatedAt,
|
||||||
UpdatedAt: g.UpdatedAt,
|
UpdatedAt: g.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -62,6 +66,29 @@ type sourceGroupWriteRequest struct {
|
|||||||
RetentionPolicy store.RetentionPolicy `json:"retention_policy"`
|
RetentionPolicy store.RetentionPolicy `json:"retention_policy"`
|
||||||
RetryMax int `json:"retry_max"`
|
RetryMax int `json:"retry_max"`
|
||||||
RetryBackoffSeconds int `json:"retry_backoff_seconds"`
|
RetryBackoffSeconds int `json:"retry_backoff_seconds"`
|
||||||
|
// Pointer fields distinguish omission (preserve on PUT) from an explicit
|
||||||
|
// empty string (clear). Hook plaintext is accepted on write but never
|
||||||
|
// returned by the JSON API.
|
||||||
|
PreHook *string `json:"pre_hook,omitempty"`
|
||||||
|
PostHook *string `json:"post_hook,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) applySourceGroupHooks(hostID string, req sourceGroupWriteRequest, g *store.SourceGroup) error {
|
||||||
|
if req.PreHook != nil {
|
||||||
|
enc, err := s.EncryptHookForGroup(hostID, "pre", *req.PreHook)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.PreHook = enc
|
||||||
|
}
|
||||||
|
if req.PostHook != nil {
|
||||||
|
enc, err := s.EncryptHookForGroup(hostID, "post", *req.PostHook)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.PostHook = enc
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListSourceGroups(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
func (s *Server) handleListSourceGroups(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
@@ -142,6 +169,10 @@ func (s *Server) handleCreateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Re
|
|||||||
RetryMax: req.RetryMax,
|
RetryMax: req.RetryMax,
|
||||||
RetryBackoffSeconds: req.RetryBackoffSeconds,
|
RetryBackoffSeconds: req.RetryBackoffSeconds,
|
||||||
}
|
}
|
||||||
|
if err := s.applySourceGroupHooks(hostID, req, &g); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "hook_encryption_failed", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil {
|
if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil {
|
||||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||||
return
|
return
|
||||||
@@ -157,7 +188,8 @@ func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Re
|
|||||||
}
|
}
|
||||||
hostID := chi.URLParam(r, "id")
|
hostID := chi.URLParam(r, "id")
|
||||||
groupID := chi.URLParam(r, "gid")
|
groupID := chi.URLParam(r, "gid")
|
||||||
if _, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID); err != nil {
|
existingGroup, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID)
|
||||||
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "")
|
writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "")
|
||||||
return
|
return
|
||||||
@@ -188,6 +220,12 @@ func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Re
|
|||||||
RetentionPolicy: req.RetentionPolicy,
|
RetentionPolicy: req.RetentionPolicy,
|
||||||
RetryMax: req.RetryMax,
|
RetryMax: req.RetryMax,
|
||||||
RetryBackoffSeconds: req.RetryBackoffSeconds,
|
RetryBackoffSeconds: req.RetryBackoffSeconds,
|
||||||
|
PreHook: existingGroup.PreHook,
|
||||||
|
PostHook: existingGroup.PostHook,
|
||||||
|
}
|
||||||
|
if err := s.applySourceGroupHooks(hostID, req, &g); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "hook_encryption_failed", "")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil {
|
if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil {
|
||||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||||
|
|||||||
Reference in New Issue
Block a user