feat(backends): Pool-Modell — Backend = Pool, N Server pro Backend

Migration 0016: backend_servers (id, backend_id, name, address, port,
weight, backup, active) + backends.lb_algorithm. Daten-Migration kopiert
bestehende backends.address/port als ersten Server, dann DROP COLUMN.

HAProxy-Renderer: rendert pro Backend einen Block mit `balance <algo>`
+ N `server`-Zeilen (weight, backup-Flag, optional check inter 5s).
LB-Algorithmen: roundrobin / leastconn / source.

REST: /backends/:id/servers (GET/POST), /backend-servers/:id (PUT/DELETE).
Re-rendert HAProxy nach jeder Server-Mutation.

UI: address/port aus Backend-Form raus, lb_algorithm-Select rein. Server
verwaltet ein expandable Sub-Panel pro Backend-Row (Tabelle + Add/Edit/
Delete-Modal). Domain-Attachment-Multi-Select bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-11 20:55:47 +02:00
parent 05850934fb
commit 8aac24b566
16 changed files with 784 additions and 85 deletions

View File

@@ -1,4 +1,7 @@
// Package backends implements CRUD against the `backends` table.
// Ein Backend ist ein Pool — Name, Scheme, Healthcheck, LB-Algorithm.
// Die konkreten Upstream-Server liegen in backend_servers (siehe
// services/backendservers).
package backends
import (
@@ -20,7 +23,7 @@ type Repo struct {
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
const baseSelect = `
SELECT id, name, scheme, address, port, health_check_path, active,
SELECT id, name, scheme, health_check_path, lb_algorithm, active,
created_at, updated_at
FROM backends
`
@@ -55,29 +58,34 @@ func (r *Repo) Get(ctx context.Context, id int64) (*models.Backend, error) {
}
func (r *Repo) Create(ctx context.Context, b models.Backend) (*models.Backend, error) {
if b.LBAlgorithm == "" {
b.LBAlgorithm = "roundrobin"
}
row := r.Pool.QueryRow(ctx, `
INSERT INTO backends (name, scheme, address, port, health_check_path, active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, scheme, address, port, health_check_path, active,
INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, scheme, health_check_path, lb_algorithm, active,
created_at, updated_at`,
b.Name, b.Scheme, b.Address, b.Port, b.HealthCheckPath, b.Active)
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.Active)
return scanBackend(row)
}
func (r *Repo) Update(ctx context.Context, id int64, b models.Backend) (*models.Backend, error) {
if b.LBAlgorithm == "" {
b.LBAlgorithm = "roundrobin"
}
row := r.Pool.QueryRow(ctx, `
UPDATE backends SET
name = $1,
scheme = $2,
address = $3,
port = $4,
health_check_path = $5,
active = $6,
health_check_path = $3,
lb_algorithm = $4,
active = $5,
updated_at = NOW()
WHERE id = $7
RETURNING id, name, scheme, address, port, health_check_path, active,
WHERE id = $6
RETURNING id, name, scheme, health_check_path, lb_algorithm, active,
created_at, updated_at`,
b.Name, b.Scheme, b.Address, b.Port, b.HealthCheckPath, b.Active, id)
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.Active, id)
out, err := scanBackend(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -102,8 +110,8 @@ func (r *Repo) Delete(ctx context.Context, id int64) error {
func scanBackend(row interface{ Scan(...any) error }) (*models.Backend, error) {
var b models.Backend
if err := row.Scan(
&b.ID, &b.Name, &b.Scheme, &b.Address, &b.Port,
&b.HealthCheckPath, &b.Active,
&b.ID, &b.Name, &b.Scheme,
&b.HealthCheckPath, &b.LBAlgorithm, &b.Active,
&b.CreatedAt, &b.UpdatedAt,
); err != nil {
return nil, err

View File

@@ -0,0 +1,144 @@
// Package backendservers implements CRUD against the backend_servers
// table — die konkreten Upstream-Server pro Backend-Pool. Schema in
// migration 0016_backend_servers.sql.
package backendservers
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var ErrNotFound = errors.New("backend server not found")
type Repo struct {
Pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
const baseSelect = `
SELECT id, backend_id, name, address, port, weight, backup, active,
created_at, updated_at
FROM backend_servers
`
// ListByBackend liefert alle Server eines Pools. Sortierung: backup
// am Ende (HAProxy bedient sie nur wenn primaries down sind, in der
// UI sollen sie auch optisch unten stehen), dann nach name.
func (r *Repo) ListByBackend(ctx context.Context, backendID int64) ([]models.BackendServer, error) {
rows, err := r.Pool.Query(ctx,
baseSelect+" WHERE backend_id = $1 ORDER BY backup ASC, name ASC", backendID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.BackendServer, 0, 4)
for rows.Next() {
s, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, *s)
}
return out, rows.Err()
}
// ListAll gibt sämtliche Server zurück — wird vom HAProxy-Renderer
// genutzt, damit der View in einem einzigen Query alle Pools füllt.
func (r *Repo) ListAll(ctx context.Context) ([]models.BackendServer, error) {
rows, err := r.Pool.Query(ctx,
baseSelect+" ORDER BY backend_id ASC, backup ASC, name ASC")
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.BackendServer, 0, 16)
for rows.Next() {
s, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, *s)
}
return out, rows.Err()
}
func (r *Repo) Get(ctx context.Context, id int64) (*models.BackendServer, error) {
row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id)
s, err := scan(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return s, nil
}
func (r *Repo) Create(ctx context.Context, s models.BackendServer) (*models.BackendServer, error) {
if s.Weight == 0 {
s.Weight = 100
}
row := r.Pool.QueryRow(ctx, `
INSERT INTO backend_servers (backend_id, name, address, port, weight, backup, active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, backend_id, name, address, port, weight, backup, active,
created_at, updated_at`,
s.BackendID, s.Name, s.Address, s.Port, s.Weight, s.Backup, s.Active)
return scan(row)
}
func (r *Repo) Update(ctx context.Context, id int64, s models.BackendServer) (*models.BackendServer, error) {
if s.Weight == 0 {
s.Weight = 100
}
row := r.Pool.QueryRow(ctx, `
UPDATE backend_servers SET
name = $1,
address = $2,
port = $3,
weight = $4,
backup = $5,
active = $6,
updated_at = NOW()
WHERE id = $7
RETURNING id, backend_id, name, address, port, weight, backup, active,
created_at, updated_at`,
s.Name, s.Address, s.Port, s.Weight, s.Backup, s.Active, id)
out, err := scan(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return out, nil
}
func (r *Repo) Delete(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM backend_servers WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func scan(row interface{ Scan(...any) error }) (*models.BackendServer, error) {
var s models.BackendServer
if err := row.Scan(
&s.ID, &s.BackendID, &s.Name, &s.Address, &s.Port,
&s.Weight, &s.Backup, &s.Active,
&s.CreatedAt, &s.UpdatedAt,
); err != nil {
return nil, err
}
return &s, nil
}