diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 56ca745..486e863 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2922,6 +2922,57 @@ paths: default: $ref: "#/components/responses/DefaultProblem" + /v1/communities/{id}/prefixes: + parameters: + - $ref: "#/components/parameters/TenantId" + - $ref: "#/components/parameters/CommunityId" + - $ref: "#/components/parameters/Cursor" + - name: limit + in: query + schema: + type: integer + default: 500 + maximum: 5000 + get: + tags: [Communities] + summary: Префиксы community (latest revision per module) + description: | + Уникальные materialized-префиксы с данным community_id + из последней ревизии каждого модуля tenant. + Поле `prefixes` — плоский список для клиентов вроде EvoFirewall. + operationId: listCommunityPrefixes + responses: + "200": + description: Успешно. + content: + application/json: + schema: + type: object + required: [items, has_more] + properties: + items: + type: array + items: + type: object + required: [prefix] + properties: + prefix: + type: string + source: + type: string + prefixes: + type: array + items: + type: string + next_cursor: + type: ["string", "null"] + has_more: + type: boolean + "404": + $ref: "#/components/responses/NotFound" + default: + $ref: "#/components/responses/DefaultProblem" + /v1/communities/{id}: parameters: - $ref: "#/components/parameters/TenantId" diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index f233da7..716cf58 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -58,6 +58,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) { m.HandleFunc("GET /communities", s.handleListComm) m.HandleFunc("POST /communities", s.handlePostComm) m.HandleFunc("GET /communities/{id}", s.handleGetComm) + m.HandleFunc("GET /communities/{id}/prefixes", s.handleListCommPrefixes) m.HandleFunc("PATCH /communities/{id}", s.handlePatchComm) m.HandleFunc("DELETE /communities/{id}", s.handleDeleteComm) @@ -945,6 +946,39 @@ func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, commJSON(x)) } +func (s *Server) handleListCommPrefixes(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok || !s.requirePerm(w, a, "bgp:directories:read") { + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit == 0 { + limit = 500 + } + cursor := r.URL.Query().Get("cursor") + rows, next, more, err := s.store.ListCommunityPrefixes(a.TenantID, r.PathValue("id"), cursor, limit) + if err != nil { + writeStoreErr(w, err) + return + } + items := make([]map[string]any, 0, len(rows)) + prefixes := make([]string, 0, len(rows)) + for _, pr := range rows { + m := map[string]any{"prefix": pr.Prefix} + if pr.Source != "" { + m["source"] = pr.Source + } + items = append(items, m) + prefixes = append(prefixes, pr.Prefix) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, + "prefixes": prefixes, + "next_cursor": strPtrOrNull(next), + "has_more": more, + }) +} + func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requirePerm(w, a, "bgp:directories:write") { diff --git a/internal/repository/postgres.go b/internal/repository/postgres.go index a468c34..e043033 100644 --- a/internal/repository/postgres.go +++ b/internal/repository/postgres.go @@ -1252,6 +1252,91 @@ func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) { return &c, nil } +func (p *Postgres) ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) ([]store.PrefixRow, string, bool, error) { + if _, err := p.GetCommunity(tenantID, communityID); err != nil { + return nil, "", false, err + } + if limit <= 0 { + limit = 500 + } + if limit > 5000 { + limit = 5000 + } + off := 0 + if cursor != "" { + if n, err := strconv.Atoi(cursor); err == nil && n >= 0 { + off = n + } + } + ctx := context.Background() + useSnap := prefixSnapshotTableExists(ctx, p.pool) + var rows pgx.Rows + var err error + if useSnap { + rows, err = p.pool.Query(ctx, ` + WITH latest AS ( + SELECT DISTINCT ON (module_id) id, prefix_snapshot_id + FROM config_revision + WHERE tenant_id = $1::uuid AND module_id IS NOT NULL + ORDER BY module_id, created_at DESC + ), + combined AS ( + SELECT rmp.prefix::text AS prefix, COALESCE(rmp.source, '') AS source + FROM revision_materialized_prefix rmp + JOIN latest l ON l.id = rmp.revision_id + WHERE l.prefix_snapshot_id IS NULL AND rmp.community_id = $2::uuid + UNION + SELECT psr.prefix::text, COALESCE(psr.source, '') + FROM prefix_snapshot_row psr + JOIN latest l ON l.prefix_snapshot_id = psr.snapshot_id + WHERE l.prefix_snapshot_id IS NOT NULL AND psr.community_id = $2::uuid + ) + SELECT prefix, source FROM combined + ORDER BY prefix + LIMIT $3 OFFSET $4`, tenantID, communityID, limit+1, off) + } else { + rows, err = p.pool.Query(ctx, ` + WITH latest AS ( + SELECT DISTINCT ON (module_id) id + FROM config_revision + WHERE tenant_id = $1::uuid AND module_id IS NOT NULL + ORDER BY module_id, created_at DESC + ) + SELECT DISTINCT rmp.prefix::text, COALESCE(rmp.source, '') + FROM revision_materialized_prefix rmp + JOIN latest l ON l.id = rmp.revision_id + WHERE rmp.community_id = $2::uuid + ORDER BY 1 + LIMIT $3 OFFSET $4`, tenantID, communityID, limit+1, off) + } + if err != nil { + return nil, "", false, err + } + defer rows.Close() + var all []store.PrefixRow + comm := communityID + for rows.Next() { + var pr store.PrefixRow + if err := rows.Scan(&pr.Prefix, &pr.Source); err != nil { + continue + } + pr.CommunityID = &comm + all = append(all, pr) + } + more := len(all) > limit + if more { + all = all[:limit] + } + next := "" + if more { + next = fmt.Sprintf("%d", off+limit) + } + if len(all) == 0 { + return nil, "", false, nil + } + return all, next, more, nil +} + func (p *Postgres) CreateCommunity(tenantID string, in *store.Community) (*store.Community, error) { if in == nil { return nil, store.ErrInvalidInput diff --git a/internal/store/backend.go b/internal/store/backend.go index bef881c..559c89b 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -60,6 +60,8 @@ type Backend interface { CreateCommunity(tenantID string, in *Community) (*Community, error) UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error) DeleteCommunity(tenantID, id string) error + // ListCommunityPrefixes returns unique prefixes tagged with community from latest revision per module. + ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) (prefixes []PrefixRow, nextCursor string, hasMore bool, err error) // ListPeers returns all BGP peers for a tenant (control plane may paginate in httpapi). ListPeers(tenantID string) []*BGPPeer diff --git a/internal/store/memory_crud.go b/internal/store/memory_crud.go index 02904d9..40e36af 100644 --- a/internal/store/memory_crud.go +++ b/internal/store/memory_crud.go @@ -1,6 +1,8 @@ package store import ( + "sort" + "strconv" "strings" "time" @@ -593,6 +595,74 @@ func (m *Memory) GetCommunity(tenantID, id string) (*Community, error) { return c, nil } +func (m *Memory) ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) ([]PrefixRow, string, bool, error) { + if _, err := m.GetCommunity(tenantID, communityID); err != nil { + return nil, "", false, err + } + if limit <= 0 { + limit = 500 + } + if limit > 5000 { + limit = 5000 + } + off := 0 + if cursor != "" { + if n, err := strconv.Atoi(cursor); err == nil && n >= 0 { + off = n + } + } + m.mu.RLock() + defer m.mu.RUnlock() + + latestByModule := map[string]*Revision{} + for _, rev := range m.revisions { + if rev.TenantID != tenantID || strings.TrimSpace(rev.ModuleID) == "" { + continue + } + cur := latestByModule[rev.ModuleID] + if cur == nil || rev.CreatedAt.After(cur.CreatedAt) { + latestByModule[rev.ModuleID] = rev + } + } + seen := map[string]struct{}{} + var all []PrefixRow + comm := communityID + for _, rev := range latestByModule { + for _, pr := range m.revPrefixes[rev.ID] { + if pr.CommunityID == nil || *pr.CommunityID != communityID { + continue + } + pfx := strings.TrimSpace(pr.Prefix) + if pfx == "" { + continue + } + if _, ok := seen[pfx]; ok { + continue + } + seen[pfx] = struct{}{} + all = append(all, PrefixRow{Prefix: pfx, CommunityID: &comm, Source: pr.Source}) + } + } + sort.Slice(all, func(i, j int) bool { return all[i].Prefix < all[j].Prefix }) + if off > len(all) { + return nil, "", false, nil + } + end := off + limit + more := false + next := "" + if end < len(all) { + more = true + next = strconv.Itoa(end) + all = all[off:end] + } else { + all = all[off:] + } + if len(all) == 0 { + return nil, "", false, nil + } + return all, next, more, nil +} + func (m *Memory) CreateCommunity(tenantID string, in *Community) (*Community, error) { if in == nil || strings.TrimSpace(in.Community) == "" { return nil, ErrInvalidInput