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
This commit is contained in:
Jeffrey Paul 2025-12-30 12:11:57 +07:00
parent 4ece7431af
commit bc275f7b9c
9 changed files with 398 additions and 5 deletions

View File

@ -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);

View File

@ -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,
},

View File

@ -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 {

View File

@ -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)

160
internal/models/port.go Normal file
View File

@ -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
}

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -213,6 +213,58 @@
</form>
</div>
<!-- Ports -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Port Mappings</h2>
{{if .Ports}}
<div class="overflow-x-auto mb-4">
<table class="table">
<thead class="table-header">
<tr>
<th>Host Port</th>
<th>Container Port</th>
<th>Protocol</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody class="table-body">
{{range .Ports}}
<tr>
<td class="font-mono">{{.HostPort}}</td>
<td class="font-mono">{{.ContainerPort}}</td>
<td>
{{if eq .Protocol "udp"}}
<span class="badge-warning">UDP</span>
{{else}}
<span class="badge-info">TCP</span>
{{end}}
</td>
<td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/ports/{{.ID}}/delete" class="inline" data-confirm="Delete this port mapping?">
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<form method="POST" action="/apps/{{.App.ID}}/ports" class="flex flex-col sm:flex-row gap-2 items-end">
<div class="flex-1 w-full">
<input type="number" name="host_port" placeholder="Host port" required min="1" max="65535" class="input font-mono text-sm">
</div>
<div class="flex-1 w-full">
<input type="number" name="container_port" placeholder="Container port" required min="1" max="65535" class="input font-mono text-sm">
</div>
<select name="protocol" class="input text-sm">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<button type="submit" class="btn-primary">Add</button>
</form>
</div>
<!-- Recent Deployments -->
<div class="card p-6 mb-6">
<div class="flex items-center justify-between mb-4">