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

@@ -0,0 +1,81 @@
-- +goose Up
-- +goose StatementBegin
-- v1.0.49: backends wird vom Single-Server-Endpoint zum Pool.
-- Bisher: backends.address/port → genau ein Upstream.
-- Jetzt: backends = Pool-Definition (LB-Algo, Healthcheck), die
-- konkreten Upstreams leben in backend_servers (1:N).
-- Damit kann eine Domain → ein Backend → N Server (HAProxy macht
-- den Loadbalance laut backends.lb_algorithm).
CREATE TABLE IF NOT EXISTS backend_servers (
id BIGSERIAL PRIMARY KEY,
backend_id BIGINT NOT NULL REFERENCES backends(id) ON DELETE CASCADE,
name TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL,
weight INTEGER NOT NULL DEFAULT 100,
backup BOOLEAN NOT NULL DEFAULT FALSE,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT backend_servers_name_unique UNIQUE (backend_id, name),
CONSTRAINT backend_servers_port_check CHECK (port > 0 AND port < 65536),
CONSTRAINT backend_servers_weight_check CHECK (weight >= 0 AND weight <= 256)
);
CREATE INDEX IF NOT EXISTS idx_backend_servers_backend
ON backend_servers (backend_id);
CREATE INDEX IF NOT EXISTS idx_backend_servers_active
ON backend_servers (backend_id, active) WHERE active;
-- LB-Algorithmus pro Backend. Default roundrobin entspricht haproxy-
-- default; source = sticky-per-Source-IP (für stateful Apps ohne
-- shared session store).
ALTER TABLE backends
ADD COLUMN IF NOT EXISTS lb_algorithm TEXT NOT NULL DEFAULT 'roundrobin';
ALTER TABLE backends
DROP CONSTRAINT IF EXISTS backends_lb_check;
ALTER TABLE backends
ADD CONSTRAINT backends_lb_check
CHECK (lb_algorithm IN ('roundrobin', 'leastconn', 'source'));
-- Daten-Migration: jede bisherige backends-Row produziert genau eine
-- backend_servers-Row mit demselben address/port. name = backends.name
-- damit der HAProxy-server-id stabil bleibt (haproxy stats / logs
-- referenzieren ihn). Idempotent — bei Re-Apply gibt's einen Skip
-- über das Unique-Constraint.
INSERT INTO backend_servers (backend_id, name, address, port, active)
SELECT id, name, address, port, active
FROM backends
WHERE address IS NOT NULL AND port IS NOT NULL
ON CONFLICT (backend_id, name) DO NOTHING;
-- Erst nach erfolgreichem Insert die alten Spalten droppen. Wenn die
-- Migration mittendrin abbricht, sind die Daten in beiden Tabellen
-- vorhanden — wir verlieren nichts.
ALTER TABLE backends DROP COLUMN IF EXISTS address;
ALTER TABLE backends DROP COLUMN IF EXISTS port;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE backends ADD COLUMN IF NOT EXISTS address TEXT;
ALTER TABLE backends ADD COLUMN IF NOT EXISTS port INTEGER;
UPDATE backends b
SET address = bs.address,
port = bs.port
FROM (
SELECT DISTINCT ON (backend_id) backend_id, address, port
FROM backend_servers
ORDER BY backend_id, id ASC
) bs
WHERE b.id = bs.backend_id;
ALTER TABLE backends DROP CONSTRAINT IF EXISTS backends_lb_check;
ALTER TABLE backends DROP COLUMN IF EXISTS lb_algorithm;
DROP TABLE IF EXISTS backend_servers;
-- +goose StatementEnd

View File

@@ -0,0 +1,126 @@
package handlers
import (
"context"
"errors"
"log/slog"
"strconv"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
)
// BackendServersHandler exposes:
// GET /api/v1/backends/:id/servers
// POST /api/v1/backends/:id/servers
// PUT /api/v1/backend-servers/:id
// DELETE /api/v1/backend-servers/:id
type BackendServersHandler struct {
Repo *backendservers.Repo
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewBackendServersHandler(repo *backendservers.Repo, a *audit.Repo,
nodeID string, reloader func(context.Context) error) *BackendServersHandler {
return &BackendServersHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *BackendServersHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("haproxy: reload after server mutation failed", "op", op, "error", err)
}
}
func (h *BackendServersHandler) Register(rg *gin.RouterGroup) {
rg.GET("/backends/:id/servers", h.ListForBackend)
rg.POST("/backends/:id/servers", h.Create)
g := rg.Group("/backend-servers")
g.PUT("/:id", h.Update)
g.DELETE("/:id", h.Delete)
}
func (h *BackendServersHandler) ListForBackend(c *gin.Context) {
backendID, ok := parseID(c)
if !ok {
return
}
out, err := h.Repo.ListByBackend(c.Request.Context(), backendID)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"servers": out})
}
func (h *BackendServersHandler) Create(c *gin.Context) {
backendID, ok := parseID(c)
if !ok {
return
}
var req models.BackendServer
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
req.BackendID = backendID
out, err := h.Repo.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "backend.server.create", req.Name, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "create")
}
func (h *BackendServersHandler) Update(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.BackendServer
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, backendservers.ErrNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "backend.server.update", out.Name, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "update")
}
func (h *BackendServersHandler) Delete(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, backendservers.ErrNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "backend.server.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c)
h.reload(c.Request.Context(), "delete")
}

View File

@@ -83,8 +83,15 @@ frontend internal_stats
backend api_backend
server api1 127.0.0.1:9443 check
{{- range .Backends}}
{{- range $b := .Backends}}
backend eg_backend_{{.ID}}
server {{.Name | safeID}} {{.Address}}:{{.Port}}{{if eq .Scheme "https"}} ssl verify none alpn h2,http/1.1{{end}}{{if .HealthCheckPath}} check inter 5s{{end}}
backend eg_backend_{{$b.ID}}
balance {{$b.LBAlgorithm}}
{{- if $b.HealthCheckPath}}
option httpchk
http-check send meth GET uri {{$b.HealthCheckPath}}
{{- end}}
{{- range $s := $b.Servers}}
server {{$s.Name | safeID}} {{$s.Address}}:{{$s.Port}}{{if eq $b.Scheme "https"}} ssl verify none alpn h2,http/1.1{{end}}{{if $b.HealthCheckPath}} check inter 5s{{end}} weight {{$s.Weight}}{{if $s.Backup}} backup{{end}}
{{- end}}
{{- end}}

View File

@@ -19,6 +19,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
)
@@ -55,10 +56,11 @@ var tpl = template.Must(template.New("haproxy").Funcs(template.FuncMap{
}).Parse(cfgTpl))
type Generator struct {
Pool *pgxpool.Pool
DomainsRepo *domains.Repo
BackendsRepo *backends.Repo
RoutingRepo *routingrules.Repo
Pool *pgxpool.Pool
DomainsRepo *domains.Repo
BackendsRepo *backends.Repo
ServersRepo *backendservers.Repo
RoutingRepo *routingrules.Repo
OutputPath string
SkipReload bool
@@ -69,6 +71,7 @@ func New(pool *pgxpool.Pool) *Generator {
Pool: pool,
DomainsRepo: domains.New(pool),
BackendsRepo: backends.New(pool),
ServersRepo: backendservers.New(pool),
RoutingRepo: routingrules.New(pool),
}
}
@@ -101,10 +104,12 @@ func (g *Generator) Render(ctx context.Context) error {
}
// View is what the template consumes. Routes per domain are pre-
// joined here so the template can stay declarative.
// joined here so the template can stay declarative; Servers leben pro
// BackendView, damit das Template einen `backend …`-Block mit den N
// Server-Zeilen rendern kann.
type View struct {
Domains []DomainView
Backends []models.Backend
Backends []BackendView
}
type DomainView struct {
@@ -117,6 +122,11 @@ type RouteView struct {
BackendID int64
}
type BackendView struct {
models.Backend
Servers []models.BackendServer
}
func (g *Generator) loadView(ctx context.Context) (*View, error) {
doms, err := g.DomainsRepo.List(ctx)
if err != nil {
@@ -126,6 +136,10 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
if err != nil {
return nil, fmt.Errorf("list backends: %w", err)
}
srvs, err := g.ServersRepo.ListAll(ctx)
if err != nil {
return nil, fmt.Errorf("list backend servers: %w", err)
}
rules, err := g.RoutingRepo.List(ctx)
if err != nil {
return nil, fmt.Errorf("list routing rules: %w", err)
@@ -142,11 +156,23 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
})
}
activeBackends := make([]models.Backend, 0, len(bes))
for _, b := range bes {
if b.Active {
activeBackends = append(activeBackends, b)
srvByBackend := map[int64][]models.BackendServer{}
for _, s := range srvs {
if !s.Active {
continue
}
srvByBackend[s.BackendID] = append(srvByBackend[s.BackendID], s)
}
activeBackends := make([]BackendView, 0, len(bes))
for _, b := range bes {
if !b.Active {
continue
}
activeBackends = append(activeBackends, BackendView{
Backend: b,
Servers: srvByBackend[b.ID],
})
}
domViews := make([]DomainView, 0, len(doms))

View File

@@ -17,6 +17,21 @@ func renderView(t *testing.T, v View) string {
return buf.String()
}
func mkBackend(id int64, name string, hcp *string) models.Backend {
return models.Backend{
ID: id, Name: name, Scheme: "http",
LBAlgorithm: "roundrobin", Active: true,
HealthCheckPath: hcp,
}
}
func mkServer(backendID int64, name, addr string, port int) models.BackendServer {
return models.BackendServer{
BackendID: backendID, Name: name, Address: addr, Port: port,
Weight: 100, Active: true,
}
}
func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) {
out := renderView(t, View{})
for _, w := range []string{
@@ -37,9 +52,13 @@ func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) {
func TestRender_DomainRoutesEmitUseBackend(t *testing.T) {
v := View{
Backends: []models.Backend{
{ID: 1, Name: "app", Address: "10.0.0.10", Port: 8080, Active: true},
{ID: 2, Name: "api", Address: "10.0.0.20", Port: 9000, Active: true},
Backends: []BackendView{
{Backend: mkBackend(1, "app", nil), Servers: []models.BackendServer{
mkServer(1, "app", "10.0.0.10", 8080),
}},
{Backend: mkBackend(2, "api", nil), Servers: []models.BackendServer{
mkServer(2, "api", "10.0.0.20", 9000),
}},
},
Domains: []DomainView{{
Domain: models.Domain{ID: 1, Name: "example.com", Active: true},
@@ -55,6 +74,7 @@ func TestRender_DomainRoutesEmitUseBackend(t *testing.T) {
"server app 10.0.0.10:8080",
"backend eg_backend_2",
"server api 10.0.0.20:9000",
"balance roundrobin",
"use_backend eg_backend_1 if { hdr(host) -i example.com } { path_beg / }",
"use_backend eg_backend_2 if { hdr(host) -i example.com } { path_beg /api }",
} {
@@ -67,12 +87,46 @@ func TestRender_DomainRoutesEmitUseBackend(t *testing.T) {
func TestRender_HealthCheckPathAddsCheckInter(t *testing.T) {
hcp := "/health"
v := View{
Backends: []models.Backend{
{ID: 1, Name: "app", Address: "10.0.0.10", Port: 8080, Active: true, HealthCheckPath: &hcp},
Backends: []BackendView{
{Backend: mkBackend(1, "app", &hcp), Servers: []models.BackendServer{
mkServer(1, "app", "10.0.0.10", 8080),
}},
},
}
out := renderView(t, v)
if !strings.Contains(out, "server app 10.0.0.10:8080 check inter 5s") {
t.Errorf("expected `check inter 5s` for backend with health_check_path:\n%s", out)
}
if !strings.Contains(out, "option httpchk") {
t.Errorf("expected `option httpchk` when health_check_path set:\n%s", out)
}
}
func TestRender_MultiServerPool(t *testing.T) {
v := View{
Backends: []BackendView{
{
Backend: models.Backend{ID: 1, Name: "vmm", Scheme: "http", LBAlgorithm: "leastconn", Active: true},
Servers: []models.BackendServer{
{BackendID: 1, Name: "vmm-1", Address: "10.0.0.11", Port: 8080, Weight: 100, Active: true},
{BackendID: 1, Name: "vmm-2", Address: "10.0.0.12", Port: 8080, Weight: 100, Active: true},
{BackendID: 1, Name: "vmm-3", Address: "10.0.0.13", Port: 8080, Weight: 50, Backup: true, Active: true},
},
},
},
}
out := renderView(t, v)
for _, w := range []string{
"backend eg_backend_1",
"balance leastconn",
"server vmm-1 10.0.0.11:8080",
"server vmm-2 10.0.0.12:8080",
"server vmm-3 10.0.0.13:8080",
"weight 50",
" backup",
} {
if !strings.Contains(out, w) {
t.Errorf("missing %q in multi-server output:\n%s", w, out)
}
}
}

View File

@@ -2,16 +2,35 @@ package models
import "time"
// Backend ist eine Pool-Definition (Name, Scheme, Healthcheck, LB-Algo).
// Die konkreten Upstream-Server leben in BackendServer (1:N).
type Backend struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"column:name;uniqueIndex" json:"name"`
Scheme string `gorm:"column:scheme" json:"scheme"`
Address string `gorm:"column:address" json:"address"`
Port int `gorm:"column:port" json:"port"`
HealthCheckPath *string `gorm:"column:health_check_path" json:"health_check_path,omitempty"`
LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"`
Active bool `gorm:"column:active" json:"active"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (Backend) TableName() string { return "backends" }
// BackendServer ist ein konkreter Upstream im Pool. weight steuert das
// LB-Verhältnis (roundrobin/leastconn); backup wird nur bedient wenn
// alle non-backup-Server down sind.
type BackendServer struct {
ID int64 `gorm:"primaryKey" json:"id"`
BackendID int64 `gorm:"column:backend_id" json:"backend_id"`
Name string `gorm:"column:name" json:"name"`
Address string `gorm:"column:address" json:"address"`
Port int `gorm:"column:port" json:"port"`
Weight int `gorm:"column:weight" json:"weight"`
Backup bool `gorm:"column:backup" json:"backup"`
Active bool `gorm:"column:active" json:"active"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (BackendServer) TableName() string { return "backend_servers" }

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
}