CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 44s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m5s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m1s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 15s
CI / docker-go-prime (push) Successful in 24s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m1s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m12s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m24s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m19s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m21s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m6s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m21s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m21s
Added functionality to handle revision retention minutes in global settings, including validation for input values. Introduced a new method to prune old revisions based on the specified retention period, ensuring that only the most recent revisions are kept. Updated the frontend to allow users to configure the revision retention setting, enhancing the management of revision lifecycles.
1188 lines
32 KiB
Go
1188 lines
32 KiB
Go
// Package repository implements SQL-backed store.Backend (PostgreSQL).
|
|
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"evobgp/internal/store"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// #region agent log
|
|
func agentDebugNDJSON3214(hypothesisID, location, message string, data map[string]any) {
|
|
if os.Getenv("EVOBGP_DEBUG_LOG") != "1" {
|
|
return
|
|
}
|
|
f, err := os.OpenFile("debug-3214dc.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
var ms runtime.MemStats
|
|
runtime.ReadMemStats(&ms)
|
|
payload := map[string]any{
|
|
"sessionId": "3214dc",
|
|
"hypothesisId": hypothesisID,
|
|
"location": location,
|
|
"message": message,
|
|
"data": data,
|
|
"timestamp": time.Now().UnixMilli(),
|
|
"allocBytes": ms.Alloc,
|
|
}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, _ = f.Write(append(b, '\n'))
|
|
}
|
|
|
|
// #endregion
|
|
|
|
// 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()
|
|
// Агрегация в БД — не тащим все строки config_revision в память.
|
|
err := p.pool.QueryRow(ctx, `
|
|
SELECT
|
|
COALESCE(MAX((meta_json->>'materialized_prefix_count')::int), 0),
|
|
COALESCE(SUM((meta_json->>'materialized_prefix_count')::int), 0)
|
|
FROM config_revision`).Scan(&max, &sum)
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
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, cron *string
|
|
var refresh *int32
|
|
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &cron, &dc); err != nil {
|
|
continue
|
|
}
|
|
if refresh != nil {
|
|
m.RefreshIntervalSec = int(*refresh)
|
|
}
|
|
if cron != nil {
|
|
m.CronExpr = *cron
|
|
}
|
|
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, cron *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, &cron, &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 cron != nil {
|
|
m.CronExpr = *cron
|
|
}
|
|
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
|
|
}
|
|
neighbor, ok := store.NormalizePeerNeighborString(in.Neighbor)
|
|
if !ok {
|
|
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)
|
|
}
|
|
enabled := store.EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState)
|
|
_, 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, neighbor, in.RemoteASN, 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 {
|
|
n, ok := store.NormalizePeerNeighborString(*patch.Neighbor)
|
|
if !ok {
|
|
return nil, store.ErrInvalidInput
|
|
}
|
|
cur.Neighbor = n
|
|
}
|
|
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
|
|
}
|
|
off := 0
|
|
if cursor != "" {
|
|
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
|
off = n
|
|
}
|
|
}
|
|
ctx := context.Background()
|
|
// List endpoint only needs materialized_prefix_count from meta_json — not full preview_fragments blobs.
|
|
// LIMIT/OFFSET in SQL avoids loading every revision for the tenant into memory (was O(N) per request).
|
|
q := `SELECT id::text, module_id::text, content_hash, parent_revision_id::text,
|
|
COALESCE((meta_json->>'materialized_prefix_count')::int, 0), created_at
|
|
FROM config_revision WHERE tenant_id=$1`
|
|
args := []any{tenantID}
|
|
n := 2
|
|
if moduleID != "" {
|
|
q += fmt.Sprintf(` AND module_id=$%d`, n)
|
|
args = append(args, moduleID)
|
|
n++
|
|
}
|
|
q += ` ORDER BY created_at DESC`
|
|
// Fetch limit+1 rows to compute has_more without COUNT(*).
|
|
q += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, n, n+1)
|
|
args = append(args, limit+1, off)
|
|
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 mpc int
|
|
if err := rows.Scan(&r.ID, &mod, &r.ContentHash, &parent, &mpc, &r.CreatedAt); err != nil {
|
|
continue
|
|
}
|
|
if mod != nil {
|
|
r.ModuleID = *mod
|
|
}
|
|
r.ParentRevisionID = strOrNil(parent)
|
|
r.MaterializedPrefixCount = mpc
|
|
r.PreviewFragments = map[string]string{}
|
|
all = append(all, &r)
|
|
}
|
|
agentDebugNDJSON3214("A", "repository/postgres.go:ListRevisions", "list_revisions_fetched", map[string]any{
|
|
"rows": len(all), "limit": limit, "offset": off,
|
|
})
|
|
hasMore := len(all) > limit
|
|
if hasMore {
|
|
all = all[:limit]
|
|
}
|
|
next := ""
|
|
if hasMore {
|
|
next = fmt.Sprintf("%d", off+limit)
|
|
}
|
|
if len(all) == 0 {
|
|
return nil, "", false
|
|
}
|
|
return all, next, hasMore
|
|
}
|
|
|
|
func (p *Postgres) ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) ([]store.PrefixRow, string, bool) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
off := 0
|
|
if cursor != "" {
|
|
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
|
off = n
|
|
}
|
|
}
|
|
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
|
|
LIMIT $2 OFFSET $3`, revisionID, limit+1, off)
|
|
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)
|
|
}
|
|
agentDebugNDJSON3214("B", "repository/postgres.go:ListRevisionPrefixes", "list_prefixes_fetched", map[string]any{
|
|
"rows": len(all), "limit": limit, "offset": off,
|
|
})
|
|
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
|
|
}
|
|
return all, 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
|
|
}
|
|
ctx := context.Background()
|
|
var unchanged int
|
|
err := p.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*)::int FROM (
|
|
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$1::uuid
|
|
INTERSECT
|
|
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$2::uuid
|
|
) t`, aID, bID).Scan(&unchanged)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// added: в B, нет в A; removed: в A, нет в B — без загрузки полных снапшотов в память.
|
|
rowsAdded, err := p.pool.Query(ctx, `
|
|
SELECT prefix::text FROM (
|
|
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$1::uuid
|
|
EXCEPT
|
|
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$2::uuid
|
|
) s ORDER BY 1`, bID, aID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rowsAdded.Close()
|
|
var added []string
|
|
for rowsAdded.Next() {
|
|
var s string
|
|
if err := rowsAdded.Scan(&s); err != nil {
|
|
continue
|
|
}
|
|
added = append(added, s)
|
|
}
|
|
rowsRem, err := p.pool.Query(ctx, `
|
|
SELECT prefix::text FROM (
|
|
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$1::uuid
|
|
EXCEPT
|
|
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$2::uuid
|
|
) s ORDER BY 1`, aID, bID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rowsRem.Close()
|
|
var removed []string
|
|
for rowsRem.Next() {
|
|
var s string
|
|
if err := rowsRem.Scan(&s); err != nil {
|
|
continue
|
|
}
|
|
removed = append(removed, s)
|
|
}
|
|
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) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
|
|
ctx := context.Background()
|
|
cmd, err := p.pool.Exec(ctx, `
|
|
DELETE FROM config_revision AS cr
|
|
WHERE cr.tenant_id = $1
|
|
AND cr.created_at < $2
|
|
AND cr.id <> (
|
|
SELECT id
|
|
FROM config_revision
|
|
WHERE tenant_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM bgp_speaker AS sp
|
|
WHERE sp.tenant_id = $1
|
|
AND (sp.last_applied_revision_id = cr.id OR sp.published_revision_id = cr.id)
|
|
)`,
|
|
tenantID, cutoff.UTC())
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return int(cmd.RowsAffected()), 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, $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, community, title, value_json::text FROM bgp_community WHERE tenant_id=$1 ORDER BY COALESCE(NULLIF(trim(title), ''), community)`, 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.Community, &c.Title, &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, community, title, value_json::text FROM bgp_community WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
|
|
&c.ID, &c.Community, &c.Title, &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
|
|
}
|
|
if strings.TrimSpace(in.Community) == "" {
|
|
return nil, store.ErrInvalidInput
|
|
}
|
|
ctx := context.Background()
|
|
id := uuid.NewString()
|
|
vj := in.ValueJSON
|
|
if strings.TrimSpace(vj) == "" {
|
|
vj = "{}"
|
|
}
|
|
comm := strings.TrimSpace(in.Community)
|
|
title := strings.TrimSpace(in.Title)
|
|
_, err := p.pool.Exec(ctx, `INSERT INTO bgp_community (id, tenant_id, community, title, value_json) VALUES ($1,$2,$3,$4,$5::jsonb)`,
|
|
id, tenantID, comm, title, 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.Community != nil {
|
|
cur.Community = strings.TrimSpace(*patch.Community)
|
|
}
|
|
if patch.Title != nil {
|
|
cur.Title = strings.TrimSpace(*patch.Title)
|
|
}
|
|
if patch.ValueJSON != nil {
|
|
cur.ValueJSON = *patch.ValueJSON
|
|
}
|
|
if strings.TrimSpace(cur.Community) == "" {
|
|
return nil, store.ErrInvalidInput
|
|
}
|
|
ctx := context.Background()
|
|
_, err = p.pool.Exec(ctx, `UPDATE bgp_community SET community=$3, title=$4, value_json=$5::jsonb, updated_at=now() WHERE id=$1 AND tenant_id=$2`,
|
|
id, tenantID, cur.Community, cur.Title, 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 / settings: см. postgres_entities.go.
|
|
|
|
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
|
|
}
|