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"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/docker/docker/pkg/archive"
|
"github.com/docker/docker/pkg/archive"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
@ -116,6 +118,7 @@ type CreateContainerOptions struct {
|
|||||||
Env map[string]string
|
Env map[string]string
|
||||||
Labels map[string]string
|
Labels map[string]string
|
||||||
Volumes []VolumeMount
|
Volumes []VolumeMount
|
||||||
|
Ports []PortMapping
|
||||||
Network string
|
Network string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,6 +129,37 @@ type VolumeMount struct {
|
|||||||
ReadOnly bool
|
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.
|
// CreateContainer creates a new container.
|
||||||
func (c *Client) CreateContainer(
|
func (c *Client) CreateContainer(
|
||||||
ctx context.Context,
|
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
|
// Create container
|
||||||
resp, err := c.docker.ContainerCreate(ctx,
|
resp, err := c.docker.ContainerCreate(ctx,
|
||||||
&container.Config{
|
&container.Config{
|
||||||
Image: opts.Image,
|
Image: opts.Image,
|
||||||
Env: envSlice,
|
Env: envSlice,
|
||||||
Labels: opts.Labels,
|
Labels: opts.Labels,
|
||||||
|
ExposedPorts: exposedPorts,
|
||||||
},
|
},
|
||||||
&container.HostConfig{
|
&container.HostConfig{
|
||||||
Mounts: mounts,
|
Mounts: mounts,
|
||||||
NetworkMode: container.NetworkMode(opts.Network),
|
PortBindings: portBindings,
|
||||||
|
NetworkMode: container.NetworkMode(opts.Network),
|
||||||
RestartPolicy: container.RestartPolicy{
|
RestartPolicy: container.RestartPolicy{
|
||||||
Name: container.RestartPolicyUnlessStopped,
|
Name: container.RestartPolicyUnlessStopped,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -121,6 +121,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
|
|||||||
envVars, _ := application.GetEnvVars(request.Context())
|
envVars, _ := application.GetEnvVars(request.Context())
|
||||||
labels, _ := application.GetLabels(request.Context())
|
labels, _ := application.GetLabels(request.Context())
|
||||||
volumes, _ := application.GetVolumes(request.Context())
|
volumes, _ := application.GetVolumes(request.Context())
|
||||||
|
ports, _ := application.GetPorts(request.Context())
|
||||||
deployments, _ := application.GetDeployments(
|
deployments, _ := application.GetDeployments(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
recentDeploymentsLimit,
|
recentDeploymentsLimit,
|
||||||
@ -135,6 +136,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
|
|||||||
"EnvVars": envVars,
|
"EnvVars": envVars,
|
||||||
"Labels": labels,
|
"Labels": labels,
|
||||||
"Volumes": volumes,
|
"Volumes": volumes,
|
||||||
|
"Ports": ports,
|
||||||
"Deployments": deployments,
|
"Deployments": deployments,
|
||||||
"WebhookURL": webhookURL,
|
"WebhookURL": webhookURL,
|
||||||
"DeployKey": deployKey,
|
"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.
|
// formatDeployKey formats an SSH public key with a descriptive comment.
|
||||||
// Format: ssh-ed25519 AAAA... upaas-2025-01-15-example.com.
|
// Format: ssh-ed25519 AAAA... upaas-2025-01-15-example.com.
|
||||||
func formatDeployKey(pubKey string, createdAt time.Time, host string) string {
|
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)
|
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.
|
// GetDeployments returns recent deployments for this app.
|
||||||
func (a *App) GetDeployments(ctx context.Context, limit int) ([]*Deployment, error) {
|
func (a *App) GetDeployments(ctx context.Context, limit int) ([]*Deployment, error) {
|
||||||
return FindDeploymentsByAppID(ctx, a.db, a.ID, limit)
|
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
|
const requestTimeout = 60 * time.Second
|
||||||
|
|
||||||
// SetupRoutes configures all HTTP routes.
|
// SetupRoutes configures all HTTP routes.
|
||||||
|
//
|
||||||
|
//nolint:funlen // route configuration is inherently long but straightforward
|
||||||
func (s *Server) SetupRoutes() {
|
func (s *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
|
|
||||||
@ -79,6 +81,10 @@ func (s *Server) SetupRoutes() {
|
|||||||
// Volumes
|
// Volumes
|
||||||
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
|
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
|
||||||
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
|
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)
|
// 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)
|
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))
|
envMap := make(map[string]string, len(envVars))
|
||||||
for _, envVar := range envVars {
|
for _, envVar := range envVars {
|
||||||
envMap[envVar.Key] = envVar.Value
|
envMap[envVar.Key] = envVar.Value
|
||||||
@ -355,6 +360,7 @@ func (svc *Service) buildContainerOptions(
|
|||||||
Env: envMap,
|
Env: envMap,
|
||||||
Labels: buildLabelMap(app, labels),
|
Labels: buildLabelMap(app, labels),
|
||||||
Volumes: buildVolumeMounts(volumes),
|
Volumes: buildVolumeMounts(volumes),
|
||||||
|
Ports: buildPortMappings(ports),
|
||||||
Network: network,
|
Network: network,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@ -384,6 +390,19 @@ func buildVolumeMounts(volumes []*models.Volume) []docker.VolumeMount {
|
|||||||
return mounts
|
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(
|
func (svc *Service) updateAppRunning(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@ -100,6 +101,7 @@ func createTestApp(
|
|||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:funlen // table-driven test with comprehensive test cases
|
||||||
func TestExtractBranch(testingT *testing.T) {
|
func TestExtractBranch(testingT *testing.T) {
|
||||||
testingT.Parallel()
|
testingT.Parallel()
|
||||||
|
|
||||||
@ -156,6 +158,9 @@ func TestExtractBranch(testingT *testing.T) {
|
|||||||
err := svc.HandleWebhook(context.Background(), app, "push", payload)
|
err := svc.HandleWebhook(context.Background(), app, "push", payload)
|
||||||
require.NoError(t, err)
|
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)
|
events, err := app.GetWebhookEvents(context.Background(), 10)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, events, 1)
|
require.Len(t, events, 1)
|
||||||
@ -190,6 +195,9 @@ func TestHandleWebhookMatchingBranch(t *testing.T) {
|
|||||||
err := svc.HandleWebhook(context.Background(), app, "push", payload)
|
err := svc.HandleWebhook(context.Background(), app, "push", payload)
|
||||||
require.NoError(t, err)
|
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)
|
events, err := app.GetWebhookEvents(context.Background(), 10)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, events, 1)
|
require.Len(t, events, 1)
|
||||||
|
|||||||
@ -213,6 +213,58 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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 -->
|
<!-- Recent Deployments -->
|
||||||
<div class="card p-6 mb-6">
|
<div class="card p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user