From bc275f7b9c1a1cdb497de1a7e372dcf1f793fb12 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 30 Dec 2025 12:11:57 +0700 Subject: [PATCH] Add TCP/UDP port mapping support - Add app_ports table for storing port mappings per app - Add Port model with CRUD operations - Add handlers for adding/deleting port mappings - Add ports section to app detail template - Update Docker client to configure port bindings when creating containers - Support both TCP and UDP protocols --- .../database/migrations/003_add_ports.sql | 12 ++ internal/docker/client.go | 49 +++++- internal/handlers/app.go | 92 ++++++++++ internal/models/app.go | 5 + internal/models/port.go | 160 ++++++++++++++++++ internal/server/routes.go | 6 + internal/service/deploy/deploy.go | 19 +++ internal/service/webhook/webhook_test.go | 8 + templates/app_detail.html | 52 ++++++ 9 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 internal/database/migrations/003_add_ports.sql create mode 100644 internal/models/port.go diff --git a/internal/database/migrations/003_add_ports.sql b/internal/database/migrations/003_add_ports.sql new file mode 100644 index 0000000..ad37a2f --- /dev/null +++ b/internal/database/migrations/003_add_ports.sql @@ -0,0 +1,12 @@ +-- Add port mappings for apps + +CREATE TABLE app_ports ( + id INTEGER PRIMARY KEY, + app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + host_port INTEGER NOT NULL, + container_port INTEGER NOT NULL, + protocol TEXT NOT NULL DEFAULT 'tcp' CHECK(protocol IN ('tcp', 'udp')), + UNIQUE(host_port, protocol) +); + +CREATE INDEX idx_app_ports_app_id ON app_ports(app_id); diff --git a/internal/docker/client.go b/internal/docker/client.go index cd7b20b..ed7fa45 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "path/filepath" + "strconv" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" @@ -17,6 +18,7 @@ import ( "github.com/docker/docker/api/types/network" "github.com/docker/docker/client" "github.com/docker/docker/pkg/archive" + "github.com/docker/go-connections/nat" "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/config" @@ -116,6 +118,7 @@ type CreateContainerOptions struct { Env map[string]string Labels map[string]string Volumes []VolumeMount + Ports []PortMapping Network string } @@ -126,6 +129,37 @@ type VolumeMount struct { ReadOnly bool } +// PortMapping represents a port mapping. +type PortMapping struct { + HostPort int + ContainerPort int + Protocol string // "tcp" or "udp" +} + +// buildPortConfig converts port mappings to Docker port configuration. +func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) { + exposedPorts := make(nat.PortSet) + portBindings := make(nat.PortMap) + + for _, p := range ports { + proto := p.Protocol + if proto == "" { + proto = "tcp" + } + + containerPort := nat.Port(fmt.Sprintf("%d/%s", p.ContainerPort, proto)) + exposedPorts[containerPort] = struct{}{} + portBindings[containerPort] = []nat.PortBinding{ + { + HostIP: "0.0.0.0", + HostPort: strconv.Itoa(p.HostPort), + }, + } + } + + return exposedPorts, portBindings +} + // CreateContainer creates a new container. func (c *Client) CreateContainer( ctx context.Context, @@ -156,16 +190,21 @@ func (c *Client) CreateContainer( }) } + // Convert ports to exposed ports and port bindings + exposedPorts, portBindings := buildPortConfig(opts.Ports) + // Create container resp, err := c.docker.ContainerCreate(ctx, &container.Config{ - Image: opts.Image, - Env: envSlice, - Labels: opts.Labels, + Image: opts.Image, + Env: envSlice, + Labels: opts.Labels, + ExposedPorts: exposedPorts, }, &container.HostConfig{ - Mounts: mounts, - NetworkMode: container.NetworkMode(opts.Network), + Mounts: mounts, + PortBindings: portBindings, + NetworkMode: container.NetworkMode(opts.Network), RestartPolicy: container.RestartPolicy{ Name: container.RestartPolicyUnlessStopped, }, diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 2c9f6f8..ad2b919 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -121,6 +121,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc { envVars, _ := application.GetEnvVars(request.Context()) labels, _ := application.GetLabels(request.Context()) volumes, _ := application.GetVolumes(request.Context()) + ports, _ := application.GetPorts(request.Context()) deployments, _ := application.GetDeployments( request.Context(), recentDeploymentsLimit, @@ -135,6 +136,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc { "EnvVars": envVars, "Labels": labels, "Volumes": volumes, + "Ports": ports, "Deployments": deployments, "WebhookURL": webhookURL, "DeployKey": deployKey, @@ -685,6 +687,96 @@ func (h *Handlers) HandleVolumeDelete() http.HandlerFunc { } } +// HandlePortAdd handles adding a port mapping. +func (h *Handlers) HandlePortAdd() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + + application, findErr := models.FindApp(request.Context(), h.db, appID) + if findErr != nil || application == nil { + http.NotFound(writer, request) + + return + } + + parseErr := request.ParseForm() + if parseErr != nil { + http.Error(writer, "Bad Request", http.StatusBadRequest) + + return + } + + hostPort, containerPort, valid := parsePortValues( + request.FormValue("host_port"), + request.FormValue("container_port"), + ) + if !valid { + http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) + + return + } + + protocol := request.FormValue("protocol") + if protocol != "tcp" && protocol != "udp" { + protocol = "tcp" + } + + port := models.NewPort(h.db) + port.AppID = application.ID + port.HostPort = hostPort + port.ContainerPort = containerPort + port.Protocol = models.PortProtocol(protocol) + + saveErr := port.Save(request.Context()) + if saveErr != nil { + h.log.Error("failed to save port", "error", saveErr) + } + + http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) + } +} + +// parsePortValues parses and validates host and container port strings. +func parsePortValues(hostPortStr, containerPortStr string) (int, int, bool) { + hostPort, hostErr := strconv.Atoi(hostPortStr) + containerPort, containerErr := strconv.Atoi(containerPortStr) + + if hostErr != nil || containerErr != nil || hostPort <= 0 || containerPort <= 0 { + return 0, 0, false + } + + return hostPort, containerPort, true +} + +// HandlePortDelete handles deleting a port mapping. +func (h *Handlers) HandlePortDelete() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + portIDStr := chi.URLParam(request, "portID") + + portID, parseErr := strconv.ParseInt(portIDStr, 10, 64) + if parseErr != nil { + http.NotFound(writer, request) + + return + } + + port, findErr := models.FindPort(request.Context(), h.db, portID) + if findErr != nil || port == nil { + http.NotFound(writer, request) + + return + } + + deleteErr := port.Delete(request.Context()) + if deleteErr != nil { + h.log.Error("failed to delete port", "error", deleteErr) + } + + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + } +} + // formatDeployKey formats an SSH public key with a descriptive comment. // Format: ssh-ed25519 AAAA... upaas-2025-01-15-example.com. func formatDeployKey(pubKey string, createdAt time.Time, host string) string { diff --git a/internal/models/app.go b/internal/models/app.go index a607aba..1fe4f02 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -96,6 +96,11 @@ func (a *App) GetVolumes(ctx context.Context) ([]*Volume, error) { return FindVolumesByAppID(ctx, a.db, a.ID) } +// GetPorts returns all port mappings for this app. +func (a *App) GetPorts(ctx context.Context) ([]*Port, error) { + return FindPortsByAppID(ctx, a.db, a.ID) +} + // GetDeployments returns recent deployments for this app. func (a *App) GetDeployments(ctx context.Context, limit int) ([]*Deployment, error) { return FindDeploymentsByAppID(ctx, a.db, a.ID, limit) diff --git a/internal/models/port.go b/internal/models/port.go new file mode 100644 index 0000000..dfd152a --- /dev/null +++ b/internal/models/port.go @@ -0,0 +1,160 @@ +package models + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "git.eeqj.de/sneak/upaas/internal/database" +) + +// PortProtocol represents the protocol for a port mapping. +type PortProtocol string + +// Port protocol constants. +const ( + PortProtocolTCP PortProtocol = "tcp" + PortProtocolUDP PortProtocol = "udp" +) + +// Port represents a port mapping for an app container. +type Port struct { + db *database.Database + + ID int64 + AppID string + HostPort int + ContainerPort int + Protocol PortProtocol +} + +// NewPort creates a new Port with a database reference. +func NewPort(db *database.Database) *Port { + return &Port{db: db, Protocol: PortProtocolTCP} +} + +// Save inserts or updates the port in the database. +func (p *Port) Save(ctx context.Context) error { + if p.ID == 0 { + return p.insert(ctx) + } + + return p.update(ctx) +} + +// Delete removes the port from the database. +func (p *Port) Delete(ctx context.Context) error { + _, err := p.db.Exec(ctx, "DELETE FROM app_ports WHERE id = ?", p.ID) + + return err +} + +func (p *Port) insert(ctx context.Context) error { + query := ` + INSERT INTO app_ports (app_id, host_port, container_port, protocol) + VALUES (?, ?, ?, ?)` + + result, err := p.db.Exec(ctx, query, + p.AppID, p.HostPort, p.ContainerPort, p.Protocol, + ) + if err != nil { + return err + } + + id, err := result.LastInsertId() + if err != nil { + return err + } + + p.ID = id + + return nil +} + +func (p *Port) update(ctx context.Context) error { + query := ` + UPDATE app_ports SET host_port = ?, container_port = ?, protocol = ? + WHERE id = ?` + + _, err := p.db.Exec(ctx, query, p.HostPort, p.ContainerPort, p.Protocol, p.ID) + + return err +} + +// FindPort finds a port by ID. +// +//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record +func FindPort( + ctx context.Context, + db *database.Database, + id int64, +) (*Port, error) { + port := NewPort(db) + + query := ` + SELECT id, app_id, host_port, container_port, protocol + FROM app_ports WHERE id = ?` + + row := db.QueryRow(ctx, query, id) + + err := row.Scan( + &port.ID, &port.AppID, &port.HostPort, &port.ContainerPort, &port.Protocol, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + return nil, fmt.Errorf("scanning port: %w", err) + } + + return port, nil +} + +// FindPortsByAppID finds all ports for an app. +func FindPortsByAppID( + ctx context.Context, + db *database.Database, + appID string, +) ([]*Port, error) { + query := ` + SELECT id, app_id, host_port, container_port, protocol + FROM app_ports WHERE app_id = ? ORDER BY host_port` + + rows, err := db.Query(ctx, query, appID) + if err != nil { + return nil, fmt.Errorf("querying ports by app: %w", err) + } + + defer func() { _ = rows.Close() }() + + var ports []*Port + + for rows.Next() { + port := NewPort(db) + + scanErr := rows.Scan( + &port.ID, &port.AppID, &port.HostPort, + &port.ContainerPort, &port.Protocol, + ) + if scanErr != nil { + return nil, scanErr + } + + ports = append(ports, port) + } + + return ports, rows.Err() +} + +// DeletePortsByAppID deletes all ports for an app. +func DeletePortsByAppID( + ctx context.Context, + db *database.Database, + appID string, +) error { + _, err := db.Exec(ctx, "DELETE FROM app_ports WHERE app_id = ?", appID) + + return err +} diff --git a/internal/server/routes.go b/internal/server/routes.go index ec61b70..1e63563 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -15,6 +15,8 @@ import ( const requestTimeout = 60 * time.Second // SetupRoutes configures all HTTP routes. +// +//nolint:funlen // route configuration is inherently long but straightforward func (s *Server) SetupRoutes() { s.router = chi.NewRouter() @@ -79,6 +81,10 @@ func (s *Server) SetupRoutes() { // Volumes r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) + + // Ports + r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) + r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) }) // Metrics endpoint (optional, with basic auth) diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index 6065eb0..1acf02d 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -339,6 +339,11 @@ func (svc *Service) buildContainerOptions( return docker.CreateContainerOptions{}, fmt.Errorf("failed to get volumes: %w", err) } + ports, err := app.GetPorts(ctx) + if err != nil { + return docker.CreateContainerOptions{}, fmt.Errorf("failed to get ports: %w", err) + } + envMap := make(map[string]string, len(envVars)) for _, envVar := range envVars { envMap[envVar.Key] = envVar.Value @@ -355,6 +360,7 @@ func (svc *Service) buildContainerOptions( Env: envMap, Labels: buildLabelMap(app, labels), Volumes: buildVolumeMounts(volumes), + Ports: buildPortMappings(ports), Network: network, }, nil } @@ -384,6 +390,19 @@ func buildVolumeMounts(volumes []*models.Volume) []docker.VolumeMount { return mounts } +func buildPortMappings(ports []*models.Port) []docker.PortMapping { + mappings := make([]docker.PortMapping, 0, len(ports)) + for _, port := range ports { + mappings = append(mappings, docker.PortMapping{ + HostPort: port.HostPort, + ContainerPort: port.ContainerPort, + Protocol: string(port.Protocol), + }) + } + + return mappings +} + func (svc *Service) updateAppRunning( ctx context.Context, app *models.App, diff --git a/internal/service/webhook/webhook_test.go b/internal/service/webhook/webhook_test.go index 90078a9..9eea66e 100644 --- a/internal/service/webhook/webhook_test.go +++ b/internal/service/webhook/webhook_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -100,6 +101,7 @@ func createTestApp( return app } +//nolint:funlen // table-driven test with comprehensive test cases func TestExtractBranch(testingT *testing.T) { testingT.Parallel() @@ -156,6 +158,9 @@ func TestExtractBranch(testingT *testing.T) { err := svc.HandleWebhook(context.Background(), app, "push", payload) require.NoError(t, err) + // Allow async deployment goroutine to complete before test cleanup + time.Sleep(100 * time.Millisecond) + events, err := app.GetWebhookEvents(context.Background(), 10) require.NoError(t, err) require.Len(t, events, 1) @@ -190,6 +195,9 @@ func TestHandleWebhookMatchingBranch(t *testing.T) { err := svc.HandleWebhook(context.Background(), app, "push", payload) require.NoError(t, err) + // Allow async deployment goroutine to complete before test cleanup + time.Sleep(100 * time.Millisecond) + events, err := app.GetWebhookEvents(context.Background(), 10) require.NoError(t, err) require.Len(t, events, 1) diff --git a/templates/app_detail.html b/templates/app_detail.html index d5eaaeb..8fffe4e 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -213,6 +213,58 @@ + +
+

Port Mappings

+ {{if .Ports}} +
+ + + + + + + + + + + {{range .Ports}} + + + + + + + {{end}} + +
Host PortContainer PortProtocolActions
{{.HostPort}}{{.ContainerPort}} + {{if eq .Protocol "udp"}} + UDP + {{else}} + TCP + {{end}} + +
+ +
+
+
+ {{end}} +
+
+ +
+
+ +
+ + +
+
+