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:
parent
4ece7431af
commit
bc275f7b9c
12
internal/database/migrations/003_add_ports.sql
Normal file
12
internal/database/migrations/003_add_ports.sql
Normal 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);
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
160
internal/models/port.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user