// Package store is the Postgres-backed repository for the fleet model. Every // mutating method bumps the global config generation so agents can detect drift. package store import ( "context" "encoding/json" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "git.unkin.net/unkin/tomswallapi/internal/model" ) // ErrNotFound is returned when a lookup by key matches no row. var ErrNotFound = errors.New("not found") // Store provides CRUD over the fleet model. type Store struct { pool *pgxpool.Pool } // New constructs a Store over the given pool. func New(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // Generation returns the current global config generation. func (s *Store) Generation(ctx context.Context) (int64, error) { var g int64 err := s.pool.QueryRow(ctx, `SELECT current FROM generation WHERE id = true`).Scan(&g) return g, err } // bump increments the generation within tx and returns the new value. func bump(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE generation SET current = current + 1 WHERE id = true`) return err } // jsonb marshals a value for a JSONB column, defaulting nil slices to "[]". func jsonb(v any) ([]byte, error) { if v == nil { return []byte("[]"), nil } return json.Marshal(v) } // unmarshalStrings decodes a JSONB string array into dst. func unmarshalStrings(data []byte, dst *[]string) error { return json.Unmarshal(data, dst) } // ---- Fabrics --------------------------------------------------------------- func (s *Store) ListFabrics(ctx context.Context) ([]model.Fabric, error) { rows, err := s.pool.Query(ctx, `SELECT name, enforce_on_routers, description FROM fabrics ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var out []model.Fabric for rows.Next() { var f model.Fabric if err := rows.Scan(&f.Name, &f.EnforceOnRouters, &f.Description); err != nil { return nil, err } out = append(out, f) } return out, rows.Err() } func (s *Store) GetFabric(ctx context.Context, name string) (model.Fabric, error) { var f model.Fabric err := s.pool.QueryRow(ctx, `SELECT name, enforce_on_routers, description FROM fabrics WHERE name = $1`, name, ).Scan(&f.Name, &f.EnforceOnRouters, &f.Description) if errors.Is(err, pgx.ErrNoRows) { return f, ErrNotFound } return f, err } func (s *Store) UpsertFabric(ctx context.Context, f model.Fabric) error { return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { if _, err := tx.Exec(ctx, ` INSERT INTO fabrics (name, enforce_on_routers, description) VALUES ($1, $2, $3) ON CONFLICT (name) DO UPDATE SET enforce_on_routers = EXCLUDED.enforce_on_routers, description = EXCLUDED.description`, f.Name, f.EnforceOnRouters, f.Description); err != nil { return err } return bump(ctx, tx) }) } // ---- Zones ----------------------------------------------------------------- func (s *Store) ListZones(ctx context.Context) ([]model.Zone, error) { rows, err := s.pool.Query(ctx, `SELECT name, type, subnets, COALESCE(parent, '') FROM zones ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var out []model.Zone for rows.Next() { var z model.Zone var subnets []byte if err := rows.Scan(&z.Name, &z.Type, &subnets, &z.Parent); err != nil { return nil, err } if err := json.Unmarshal(subnets, &z.Subnets); err != nil { return nil, err } out = append(out, z) } return out, rows.Err() } func (s *Store) GetZone(ctx context.Context, name string) (model.Zone, error) { var z model.Zone var subnets []byte err := s.pool.QueryRow(ctx, `SELECT name, type, subnets, COALESCE(parent, '') FROM zones WHERE name = $1`, name, ).Scan(&z.Name, &z.Type, &subnets, &z.Parent) if errors.Is(err, pgx.ErrNoRows) { return z, ErrNotFound } if err != nil { return z, err } return z, json.Unmarshal(subnets, &z.Subnets) } func (s *Store) UpsertZone(ctx context.Context, z model.Zone) error { subnets, err := jsonb(z.Subnets) if err != nil { return err } if z.Type == "" { z.Type = "ip" } var parent any if z.Parent != "" { parent = z.Parent } return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { if _, err := tx.Exec(ctx, ` INSERT INTO zones (name, type, subnets, parent) VALUES ($1, $2, $3, $4) ON CONFLICT (name) DO UPDATE SET type = EXCLUDED.type, subnets = EXCLUDED.subnets, parent = EXCLUDED.parent`, z.Name, z.Type, subnets, parent); err != nil { return err } return bump(ctx, tx) }) } // ---- Address groups -------------------------------------------------------- func (s *Store) ListAddressGroups(ctx context.Context) ([]model.AddressGroup, error) { rows, err := s.pool.Query(ctx, `SELECT name, type, members, refresh, description, resolved, resolved_at FROM address_groups ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var out []model.AddressGroup for rows.Next() { var g model.AddressGroup var members, resolved []byte if err := rows.Scan(&g.Name, &g.Type, &members, &g.Refresh, &g.Description, &resolved, &g.ResolvedAt); err != nil { return nil, err } if err := json.Unmarshal(members, &g.Members); err != nil { return nil, err } if err := json.Unmarshal(resolved, &g.Resolved); err != nil { return nil, err } out = append(out, g) } return out, rows.Err() } // UpdateResolvedPrefixes stores expanded ASN prefixes for a group and bumps the // generation so agents re-pull. Membership churn is stored separately from the // group definition, so an UpsertAddressGroup never clobbers it. func (s *Store) UpdateResolvedPrefixes(ctx context.Context, name string, prefixes []string) error { resolved, err := jsonb(prefixes) if err != nil { return err } return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { tag, err := tx.Exec(ctx, `UPDATE address_groups SET resolved = $2, resolved_at = now() WHERE name = $1 AND type = 'asn'`, name, resolved) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return bump(ctx, tx) }) } func (s *Store) UpsertAddressGroup(ctx context.Context, g model.AddressGroup) error { members, err := jsonb(g.Members) if err != nil { return err } return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { if _, err := tx.Exec(ctx, ` INSERT INTO address_groups (name, type, members, refresh, description) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (name) DO UPDATE SET type = EXCLUDED.type, members = EXCLUDED.members, refresh = EXCLUDED.refresh, description = EXCLUDED.description`, g.Name, g.Type, members, g.Refresh, g.Description); err != nil { return err } return bump(ctx, tx) }) } // ---- Devices --------------------------------------------------------------- func (s *Store) ListDevices(ctx context.Context) ([]model.Device, error) { rows, err := s.pool.Query(ctx, `SELECT name, class, COALESCE(fabric, ''), resolver FROM devices ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var out []model.Device for rows.Next() { var d model.Device var resolver []byte if err := rows.Scan(&d.Name, &d.Class, &d.Fabric, &resolver); err != nil { return nil, err } if err := json.Unmarshal(resolver, &d.Resolver); err != nil { return nil, err } out = append(out, d) } return out, rows.Err() } func (s *Store) UpsertDevice(ctx context.Context, d model.Device) error { resolver, err := jsonb(d.Resolver) if err != nil { return err } settings, err := jsonb(d.Settings) if err != nil { return err } var fabric any if d.Fabric != "" { fabric = d.Fabric } return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { if _, err := tx.Exec(ctx, ` INSERT INTO devices (name, class, fabric, resolver, settings) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (name) DO UPDATE SET class = EXCLUDED.class, fabric = EXCLUDED.fabric, resolver = EXCLUDED.resolver, settings = EXCLUDED.settings`, d.Name, d.Class, fabric, resolver, settings); err != nil { return err } return bump(ctx, tx) }) } // RecordDeviceStatus stores the generation an agent reports as applied. func (s *Store) RecordDeviceStatus(ctx context.Context, name string, generation int64) error { tag, err := s.pool.Exec(ctx, `UPDATE devices SET reported_generation = $2, last_seen = now() WHERE name = $1`, name, generation) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } func (s *Store) GetDevice(ctx context.Context, name string) (model.Device, error) { var d model.Device var resolver, settings, reachable []byte err := s.pool.QueryRow(ctx, `SELECT name, class, COALESCE(fabric, ''), resolver, settings, reachable_prefixes FROM devices WHERE name = $1`, name, ).Scan(&d.Name, &d.Class, &d.Fabric, &resolver, &settings, &reachable) if errors.Is(err, pgx.ErrNoRows) { return d, ErrNotFound } if err != nil { return d, err } if err := json.Unmarshal(resolver, &d.Resolver); err != nil { return d, err } if err := json.Unmarshal(reachable, &d.ReachablePrefixes); err != nil { return d, err } return d, json.Unmarshal(settings, &d.Settings) } // UpdateDeviceRoutes stores the reachable prefixes an agent reports from its FIB. // This is scoping data, not config — it does not bump the config generation. func (s *Store) UpdateDeviceRoutes(ctx context.Context, name string, prefixes []string) error { reachable, err := jsonb(prefixes) if err != nil { return err } tag, err := s.pool.Exec(ctx, `UPDATE devices SET reachable_prefixes = $2, routes_reported_at = now() WHERE name = $1`, name, reachable) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // ---- Settings, portgroups, policies ---------------------------------------- func (s *Store) GetSettings(ctx context.Context) (model.Settings, error) { var st model.Settings var resolver []byte err := s.pool.QueryRow(ctx, ` SELECT address_family, log_level, ip_forwarding, table_name, default_resolver FROM settings WHERE id = true`, ).Scan(&st.AddressFamily, &st.LogLevel, &st.IPForwarding, &st.TableName, &resolver) if err != nil { return st, err } return st, json.Unmarshal(resolver, &st.DefaultResolver) } func (s *Store) ListPortGroups(ctx context.Context) ([]model.PortGroup, error) { rows, err := s.pool.Query(ctx, `SELECT name, proto, ports FROM portgroups ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var out []model.PortGroup for rows.Next() { var p model.PortGroup var ports []byte if err := rows.Scan(&p.Name, &p.Proto, &ports); err != nil { return nil, err } if err := json.Unmarshal(ports, &p.Ports); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } func (s *Store) UpsertPortGroup(ctx context.Context, p model.PortGroup) error { ports, err := jsonb(p.Ports) if err != nil { return err } return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { if _, err := tx.Exec(ctx, ` INSERT INTO portgroups (name, proto, ports) VALUES ($1, $2, $3) ON CONFLICT (name) DO UPDATE SET proto = EXCLUDED.proto, ports = EXCLUDED.ports`, p.Name, p.Proto, ports); err != nil { return err } return bump(ctx, tx) }) } func (s *Store) ListPolicies(ctx context.Context) ([]model.Policy, error) { rows, err := s.pool.Query(ctx, `SELECT id, priority, source, dest, action, log FROM policies ORDER BY priority, id`) if err != nil { return nil, err } defer rows.Close() var out []model.Policy for rows.Next() { var p model.Policy if err := rows.Scan(&p.ID, &p.Priority, &p.Source, &p.Dest, &p.Action, &p.Log); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // ---- Bindings -------------------------------------------------------------- func (s *Store) ListBindings(ctx context.Context, device string) ([]model.Binding, error) { rows, err := s.pool.Query(ctx, `SELECT device, zone, interfaces FROM bindings WHERE device = $1 ORDER BY zone`, device) if err != nil { return nil, err } defer rows.Close() var out []model.Binding for rows.Next() { var b model.Binding var ifaces []byte if err := rows.Scan(&b.Device, &b.Zone, &ifaces); err != nil { return nil, err } if err := json.Unmarshal(ifaces, &b.Interfaces); err != nil { return nil, err } out = append(out, b) } return out, rows.Err() } func (s *Store) UpsertBinding(ctx context.Context, b model.Binding) error { ifaces, err := jsonb(b.Interfaces) if err != nil { return err } return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { if _, err := tx.Exec(ctx, ` INSERT INTO bindings (device, zone, interfaces) VALUES ($1, $2, $3) ON CONFLICT (device, zone) DO UPDATE SET interfaces = EXCLUDED.interfaces`, b.Device, b.Zone, ifaces); err != nil { return err } return bump(ctx, tx) }) } // ---- Rules ----------------------------------------------------------------- func (s *Store) ListRules(ctx context.Context) ([]model.Rule, error) { rows, err := s.pool.Query(ctx, ` SELECT id, priority, action, source, dest, proto, COALESCE(portgroup, ''), ports, log, comment FROM rules ORDER BY priority, id`) if err != nil { return nil, err } defer rows.Close() var out []model.Rule for rows.Next() { var r model.Rule var source, dest, ports []byte if err := rows.Scan(&r.ID, &r.Priority, &r.Action, &source, &dest, &r.Proto, &r.PortGroup, &ports, &r.Log, &r.Comment); err != nil { return nil, err } if err := json.Unmarshal(source, &r.Source); err != nil { return nil, err } if err := json.Unmarshal(dest, &r.Dest); err != nil { return nil, err } if err := json.Unmarshal(ports, &r.Ports); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // CreateRule inserts a rule after validating its source/dest grammar, returning // the assigned id. func (s *Store) CreateRule(ctx context.Context, r model.Rule) (int64, error) { if _, err := model.ParseElements(r.Source); err != nil { return 0, fmt.Errorf("source: %w", err) } if _, err := model.ParseElements(r.Dest); err != nil { return 0, fmt.Errorf("dest: %w", err) } source, _ := jsonb(r.Source) dest, _ := jsonb(r.Dest) ports, _ := jsonb(r.Ports) var portgroup any if r.PortGroup != "" { portgroup = r.PortGroup } var id int64 err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { if err := tx.QueryRow(ctx, ` INSERT INTO rules (priority, action, source, dest, proto, portgroup, ports, log, comment) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, r.Priority, r.Action, source, dest, r.Proto, portgroup, ports, r.Log, r.Comment, ).Scan(&id); err != nil { return err } return bump(ctx, tx) }) return id, err } 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, query, args...) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } 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) }