From 82c7d9c5f78e4e8006aa39d4b1bbc96ca85eb7cb Mon Sep 17 00:00:00 2001 From: benvin Date: Sun, 19 Jul 2026 22:18:07 +1000 Subject: [PATCH] Complete CRUD: add GET-single and DELETE endpoints Add GET /{name} and DELETE /{name} for zones, address-groups, portgroups, fabrics, devices, and bindings, plus GET /rules/{id}, with the matching store Get/Delete methods (deletes bump the generation and 404 on no-match). This gives the resources full read/update/delete lifecycle so the Terraform provider can manage them. --- internal/server/resources.go | 105 +++++++++++++++++++++++++++++++++++ internal/store/store.go | 105 ++++++++++++++++++++++++++++++++++- 2 files changed, 209 insertions(+), 1 deletion(-) diff --git a/internal/server/resources.go b/internal/server/resources.go index 8b1c38e..0b52647 100644 --- a/internal/server/resources.go +++ b/internal/server/resources.go @@ -21,33 +21,72 @@ func (s *Server) mountResources(r chi.Router) { r.Route("/fabrics", func(r chi.Router) { r.Get("/", s.listFabrics) + r.Get("/{name}", s.getFabric) r.Put("/{name}", s.putFabric) + r.Delete("/{name}", s.deleteFabric) }) r.Route("/zones", func(r chi.Router) { r.Get("/", s.listZones) + r.Get("/{name}", s.getZone) r.Put("/{name}", s.putZone) + r.Delete("/{name}", s.deleteZone) }) r.Route("/address-groups", func(r chi.Router) { r.Get("/", s.listAddressGroups) + r.Get("/{name}", s.getAddressGroup) r.Put("/{name}", s.putAddressGroup) + r.Delete("/{name}", s.deleteAddressGroup) }) r.Route("/devices", func(r chi.Router) { r.Get("/", s.listDevices) + r.Get("/{name}", s.getDevice) r.Put("/{name}", s.putDevice) + r.Delete("/{name}", s.deleteDevice) r.Get("/{name}/bindings", s.listBindings) + r.Get("/{name}/bindings/{zone}", s.getBinding) r.Put("/{name}/bindings/{zone}", s.putBinding) + r.Delete("/{name}/bindings/{zone}", s.deleteBinding) }) r.Route("/portgroups", func(r chi.Router) { r.Get("/", s.listPortGroups) + r.Get("/{name}", s.getPortGroup) r.Put("/{name}", s.putPortGroup) + r.Delete("/{name}", s.deletePortGroup) }) r.Route("/rules", func(r chi.Router) { r.Get("/", s.listRules) r.Post("/", s.createRule) + r.Get("/{id}", s.getRule) r.Delete("/{id}", s.deleteRule) }) } +// respondOne writes a single resource, mapping ErrNotFound to 404. +func respondOne(w http.ResponseWriter, v any, err error) { + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, v) +} + +// respondDelete maps a delete result to 204/404/500. +func respondDelete(w http.ResponseWriter, err error) { + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) listPortGroups(w http.ResponseWriter, r *http.Request) { list, err := s.store.ListPortGroups(r.Context()) respondList(w, list, err) @@ -221,6 +260,72 @@ func (s *Server) deleteRule(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// ---- Get-single and Delete handlers ---------------------------------------- + +func (s *Server) getFabric(w http.ResponseWriter, r *http.Request) { + f, err := s.store.GetFabric(r.Context(), chi.URLParam(r, "name")) + respondOne(w, f, err) +} + +func (s *Server) deleteFabric(w http.ResponseWriter, r *http.Request) { + respondDelete(w, s.store.DeleteFabric(r.Context(), chi.URLParam(r, "name"))) +} + +func (s *Server) getZone(w http.ResponseWriter, r *http.Request) { + z, err := s.store.GetZone(r.Context(), chi.URLParam(r, "name")) + respondOne(w, z, err) +} + +func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request) { + respondDelete(w, s.store.DeleteZone(r.Context(), chi.URLParam(r, "name"))) +} + +func (s *Server) getAddressGroup(w http.ResponseWriter, r *http.Request) { + g, err := s.store.GetAddressGroup(r.Context(), chi.URLParam(r, "name")) + respondOne(w, g, err) +} + +func (s *Server) deleteAddressGroup(w http.ResponseWriter, r *http.Request) { + respondDelete(w, s.store.DeleteAddressGroup(r.Context(), chi.URLParam(r, "name"))) +} + +func (s *Server) getPortGroup(w http.ResponseWriter, r *http.Request) { + p, err := s.store.GetPortGroup(r.Context(), chi.URLParam(r, "name")) + respondOne(w, p, err) +} + +func (s *Server) deletePortGroup(w http.ResponseWriter, r *http.Request) { + respondDelete(w, s.store.DeletePortGroup(r.Context(), chi.URLParam(r, "name"))) +} + +func (s *Server) getDevice(w http.ResponseWriter, r *http.Request) { + d, err := s.store.GetDevice(r.Context(), chi.URLParam(r, "name")) + respondOne(w, d, err) +} + +func (s *Server) deleteDevice(w http.ResponseWriter, r *http.Request) { + respondDelete(w, s.store.DeleteDevice(r.Context(), chi.URLParam(r, "name"))) +} + +func (s *Server) getBinding(w http.ResponseWriter, r *http.Request) { + b, err := s.store.GetBinding(r.Context(), chi.URLParam(r, "name"), chi.URLParam(r, "zone")) + respondOne(w, b, err) +} + +func (s *Server) deleteBinding(w http.ResponseWriter, r *http.Request) { + respondDelete(w, s.store.DeleteBinding(r.Context(), chi.URLParam(r, "name"), chi.URLParam(r, "zone"))) +} + +func (s *Server) getRule(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "id must be an integer") + return + } + rule, err := s.store.GetRule(r.Context(), id) + respondOne(w, rule, err) +} + // ---- Agent endpoints ------------------------------------------------------- func (s *Server) handleDeviceConfig(w http.ResponseWriter, r *http.Request) { diff --git a/internal/store/store.go b/internal/store/store.go index 8aaedbf..42a557b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -480,8 +480,16 @@ func (s *Store) CreateRule(ctx context.Context, r model.Rule) (int64, error) { } func (s *Store) DeleteRule(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM rules WHERE id = $1`, id) +} + +// ---- Get-single and Delete completion -------------------------------------- + +// deleteOne runs a single-row delete, returning ErrNotFound when nothing matched +// and bumping the generation on success. +func (s *Store) deleteOne(ctx context.Context, query string, args ...any) error { return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - tag, err := tx.Exec(ctx, `DELETE FROM rules WHERE id = $1`, id) + tag, err := tx.Exec(ctx, query, args...) if err != nil { return err } @@ -491,3 +499,98 @@ func (s *Store) DeleteRule(ctx context.Context, id int64) error { return bump(ctx, tx) }) } + +func (s *Store) DeleteZone(ctx context.Context, name string) error { + return s.deleteOne(ctx, `DELETE FROM zones WHERE name = $1`, name) +} + +func (s *Store) DeleteFabric(ctx context.Context, name string) error { + return s.deleteOne(ctx, `DELETE FROM fabrics WHERE name = $1`, name) +} + +func (s *Store) DeleteDevice(ctx context.Context, name string) error { + return s.deleteOne(ctx, `DELETE FROM devices WHERE name = $1`, name) +} + +func (s *Store) DeletePortGroup(ctx context.Context, name string) error { + return s.deleteOne(ctx, `DELETE FROM portgroups WHERE name = $1`, name) +} + +func (s *Store) DeleteAddressGroup(ctx context.Context, name string) error { + return s.deleteOne(ctx, `DELETE FROM address_groups WHERE name = $1`, name) +} + +func (s *Store) DeleteBinding(ctx context.Context, device, zone string) error { + return s.deleteOne(ctx, `DELETE FROM bindings WHERE device = $1 AND zone = $2`, device, zone) +} + +func (s *Store) GetAddressGroup(ctx context.Context, name string) (model.AddressGroup, error) { + var g model.AddressGroup + var members, resolved []byte + err := s.pool.QueryRow(ctx, ` + SELECT name, type, members, refresh, description, resolved, resolved_at + FROM address_groups WHERE name = $1`, name, + ).Scan(&g.Name, &g.Type, &members, &g.Refresh, &g.Description, &resolved, &g.ResolvedAt) + if errors.Is(err, pgx.ErrNoRows) { + return g, ErrNotFound + } + if err != nil { + return g, err + } + if err := json.Unmarshal(members, &g.Members); err != nil { + return g, err + } + return g, json.Unmarshal(resolved, &g.Resolved) +} + +func (s *Store) GetPortGroup(ctx context.Context, name string) (model.PortGroup, error) { + var p model.PortGroup + var ports []byte + err := s.pool.QueryRow(ctx, + `SELECT name, proto, ports FROM portgroups WHERE name = $1`, name, + ).Scan(&p.Name, &p.Proto, &ports) + if errors.Is(err, pgx.ErrNoRows) { + return p, ErrNotFound + } + if err != nil { + return p, err + } + return p, json.Unmarshal(ports, &p.Ports) +} + +func (s *Store) GetBinding(ctx context.Context, device, zone string) (model.Binding, error) { + var b model.Binding + var ifaces []byte + err := s.pool.QueryRow(ctx, + `SELECT device, zone, interfaces FROM bindings WHERE device = $1 AND zone = $2`, device, zone, + ).Scan(&b.Device, &b.Zone, &ifaces) + if errors.Is(err, pgx.ErrNoRows) { + return b, ErrNotFound + } + if err != nil { + return b, err + } + return b, json.Unmarshal(ifaces, &b.Interfaces) +} + +func (s *Store) GetRule(ctx context.Context, id int64) (model.Rule, error) { + var r model.Rule + var source, dest, ports []byte + err := s.pool.QueryRow(ctx, ` + SELECT id, priority, action, source, dest, proto, COALESCE(portgroup, ''), ports, log, comment + FROM rules WHERE id = $1`, id, + ).Scan(&r.ID, &r.Priority, &r.Action, &source, &dest, &r.Proto, &r.PortGroup, &ports, &r.Log, &r.Comment) + if errors.Is(err, pgx.ErrNoRows) { + return r, ErrNotFound + } + if err != nil { + return r, err + } + if err := json.Unmarshal(source, &r.Source); err != nil { + return r, err + } + if err := json.Unmarshal(dest, &r.Dest); err != nil { + return r, err + } + return r, json.Unmarshal(ports, &r.Ports) +} -- 2.47.3