1087 lines
29 KiB
Go
1087 lines
29 KiB
Go
// Package repository implements SQL-backed store.Backend (PostgreSQL).
|
|
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"evobgp/internal/store"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Postgres implements store.Backend using pgxpool.
|
|
type Postgres struct {
|
|
pool *pgxpool.Pool
|
|
// demo IDs after seed
|
|
demoTenant, demoCDN, demoIP, demoRev, demoSpk string
|
|
}
|
|
|
|
// NewPostgres opens migrations-applied pool is assumed; seedDemo inserts demo tenant graph.
|
|
func NewPostgres(ctx context.Context, pool *pgxpool.Pool, seedDemo bool) (*Postgres, error) {
|
|
p := &Postgres{pool: pool}
|
|
if seedDemo {
|
|
if err := p.seedDemo(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (p *Postgres) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker string) {
|
|
return p.demoTenant, p.demoCDN, p.demoIP, p.demoRev, p.demoSpk
|
|
}
|
|
|
|
func (p *Postgres) MaterializedPrefixStats() (max int, sum int) {
|
|
ctx := context.Background()
|
|
rows, err := p.pool.Query(ctx, `
|
|
SELECT COALESCE((meta_json->>'materialized_prefix_count')::int, 0) AS n
|
|
FROM config_revision`)
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var n int
|
|
if rows.Scan(&n) == nil {
|
|
sum += n
|
|
if n > max {
|
|
max = n
|
|
}
|
|
}
|
|
}
|
|
return max, sum
|
|
}
|
|
|
|
func (p *Postgres) PeerCount() int {
|
|
ctx := context.Background()
|
|
var n int
|
|
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM bgp_peer`).Scan(&n)
|
|
return n
|
|
}
|
|
|
|
func (p *Postgres) PeerSessionCountsByState() map[string]int {
|
|
ctx := context.Background()
|
|
rows, err := p.pool.Query(ctx, `SELECT COALESCE(meta_json->>'session_state','unknown'), COUNT(*) FROM bgp_peer GROUP BY 1`)
|
|
if err != nil {
|
|
return map[string]int{}
|
|
}
|
|
defer rows.Close()
|
|
out := make(map[string]int)
|
|
for rows.Next() {
|
|
var st string
|
|
var c int
|
|
if rows.Scan(&st, &c) == nil {
|
|
out[st] = c
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
|
ctx := context.Background()
|
|
rows, err := p.pool.Query(ctx, `
|
|
SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text
|
|
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
var out []*store.Module
|
|
for rows.Next() {
|
|
var m store.Module
|
|
m.TenantID = tenantID
|
|
var doh, dc *string
|
|
var refresh *int32
|
|
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &m.CronExpr, &dc); err != nil {
|
|
continue
|
|
}
|
|
if refresh != nil {
|
|
m.RefreshIntervalSec = int(*refresh)
|
|
}
|
|
if doh != nil && *doh != "" {
|
|
m.DohProfileID = doh
|
|
}
|
|
if dc != nil && *dc != "" {
|
|
m.DefaultCommunityID = dc
|
|
}
|
|
out = append(out, &m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
|
|
ctx := context.Background()
|
|
var m store.Module
|
|
m.TenantID = tenantID
|
|
var doh, dc *string
|
|
var refresh *int32
|
|
err := p.pool.QueryRow(ctx, `
|
|
SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text
|
|
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
|
|
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &m.CronExpr, &dc)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, store.ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if refresh != nil {
|
|
m.RefreshIntervalSec = int(*refresh)
|
|
}
|
|
if doh != nil && *doh != "" {
|
|
m.DohProfileID = doh
|
|
}
|
|
if dc != nil && *dc != "" {
|
|
m.DefaultCommunityID = dc
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Module, error) {
|
|
if in == nil {
|
|
return nil, store.ErrInvalidInput
|
|
}
|
|
ctx := context.Background()
|
|
id := uuid.NewString()
|
|
var doh, dc any
|
|
if in.DohProfileID != nil && strings.TrimSpace(*in.DohProfileID) != "" {
|
|
doh = strings.TrimSpace(*in.DohProfileID)
|
|
}
|
|
if in.DefaultCommunityID != nil && strings.TrimSpace(*in.DefaultCommunityID) != "" {
|
|
dc = strings.TrimSpace(*in.DefaultCommunityID)
|
|
}
|
|
var ri any
|
|
if in.RefreshIntervalSec != 0 {
|
|
ri = in.RefreshIntervalSec
|
|
}
|
|
var cronArg any
|
|
if strings.TrimSpace(in.CronExpr) != "" {
|
|
cronArg = strings.TrimSpace(in.CronExpr)
|
|
}
|
|
_, err := p.pool.Exec(ctx, `
|
|
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, refresh_interval_sec, cron_expr, default_community_id)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
|
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, ri, cronArg, dc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p.GetModule(tenantID, id)
|
|
}
|
|
|
|
func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePatch) (*store.Module, error) {
|
|
if patch == nil {
|
|
return nil, store.ErrInvalidInput
|
|
}
|
|
ctx := context.Background()
|
|
base, err := p.GetModule(tenantID, moduleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name := base.Name
|
|
en := base.Enabled
|
|
pr := base.Priority
|
|
ri := base.RefreshIntervalSec
|
|
cron := base.CronExpr
|
|
var dc, doh *string
|
|
dc = base.DefaultCommunityID
|
|
doh = base.DohProfileID
|
|
if patch.Name != nil {
|
|
name = strings.TrimSpace(*patch.Name)
|
|
}
|
|
if patch.Enabled != nil {
|
|
en = *patch.Enabled
|
|
}
|
|
if patch.Priority != nil {
|
|
pr = *patch.Priority
|
|
}
|
|
if patch.RefreshIntervalSec != nil {
|
|
ri = *patch.RefreshIntervalSec
|
|
}
|
|
if patch.CronExpr != nil {
|
|
cron = *patch.CronExpr
|
|
}
|
|
if patch.DefaultCommunityID != nil {
|
|
v := strings.TrimSpace(*patch.DefaultCommunityID)
|
|
if v == "" {
|
|
dc = nil
|
|
} else {
|
|
dc = &v
|
|
}
|
|
}
|
|
if patch.DohProfileID != nil {
|
|
v := strings.TrimSpace(*patch.DohProfileID)
|
|
if v == "" {
|
|
doh = nil
|
|
} else {
|
|
doh = &v
|
|
}
|
|
}
|
|
var dcArg, dohArg any
|
|
if dc != nil {
|
|
dcArg = *dc
|
|
}
|
|
if doh != nil {
|
|
dohArg = *doh
|
|
}
|
|
var riArg any
|
|
if ri != 0 {
|
|
riArg = ri
|
|
}
|
|
var cronArg any
|
|
if strings.TrimSpace(cron) != "" {
|
|
cronArg = strings.TrimSpace(cron)
|
|
}
|
|
_, err = p.pool.Exec(ctx, `
|
|
UPDATE module SET name=$3, enabled=$4, priority=$5, refresh_interval_sec=$6, cron_expr=$7,
|
|
default_community_id=$8, doh_profile_id=$9, updated_at=now()
|
|
WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL`,
|
|
moduleID, tenantID, name, en, pr, riArg, cronArg, dcArg, dohArg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p.GetModule(tenantID, moduleID)
|
|
}
|
|
|
|
func (p *Postgres) SoftDeleteModule(tenantID, moduleID string) error {
|
|
ctx := context.Background()
|
|
tag, err := p.pool.Exec(ctx, `UPDATE module SET deleted_at=now(), updated_at=now() WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL`, moduleID, tenantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return store.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer {
|
|
ctx := context.Background()
|
|
rows, err := p.pool.Query(ctx, `
|
|
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
|
|
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
|
|
FROM bgp_peer WHERE tenant_id=$1 ORDER BY neighbor`, tenantID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
var out []*store.BGPPeer
|
|
for rows.Next() {
|
|
var peer store.BGPPeer
|
|
var sp *string
|
|
if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON); err != nil {
|
|
continue
|
|
}
|
|
peer.SpeakerID = sp
|
|
out = append(out, &peer)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) {
|
|
ctx := context.Background()
|
|
var peer store.BGPPeer
|
|
var sp *string
|
|
err := p.pool.QueryRow(ctx, `
|
|
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
|
|
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
|
|
FROM bgp_peer WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
|
|
&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, store.ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
peer.SpeakerID = sp
|
|
return &peer, nil
|
|
}
|
|
|
|
func (p *Postgres) CreatePeer(tenantID string, in *store.BGPPeer) (*store.BGPPeer, error) {
|
|
if in == nil || in.RemoteASN == 0 {
|
|
return nil, store.ErrInvalidInput
|
|
}
|
|
ctx := context.Background()
|
|
id := uuid.NewString()
|
|
meta := map[string]any{"name": in.Name, "session_state": in.SessionState}
|
|
mb, _ := json.Marshal(meta)
|
|
pol := "{}"
|
|
if strings.TrimSpace(in.PoliciesJSON) != "" {
|
|
pol = in.PoliciesJSON
|
|
}
|
|
var sp any
|
|
if in.SpeakerID != nil && strings.TrimSpace(*in.SpeakerID) != "" {
|
|
sp = strings.TrimSpace(*in.SpeakerID)
|
|
}
|
|
_, err := p.pool.Exec(ctx, `
|
|
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json)
|
|
VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb)`,
|
|
id, tenantID, sp, in.Neighbor, in.RemoteASN, in.Enabled, pol, string(mb))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p.GetPeer(tenantID, id)
|
|
}
|
|
|
|
func (p *Postgres) UpdatePeer(tenantID, id string, patch *store.PeerPatch) (*store.BGPPeer, error) {
|
|
cur, err := p.GetPeer(tenantID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if patch.Neighbor != nil {
|
|
cur.Neighbor = strings.TrimSpace(*patch.Neighbor)
|
|
}
|
|
if patch.RemoteASN != nil {
|
|
cur.RemoteASN = *patch.RemoteASN
|
|
}
|
|
if patch.Enabled != nil {
|
|
cur.Enabled = *patch.Enabled
|
|
}
|
|
if patch.Name != nil {
|
|
cur.Name = *patch.Name
|
|
}
|
|
if patch.SessionState != nil {
|
|
cur.SessionState = *patch.SessionState
|
|
}
|
|
if patch.PoliciesJSON != nil {
|
|
cur.PoliciesJSON = *patch.PoliciesJSON
|
|
}
|
|
if patch.SpeakerID != nil {
|
|
v := strings.TrimSpace(*patch.SpeakerID)
|
|
if v == "" {
|
|
cur.SpeakerID = nil
|
|
} else {
|
|
cur.SpeakerID = &v
|
|
}
|
|
}
|
|
ctx := context.Background()
|
|
meta := map[string]any{"name": cur.Name, "session_state": cur.SessionState}
|
|
mb, _ := json.Marshal(meta)
|
|
pol := "{}"
|
|
if strings.TrimSpace(cur.PoliciesJSON) != "" {
|
|
pol = cur.PoliciesJSON
|
|
}
|
|
var sp any
|
|
if cur.SpeakerID != nil && strings.TrimSpace(*cur.SpeakerID) != "" {
|
|
sp = strings.TrimSpace(*cur.SpeakerID)
|
|
}
|
|
_, err = p.pool.Exec(ctx, `
|
|
UPDATE bgp_peer SET neighbor=$3::inet, remote_asn=$4, enabled=$5, policies_json=$6::jsonb, meta_json=$7::jsonb,
|
|
bgp_speaker_id=$8, updated_at=now()
|
|
WHERE id=$1 AND tenant_id=$2`, id, tenantID, cur.Neighbor, cur.RemoteASN, cur.Enabled, pol, string(mb), sp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p.GetPeer(tenantID, id)
|
|
}
|
|
|
|
func (p *Postgres) DeletePeer(tenantID, id string) error {
|
|
ctx := context.Background()
|
|
tag, err := p.pool.Exec(ctx, `DELETE FROM bgp_peer WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return store.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Postgres) ListSpeakersForTenant(tenantID string) []*store.Speaker {
|
|
ctx := context.Background()
|
|
rows, err := p.pool.Query(ctx, `
|
|
SELECT id::text, role, COALESCE(endpoint,''), last_applied_revision_id::text, COALESCE(meta_json::text,'{}')
|
|
FROM bgp_speaker WHERE tenant_id=$1 ORDER BY id`, tenantID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
var out []*store.Speaker
|
|
for rows.Next() {
|
|
var s store.Speaker
|
|
s.TenantID = tenantID
|
|
var lap *string
|
|
if err := rows.Scan(&s.ID, &s.Role, &s.Endpoint, &lap, &s.MetaJSON); err != nil {
|
|
continue
|
|
}
|
|
s.LastAppliedRevisionID = strOrNil(lap)
|
|
out = append(out, &s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *Postgres) GetSpeaker(tenantID, speakerID string) (*store.Speaker, error) {
|
|
sp, err := p.getSpeakerRow(context.Background(), speakerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sp.TenantID != tenantID {
|
|
return nil, store.ErrTenantScope
|
|
}
|
|
return sp, nil
|
|
}
|
|
|
|
func (p *Postgres) GetSpeakerAnyTenant(speakerID string) (*store.Speaker, error) {
|
|
return p.getSpeakerRow(context.Background(), speakerID)
|
|
}
|
|
|
|
func (p *Postgres) getSpeakerRow(ctx context.Context, speakerID string) (*store.Speaker, error) {
|
|
var s store.Speaker
|
|
var lap *string
|
|
err := p.pool.QueryRow(ctx, `
|
|
SELECT id::text, tenant_id::text, role, COALESCE(endpoint,''), last_applied_revision_id::text, COALESCE(meta_json::text,'{}')
|
|
FROM bgp_speaker WHERE id=$1`, speakerID).Scan(&s.ID, &s.TenantID, &s.Role, &s.Endpoint, &lap, &s.MetaJSON)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, store.ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
s.LastAppliedRevisionID = strOrNil(lap)
|
|
return &s, nil
|
|
}
|
|
|
|
func (p *Postgres) CreateSpeaker(tenantID string, in *store.Speaker) (*store.Speaker, error) {
|
|
if in == nil {
|
|
return nil, store.ErrInvalidInput
|
|
}
|
|
ctx := context.Background()
|
|
id := uuid.NewString()
|
|
meta := in.MetaJSON
|
|
if meta == "" {
|
|
meta = "{}"
|
|
}
|
|
var ep any
|
|
if strings.TrimSpace(in.Endpoint) != "" {
|
|
ep = strings.TrimSpace(in.Endpoint)
|
|
}
|
|
_, err := p.pool.Exec(ctx, `INSERT INTO bgp_speaker (id, tenant_id, role, endpoint, meta_json) VALUES ($1,$2,$3,$4,$5::jsonb)`,
|
|
id, tenantID, in.Role, ep, meta)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p.GetSpeaker(tenantID, id)
|
|
}
|
|
|
|
func (p *Postgres) UpdateSpeaker(tenantID, id string, patch *store.SpeakerPatch) (*store.Speaker, error) {
|
|
cur, err := p.GetSpeaker(tenantID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if patch.Role != nil {
|
|
cur.Role = strings.TrimSpace(*patch.Role)
|
|
}
|
|
if patch.Endpoint != nil {
|
|
cur.Endpoint = *patch.Endpoint
|
|
}
|
|
if patch.MetaJSON != nil {
|
|
cur.MetaJSON = *patch.MetaJSON
|
|
}
|
|
ctx := context.Background()
|
|
meta := cur.MetaJSON
|
|
if strings.TrimSpace(meta) == "" {
|
|
meta = "{}"
|
|
}
|
|
var ep any
|
|
if strings.TrimSpace(cur.Endpoint) != "" {
|
|
ep = strings.TrimSpace(cur.Endpoint)
|
|
}
|
|
_, err = p.pool.Exec(ctx, `UPDATE bgp_speaker SET role=$3, endpoint=$4, meta_json=$5::jsonb, updated_at=now() WHERE id=$1 AND tenant_id=$2`,
|
|
id, tenantID, cur.Role, ep, meta)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p.GetSpeaker(tenantID, id)
|
|
}
|
|
|
|
func (p *Postgres) GetRevision(tenantID, revisionID string) (*store.Revision, error) {
|
|
ctx := context.Background()
|
|
var r store.Revision
|
|
var mod *string
|
|
var parent *string
|
|
var meta []byte
|
|
err := p.pool.QueryRow(ctx, `
|
|
SELECT id::text, tenant_id::text, module_id::text, content_hash, parent_revision_id::text, meta_json, created_at
|
|
FROM config_revision WHERE id=$1 AND tenant_id=$2`, revisionID, tenantID).Scan(
|
|
&r.ID, &r.TenantID, &mod, &r.ContentHash, &parent, &meta, &r.CreatedAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, store.ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if mod != nil {
|
|
r.ModuleID = *mod
|
|
}
|
|
r.ParentRevisionID = strOrNil(parent)
|
|
var mj struct {
|
|
PreviewFragments map[string]string `json:"preview_fragments"`
|
|
MaterializedPrefixCount int `json:"materialized_prefix_count"`
|
|
}
|
|
_ = json.Unmarshal(meta, &mj)
|
|
if mj.PreviewFragments == nil {
|
|
mj.PreviewFragments = map[string]string{}
|
|
}
|
|
r.PreviewFragments = mj.PreviewFragments
|
|
r.MaterializedPrefixCount = mj.MaterializedPrefixCount
|
|
return &r, nil
|
|
}
|
|
|
|
func (p *Postgres) ListRevisions(tenantID, moduleID string, cursor string, limit int) ([]*store.Revision, string, bool) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
ctx := context.Background()
|
|
q := `SELECT id::text, module_id::text, content_hash, parent_revision_id::text, meta_json, created_at FROM config_revision WHERE tenant_id=$1`
|
|
args := []any{tenantID}
|
|
if moduleID != "" {
|
|
q += ` AND module_id = $2`
|
|
args = append(args, moduleID)
|
|
}
|
|
q += ` ORDER BY created_at DESC`
|
|
rows, err := p.pool.Query(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, "", false
|
|
}
|
|
defer rows.Close()
|
|
var all []*store.Revision
|
|
for rows.Next() {
|
|
var r store.Revision
|
|
r.TenantID = tenantID
|
|
var mod, parent *string
|
|
var meta []byte
|
|
if err := rows.Scan(&r.ID, &mod, &r.ContentHash, &parent, &meta, &r.CreatedAt); err != nil {
|
|
continue
|
|
}
|
|
if mod != nil {
|
|
r.ModuleID = *mod
|
|
}
|
|
r.ParentRevisionID = strOrNil(parent)
|
|
var mj struct {
|
|
PreviewFragments map[string]string `json:"preview_fragments"`
|
|
MaterializedPrefixCount int `json:"materialized_prefix_count"`
|
|
}
|
|
_ = json.Unmarshal(meta, &mj)
|
|
if mj.PreviewFragments == nil {
|
|
mj.PreviewFragments = map[string]string{}
|
|
}
|
|
r.PreviewFragments = mj.PreviewFragments
|
|
r.MaterializedPrefixCount = mj.MaterializedPrefixCount
|
|
all = append(all, &r)
|
|
}
|
|
off := 0
|
|
if cursor != "" {
|
|
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
|
off = n
|
|
}
|
|
}
|
|
end := off + limit
|
|
next := ""
|
|
hasMore := false
|
|
if end > len(all) {
|
|
end = len(all)
|
|
} else {
|
|
hasMore = true
|
|
next = fmt.Sprintf("%d", end)
|
|
}
|
|
if off >= len(all) {
|
|
return nil, "", false
|
|
}
|
|
return all[off:end], next, hasMore
|
|
}
|
|
|
|
func (p *Postgres) ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) ([]store.PrefixRow, string, bool) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
ctx := context.Background()
|
|
if _, err := p.GetRevision(tenantID, revisionID); err != nil {
|
|
return nil, "", false
|
|
}
|
|
rows, err := p.pool.Query(ctx, `
|
|
SELECT prefix::text, community_id::text, source FROM revision_materialized_prefix
|
|
WHERE revision_id=$1 ORDER BY id`, revisionID)
|
|
if err != nil {
|
|
return nil, "", false
|
|
}
|
|
defer rows.Close()
|
|
var all []store.PrefixRow
|
|
for rows.Next() {
|
|
var pr store.PrefixRow
|
|
var comm *string
|
|
if err := rows.Scan(&pr.Prefix, &comm, &pr.Source); err != nil {
|
|
continue
|
|
}
|
|
pr.CommunityID = comm
|
|
all = append(all, pr)
|
|
}
|
|
off := 0
|
|
if cursor != "" {
|
|
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
|
off = n
|
|
}
|
|
}
|
|
end := off + limit
|
|
next := ""
|
|
more := false
|
|
if end > len(all) {
|
|
end = len(all)
|
|
} else {
|
|
more = true
|
|
next = fmt.Sprintf("%d", end)
|
|
}
|
|
if off >= len(all) {
|
|
return nil, "", false
|
|
}
|
|
return all[off:end], next, more
|
|
}
|
|
|
|
func (p *Postgres) CreateRollbackRevision(tenantID, sourceRevisionID string) (string, error) {
|
|
src, err := p.GetRevision(tenantID, sourceRevisionID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
ctx := context.Background()
|
|
newID := uuid.NewString()
|
|
parent := sourceRevisionID
|
|
meta, _ := json.Marshal(map[string]any{
|
|
"preview_fragments": src.PreviewFragments,
|
|
"materialized_prefix_count": src.MaterializedPrefixCount,
|
|
})
|
|
var modArg any
|
|
if strings.TrimSpace(src.ModuleID) != "" {
|
|
modArg = src.ModuleID
|
|
}
|
|
_, err = p.pool.Exec(ctx, `
|
|
INSERT INTO config_revision (id, tenant_id, module_id, content_hash, parent_revision_id, meta_json)
|
|
VALUES ($1,$2,$3,$4,$5::uuid,$6::jsonb)`,
|
|
newID, tenantID, modArg, src.ContentHash+":rollback", parent, string(meta))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// copy materialized prefixes
|
|
_, _ = p.pool.Exec(ctx, `
|
|
INSERT INTO revision_materialized_prefix (revision_id, prefix, community_id, source, meta_json)
|
|
SELECT $1::uuid, prefix, community_id, source, meta_json FROM revision_materialized_prefix WHERE revision_id=$2::uuid`,
|
|
newID, sourceRevisionID)
|
|
return newID, nil
|
|
}
|
|
|
|
func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, error) {
|
|
if _, err := p.GetRevision(tenantID, aID); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := p.GetRevision(tenantID, bID); err != nil {
|
|
return nil, err
|
|
}
|
|
pa, _, _ := p.ListRevisionPrefixes(tenantID, aID, "", 100000)
|
|
pb, _, _ := p.ListRevisionPrefixes(tenantID, bID, "", 100000)
|
|
setA := make(map[string]struct{})
|
|
setB := make(map[string]struct{})
|
|
for _, x := range pa {
|
|
setA[x.Prefix] = struct{}{}
|
|
}
|
|
for _, x := range pb {
|
|
setB[x.Prefix] = struct{}{}
|
|
}
|
|
var added, removed []string
|
|
unchanged := 0
|
|
for pfx := range setB {
|
|
if _, ok := setA[pfx]; !ok {
|
|
added = append(added, pfx)
|
|
} else {
|
|
unchanged++
|
|
}
|
|
}
|
|
for pfx := range setA {
|
|
if _, ok := setB[pfx]; !ok {
|
|
removed = append(removed, pfx)
|
|
}
|
|
}
|
|
sort.Strings(added)
|
|
sort.Strings(removed)
|
|
return map[string]any{
|
|
"revision_a": aID,
|
|
"revision_b": bID,
|
|
"prefixes": map[string]any{
|
|
"added": added, "removed": removed, "unchanged_count": unchanged,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (p *Postgres) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error {
|
|
ctx := context.Background()
|
|
tag, err := p.pool.Exec(ctx, `
|
|
UPDATE bgp_speaker SET last_applied_revision_id=$3::uuid, updated_at=now()
|
|
WHERE id=$1 AND tenant_id=$2`, speakerID, tenantID, revisionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return store.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Postgres) PublishRevisionForSpeaker(speakerID, revisionID string) error {
|
|
ctx := context.Background()
|
|
tag, err := p.pool.Exec(ctx, `
|
|
UPDATE bgp_speaker SET published_revision_id=$2::uuid, published_at=now(), updated_at=now() WHERE id=$1`, speakerID, revisionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return store.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Postgres) LatestPublishedRevision(speakerID string) (string, time.Time, error) {
|
|
ctx := context.Background()
|
|
var rid string
|
|
var at time.Time
|
|
err := p.pool.QueryRow(ctx, `
|
|
SELECT published_revision_id::text, published_at FROM bgp_speaker
|
|
WHERE id=$1 AND published_revision_id IS NOT NULL`, speakerID).Scan(&rid, &at)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", time.Time{}, store.ErrNotFound
|
|
}
|
|
return "", time.Time{}, err
|
|
}
|
|
return rid, at, nil
|
|
}
|
|
|
|
func (p *Postgres) ListTenantIDs() ([]string, error) {
|
|
ctx := context.Background()
|
|
rows, err := p.pool.Query(ctx, `SELECT id::text FROM tenant ORDER BY id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
continue
|
|
}
|
|
out = append(out, id)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (p *Postgres) CreateRenderRevision(revisionID, tenantID, moduleID string, parentRevisionID *string, contentHash string, previewFragments map[string]string, prefixes []store.PrefixRow) error {
|
|
if strings.TrimSpace(revisionID) == "" {
|
|
return store.ErrInvalidInput
|
|
}
|
|
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
|
return err
|
|
}
|
|
ctx := context.Background()
|
|
if previewFragments == nil {
|
|
previewFragments = map[string]string{}
|
|
}
|
|
meta, err := json.Marshal(map[string]any{
|
|
"preview_fragments": previewFragments,
|
|
"materialized_prefix_count": len(prefixes),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tx, err := p.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var parent any
|
|
if parentRevisionID != nil && strings.TrimSpace(*parentRevisionID) != "" {
|
|
parent = strings.TrimSpace(*parentRevisionID)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO config_revision (id, tenant_id, module_id, content_hash, parent_revision_id, meta_json)
|
|
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5::uuid, $6::jsonb)`,
|
|
strings.TrimSpace(revisionID), tenantID, moduleID, strings.TrimSpace(contentHash), parent, string(meta))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, pr := range prefixes {
|
|
var comm any
|
|
if pr.CommunityID != nil && strings.TrimSpace(*pr.CommunityID) != "" {
|
|
comm = strings.TrimSpace(*pr.CommunityID)
|
|
}
|
|
src := pr.Source
|
|
if strings.TrimSpace(src) == "" {
|
|
src = "render"
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO revision_materialized_prefix (revision_id, prefix, community_id, source)
|
|
VALUES ($1::uuid, $2::cidr, $3::uuid, $4)`,
|
|
strings.TrimSpace(revisionID), strings.TrimSpace(pr.Prefix), comm, src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Postgres) ListDohProfiles(tenantID string) ([]*store.DohProfile, error) {
|
|
ctx := context.Background()
|
|
rows, err := p.pool.Query(ctx, `SELECT id::text, name, url, timeout_ms, secret_ref FROM doh_profile WHERE tenant_id=$1`, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*store.DohProfile
|
|
for rows.Next() {
|
|
var d store.DohProfile
|
|
d.TenantID = tenantID
|
|
var to *int32
|
|
if err := rows.Scan(&d.ID, &d.Name, &d.URL, &to, &d.SecretRef); err != nil {
|
|
continue
|
|
}
|
|
if to != nil {
|
|
v := int(*to)
|
|
d.TimeoutMs = &v
|
|
}
|
|
out = append(out, &d)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (p *Postgres) GetDohProfile(tenantID, id string) (*store.DohProfile, error) {
|
|
ctx := context.Background()
|
|
var d store.DohProfile
|
|
d.TenantID = tenantID
|
|
var to *int32
|
|
err := p.pool.QueryRow(ctx, `SELECT id::text, name, url, timeout_ms, secret_ref FROM doh_profile WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
|
|
&d.ID, &d.Name, &d.URL, &to, &d.SecretRef)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, store.ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if to != nil {
|
|
v := int(*to)
|
|
d.TimeoutMs = &v
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
func (p *Postgres) CreateDohProfile(tenantID string, in *store.DohProfile) (*store.DohProfile, error) {
|
|
if in == nil {
|
|
return nil, store.ErrInvalidInput
|
|
}
|
|
ctx := context.Background()
|
|
id := uuid.NewString()
|
|
_, err := p.pool.Exec(ctx, `INSERT INTO doh_profile (id, tenant_id, name, url, timeout_ms, secret_ref) VALUES ($1,$2,$3,$4,$5,$6)`,
|
|
id, tenantID, in.Name, in.URL, nullInt32Ptr(in.TimeoutMs), in.SecretRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p.GetDohProfile(tenantID, id)
|
|
}
|
|
|
|
func (p *Postgres) UpdateDohProfile(tenantID, id string, patch *store.DohProfilePatch) (*store.DohProfile, error) {
|
|
cur, err := p.GetDohProfile(tenantID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if patch.Name != nil {
|
|
cur.Name = *patch.Name
|
|
}
|
|
if patch.URL != nil {
|
|
cur.URL = *patch.URL
|
|
}
|
|
if patch.TimeoutMs != nil {
|
|
cur.TimeoutMs = patch.TimeoutMs
|
|
}
|
|
if patch.SecretRef != nil {
|
|
cur.SecretRef = patch.SecretRef
|
|
}
|
|
ctx := context.Background()
|
|
_, err = p.pool.Exec(ctx, `UPDATE doh_profile SET name=$3, url=$4, timeout_ms=$5, secret_ref=$6, updated_at=now() WHERE id=$1 AND tenant_id=$2`,
|
|
id, tenantID, cur.Name, cur.URL, nullInt32Ptr(cur.TimeoutMs), cur.SecretRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p.GetDohProfile(tenantID, id)
|
|
}
|
|
|
|
func (p *Postgres) DeleteDohProfile(tenantID, id string) error {
|
|
ctx := context.Background()
|
|
var n int
|
|
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM module WHERE doh_profile_id=$1::uuid AND deleted_at IS NULL`, id).Scan(&n)
|
|
if n > 0 {
|
|
return store.ErrInvalidInput
|
|
}
|
|
tag, err := p.pool.Exec(ctx, `DELETE FROM doh_profile WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return store.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Postgres) ListCommunities(tenantID string) ([]*store.Community, error) {
|
|
ctx := context.Background()
|
|
rows, err := p.pool.Query(ctx, `SELECT id::text, name, kind, value_json::text FROM bgp_community WHERE tenant_id=$1`, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*store.Community
|
|
for rows.Next() {
|
|
var c store.Community
|
|
c.TenantID = tenantID
|
|
if err := rows.Scan(&c.ID, &c.Name, &c.Kind, &c.ValueJSON); err != nil {
|
|
continue
|
|
}
|
|
out = append(out, &c)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) {
|
|
ctx := context.Background()
|
|
var c store.Community
|
|
c.TenantID = tenantID
|
|
err := p.pool.QueryRow(ctx, `SELECT id::text, name, kind, value_json::text FROM bgp_community WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
|
|
&c.ID, &c.Name, &c.Kind, &c.ValueJSON)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, store.ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
func (p *Postgres) CreateCommunity(tenantID string, in *store.Community) (*store.Community, error) {
|
|
if in == nil {
|
|
return nil, store.ErrInvalidInput
|
|
}
|
|
ctx := context.Background()
|
|
id := uuid.NewString()
|
|
vj := in.ValueJSON
|
|
if strings.TrimSpace(vj) == "" {
|
|
vj = "{}"
|
|
}
|
|
_, err := p.pool.Exec(ctx, `INSERT INTO bgp_community (id, tenant_id, name, kind, value_json) VALUES ($1,$2,$3,$4,$5::jsonb)`,
|
|
id, tenantID, in.Name, in.Kind, vj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p.GetCommunity(tenantID, id)
|
|
}
|
|
|
|
func (p *Postgres) UpdateCommunity(tenantID, id string, patch *store.CommunityPatch) (*store.Community, error) {
|
|
cur, err := p.GetCommunity(tenantID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if patch.Name != nil {
|
|
cur.Name = *patch.Name
|
|
}
|
|
if patch.Kind != nil {
|
|
cur.Kind = *patch.Kind
|
|
}
|
|
if patch.ValueJSON != nil {
|
|
cur.ValueJSON = *patch.ValueJSON
|
|
}
|
|
ctx := context.Background()
|
|
_, err = p.pool.Exec(ctx, `UPDATE bgp_community SET name=$3, kind=$4, value_json=$5::jsonb, updated_at=now() WHERE id=$1 AND tenant_id=$2`,
|
|
id, tenantID, cur.Name, cur.Kind, cur.ValueJSON)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p.GetCommunity(tenantID, id)
|
|
}
|
|
|
|
func (p *Postgres) DeleteCommunity(tenantID, id string) error {
|
|
ctx := context.Background()
|
|
var n int
|
|
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM module WHERE default_community_id=$1::uuid AND deleted_at IS NULL`, id).Scan(&n)
|
|
if n > 0 {
|
|
return store.ErrInvalidInput
|
|
}
|
|
tag, err := p.pool.Exec(ctx, `DELETE FROM bgp_community WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return store.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CDN / AS / domain / IP stubs: implement in postgres_sub.go to keep file size manageable.
|
|
|
|
func strPtrUUID(s *string) *string {
|
|
if s == nil || strings.TrimSpace(*s) == "" {
|
|
return nil
|
|
}
|
|
v := strings.TrimSpace(*s)
|
|
return &v
|
|
}
|
|
|
|
func strOrNil(s *string) *string {
|
|
if s == nil || *s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
func nullStr(s string) *string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
v := strings.TrimSpace(s)
|
|
return &v
|
|
}
|
|
|
|
func nullInt32(i int) *int32 {
|
|
if i == 0 {
|
|
return nil
|
|
}
|
|
v := int32(i)
|
|
return &v
|
|
}
|
|
|
|
func nullIntOrZero(i int) any {
|
|
if i == 0 {
|
|
return nil
|
|
}
|
|
return i
|
|
}
|
|
|
|
func nullInt32Ptr(i *int) *int32 {
|
|
if i == nil {
|
|
return nil
|
|
}
|
|
v := int32(*i)
|
|
return &v
|
|
}
|
|
|
|
func nullJSON(s string) *string {
|
|
if strings.TrimSpace(s) == "" {
|
|
v := "{}"
|
|
return &v
|
|
}
|
|
return &s
|
|
}
|