Add NAT-tier resources and testcontainers integration tests
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

- Add snat/masquerade, netmap, and 1:1 nat as stored, terraformable resources:
  migration 0003, model types, store CRUD (id-keyed, generation-bumping), and
  REST handlers. These are the global-intent/device-resolved NAT tier; compiler
  rendering of NAT into per-device configs is a tracked follow-up.
- Add a testcontainers-backed store integration suite exercising the CRUD
  lifecycle, generation bumping, source/dest grammar validation, and FK cascade
  against a real Postgres. It self-skips under 'go test -short' (the CI path) so
  a container runtime is only needed for the full run.
This commit is contained in:
benvin
2026-07-20 22:25:36 +10:00
parent de8ce4b4ec
commit 5060804359
8 changed files with 733 additions and 10 deletions
+160
View File
@@ -0,0 +1,160 @@
package store
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// ---- SNAT / masquerade -----------------------------------------------------
func (s *Store) ListSNAT(ctx context.Context) ([]model.SNATRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, action, source, egress, address, probability, comment FROM snat ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.SNATRule
for rows.Next() {
var r model.SNATRule
if err := rows.Scan(&r.ID, &r.Action, &r.Source, &r.Egress, &r.Address, &r.Probability, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetSNAT(ctx context.Context, id int64) (model.SNATRule, error) {
var r model.SNATRule
err := s.pool.QueryRow(ctx,
`SELECT id, action, source, egress, address, probability, comment FROM snat WHERE id = $1`, id,
).Scan(&r.ID, &r.Action, &r.Source, &r.Egress, &r.Address, &r.Probability, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateSNAT(ctx context.Context, r model.SNATRule) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO snat (action, source, egress, address, probability, comment)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
r.Action, r.Source, r.Egress, r.Address, r.Probability, r.Comment,
).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteSNAT(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM snat WHERE id = $1`, id)
}
// ---- Netmap ----------------------------------------------------------------
func (s *Store) ListNetmap(ctx context.Context) ([]model.NetmapRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, type, from_net, to_net, anchor, comment FROM netmap ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.NetmapRule
for rows.Next() {
var r model.NetmapRule
if err := rows.Scan(&r.ID, &r.Type, &r.FromNet, &r.ToNet, &r.Anchor, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetNetmap(ctx context.Context, id int64) (model.NetmapRule, error) {
var r model.NetmapRule
err := s.pool.QueryRow(ctx,
`SELECT id, type, from_net, to_net, anchor, comment FROM netmap WHERE id = $1`, id,
).Scan(&r.ID, &r.Type, &r.FromNet, &r.ToNet, &r.Anchor, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateNetmap(ctx context.Context, r model.NetmapRule) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO netmap (type, from_net, to_net, anchor, comment)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
r.Type, r.FromNet, r.ToNet, r.Anchor, r.Comment,
).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteNetmap(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM netmap WHERE id = $1`, id)
}
// ---- 1:1 static NAT --------------------------------------------------------
func (s *Store) ListNAT(ctx context.Context) ([]model.NATRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, external, internal, interface, comment FROM nat ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.NATRule
for rows.Next() {
var r model.NATRule
if err := rows.Scan(&r.ID, &r.Device, &r.External, &r.Internal, &r.Interface, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetNAT(ctx context.Context, id int64) (model.NATRule, error) {
var r model.NATRule
err := s.pool.QueryRow(ctx,
`SELECT id, device, external, internal, interface, comment FROM nat WHERE id = $1`, id,
).Scan(&r.ID, &r.Device, &r.External, &r.Internal, &r.Interface, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateNAT(ctx context.Context, r model.NATRule) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO nat (device, external, internal, interface, comment)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
r.Device, r.External, r.Internal, r.Interface, r.Comment,
).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteNAT(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM nat WHERE id = $1`, id)
}
+151
View File
@@ -0,0 +1,151 @@
package store_test
import (
"context"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"git.unkin.net/unkin/tomswallapi/internal/database"
"git.unkin.net/unkin/tomswallapi/internal/model"
"git.unkin.net/unkin/tomswallapi/internal/store"
)
// newTestStore spins up a throwaway Postgres, applies migrations, and returns a
// Store. It skips (rather than fails) when Docker is unavailable, so the default
// `go test -short` run in CI passes without a container runtime.
func newTestStore(t *testing.T) *store.Store {
t.Helper()
if testing.Short() {
t.Skip("skipping container-backed test in -short mode")
}
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:17-alpine",
postgres.WithDatabase("tomswallapi"),
postgres.WithUsername("tomswallapi"),
postgres.WithPassword("tomswallapi"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).WithStartupTimeout(60*time.Second)),
)
if err != nil {
t.Skipf("skipping: cannot start postgres container (Docker unavailable?): %v", err)
}
t.Cleanup(func() { _ = pg.Terminate(ctx) })
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
db, err := database.New(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(db.Close)
if err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
return store.New(db.Pool)
}
func TestZoneLifecycleAndGeneration(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
gen0, err := s.Generation(ctx)
if err != nil {
t.Fatalf("generation: %v", err)
}
if err := s.UpsertZone(ctx, model.Zone{Name: "zone-a", Type: "ip", Subnets: []string{"10.1.0.0/24"}}); err != nil {
t.Fatalf("upsert zone: %v", err)
}
gen1, _ := s.Generation(ctx)
if gen1 <= gen0 {
t.Errorf("generation should bump on write: %d -> %d", gen0, gen1)
}
got, err := s.GetZone(ctx, "zone-a")
if err != nil {
t.Fatalf("get zone: %v", err)
}
if len(got.Subnets) != 1 || got.Subnets[0] != "10.1.0.0/24" {
t.Errorf("subnets round-trip failed: %+v", got)
}
zones, err := s.ListZones(ctx)
if err != nil || len(zones) != 1 {
t.Fatalf("list zones = %d (%v)", len(zones), err)
}
if err := s.DeleteZone(ctx, "zone-a"); err != nil {
t.Fatalf("delete zone: %v", err)
}
if _, err := s.GetZone(ctx, "zone-a"); err != store.ErrNotFound {
t.Errorf("expected ErrNotFound after delete, got %v", err)
}
gen2, _ := s.Generation(ctx)
if gen2 <= gen1 {
t.Errorf("generation should bump on delete: %d -> %d", gen1, gen2)
}
}
func TestRuleGrammarValidationAtStore(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
// Valid rule with a paired selector.
if _, err := s.CreateRule(ctx, model.Rule{
Action: "accept", Source: []string{"loc"}, Dest: []string{"net:+cloudflare"},
}); err != nil {
t.Fatalf("valid rule rejected: %v", err)
}
// Bare selector must be rejected before it reaches the DB.
if _, err := s.CreateRule(ctx, model.Rule{
Action: "accept", Source: []string{"+cloudflare"}, Dest: []string{"loc"},
}); err == nil {
t.Fatal("bare selector rule should be rejected")
}
}
func TestNATTierRoundTrip(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
// nat references a device via FK.
if err := s.UpsertDevice(ctx, model.Device{Name: "fw-a", Class: model.ClassFirewall}); err != nil {
t.Fatalf("upsert device: %v", err)
}
sid, err := s.CreateSNAT(ctx, model.SNATRule{Action: "masquerade", Source: "loc", Egress: "net"})
if err != nil {
t.Fatalf("create snat: %v", err)
}
if got, err := s.GetSNAT(ctx, sid); err != nil || got.Action != "masquerade" {
t.Errorf("snat round-trip: %+v (%v)", got, err)
}
nid, err := s.CreateNAT(ctx, model.NATRule{Device: "fw-a", External: "203.0.113.10", Internal: "10.1.0.10"})
if err != nil {
t.Fatalf("create nat: %v", err)
}
if err := s.DeleteNAT(ctx, nid); err != nil {
t.Fatalf("delete nat: %v", err)
}
if err := s.DeleteNAT(ctx, nid); err != store.ErrNotFound {
t.Errorf("second delete should be ErrNotFound, got %v", err)
}
// Deleting the device cascades to its nat rows.
_, _ = s.CreateNAT(ctx, model.NATRule{Device: "fw-a", External: "203.0.113.11", Internal: "10.1.0.11"})
if err := s.DeleteDevice(ctx, "fw-a"); err != nil {
t.Fatalf("delete device: %v", err)
}
nats, _ := s.ListNAT(ctx)
if len(nats) != 0 {
t.Errorf("expected nat rows to cascade-delete with device, got %d", len(nats))
}
}