From c15e6514dfb5886844e48b5fae3138fd4f5a2e6a Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 4 Oct 2026 08:54:23 +0200 Subject: [PATCH] fix: persist service host port; demo-data scope for telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public URLs were ephemeral: every restart/redeploy rebound a random host port, so endpoint links rotted. Persist published_port on the service and prefer it for replica 0 on create. If the port is taken, fall back to ephemeral and repersist the new assignment. Post-start the binding is verified — Docker Desktop accepts a conflicting publish silently, which previously left services running with no endpoint at all. demo-data.ts gains the required scope field for HostMonitoring (CI fix). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/backend/internal/api/deployments.go | 7 ++ app/backend/internal/api/runtime.go | 25 ++++++- app/backend/internal/api/services.go | 4 +- app/backend/internal/deployment/engine.go | 4 ++ app/backend/internal/deployment/runtime.go | 69 ++++++++++++++++--- .../20261003120000_service_published_port.sql | 8 +++ app/frontend/src/lib/demo-data.ts | 1 + 7 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 app/backend/migrations_goose/20261003120000_service_published_port.sql diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index bbb4c5a..0b3b9d3 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -356,6 +356,12 @@ func runDeploymentAndSyncWithImage( replicas = 1 } + var publishedPort int32 + // Best effort: reuse the host port from the last live deployment so public + // URLs survive redeploys. Column exists post-migration; older DBs get + // ephemeral ports. + _ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort) + deployReq := &deployment.DeploymentRequest{ ProjectID: service.ProjectID.String(), ServiceID: service.ID.String(), @@ -367,6 +373,7 @@ func runDeploymentAndSyncWithImage( Environment: env, Replicas: replicas, PublicPort: int32(service.Port), + PublishedPort: publishedPort, Domain: service.Domain, HealthPath: service.HealthCheckPath, RestartPolicy: service.RestartPolicy, diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go index ae74fba..51fd126 100644 --- a/app/backend/internal/api/runtime.go +++ b/app/backend/internal/api/runtime.go @@ -36,6 +36,10 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe HealthPath: service.HealthCheckPath, RestartPolicy: service.RestartPolicy, } + // Best effort: reuse the host port from the last live deployment so the + // public URL survives restarts. Column exists post-migration; older DBs + // silently get ephemeral ports. + _ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&spec.PublishedPort) if cmd := strings.TrimSpace(service.Command); cmd != "" { spec.Command = strings.Fields(cmd) } @@ -284,6 +288,7 @@ func handleGetServiceRuntime(c *gin.Context) { return } engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath) + persistPublishedPort(db, service.ID, state.Ports) if host := requestHostname(c); host != "" { for i, u := range state.URLs { state.URLs[i] = rewriteLoopbackURL(u, host) @@ -308,6 +313,7 @@ func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *datab status := "stopped" if state != nil { status = state.Status + persistPublishedPort(db, service.ID, state.Ports) } _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID) c.JSON(http.StatusOK, gin.H{"runtime": state}) @@ -403,6 +409,18 @@ func handleServiceRedeploy(c *gin.Context) { reconcileNow(c, engine, db, service) } +// persistPublishedPort records the host port Docker actually bound so later +// redeploys can reuse it. Idempotent — only writes when the value changed. +func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16) { + if len(ports) == 0 { + return + } + _, _ = db.Exec( + `UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1`, + int(ports[0]), serviceID, + ) +} + // liveServiceStatus reconciles the stored status with real container state. // No-op when Docker is unavailable — the stored status is returned as-is. func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) { @@ -421,6 +439,7 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) { service.Status = state.Status _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID) } + persistPublishedPort(db, service.ID, state.Ports) if service.Domain != "" { service.PublicURL = "https://" + service.Domain } else if len(state.URLs) > 0 { @@ -481,9 +500,13 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - if _, err := engine.ReconcileService(ctx, spec); err != nil { + state, err := engine.ReconcileService(ctx, spec) + if err != nil { return err } + if state != nil { + persistPublishedPort(db, service.ID, state.Ports) + } _, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID) return nil } diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index b4e2c47..b9d9b4d 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -520,7 +520,9 @@ func handleUpdateService(c *gin.Context) { } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - _, _ = engine.ReconcileService(ctx, spec) + if state, err := engine.ReconcileService(ctx, spec); err == nil && state != nil { + persistPublishedPort(db.(*database.DB), serviceCopy.ID, state.Ports) + } }() } } diff --git a/app/backend/internal/deployment/engine.go b/app/backend/internal/deployment/engine.go index a87d16c..4cd265a 100644 --- a/app/backend/internal/deployment/engine.go +++ b/app/backend/internal/deployment/engine.go @@ -59,6 +59,9 @@ type ServiceConfig struct { Domain string `json:"domain,omitempty"` // HealthPath is an HTTP path probed on PublicPort for container health. HealthPath string `json:"health_path,omitempty"` + // PublishedPort is the host port bound by the previous deployment; replica 0 + // reuses it so public URLs survive restarts. 0 = pick ephemeral. + PublishedPort int32 `json:"published_port,omitempty"` } type PortMapping struct { @@ -315,6 +318,7 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo Env: cfg.Environment, Replicas: cfg.Replicas, Port: cfg.PublicPort, + PublishedPort: cfg.PublishedPort, Domain: cfg.Domain, HealthPath: cfg.HealthPath, RestartPolicy: cfg.RestartPolicy, diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go index 075b0c1..19d7833 100644 --- a/app/backend/internal/deployment/runtime.go +++ b/app/backend/internal/deployment/runtime.go @@ -5,9 +5,11 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "net/http" "sort" + "strconv" "strings" "time" @@ -39,6 +41,7 @@ type RuntimeSpec struct { Env map[string]string // already resolved Replicas int Port int32 // container port to expose publicly; 0 = none + PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral Domain string // public hostname routed via Traefik; empty = none HealthPath string // http path probed on Port for container healthcheck RestartPolicy string @@ -240,24 +243,72 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, Networks: endpoints, } + var wantPort nat.Port if spec.Port > 0 { - port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port)) - cfg.ExposedPorts = nat.PortSet{port: struct{}{}} - // Always publish an ephemeral host port for direct access; a configured - // domain is additionally routed via Traefik when the edge network exists. - cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}} + wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port)) + cfg.ExposedPorts = nat.PortSet{wantPort: struct{}{}} } - id, err := de.dockerClient.CreateContainer(ctx, cfg) + // tryBind creates and starts a container publishing wantPort on hostPort + // ("" = ephemeral). A requested port is verified after start: Docker + // Desktop accepts a conflicting binding at create/start but publishes + // nothing, which would leave the service running with no endpoint. + tryBind := func(hostPort string) (string, error) { + if spec.Port > 0 { + cfg.PortBindings = nat.PortMap{wantPort: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: hostPort}}} + } + id, err := de.dockerClient.CreateContainer(ctx, cfg) + if err != nil { + return "", err + } + if err := de.dockerClient.StartContainer(ctx, id); err != nil { + _ = de.dockerClient.RemoveContainer(ctx, id, true) + return "", fmt.Errorf("start: %w", err) + } + if hostPort != "" { + info, err := de.dockerClient.GetContainer(ctx, id) + if err != nil { + _ = de.dockerClient.RemoveContainer(ctx, id, true) + return "", fmt.Errorf("inspect: %w", err) + } + if info.NetworkSettings == nil || len(info.NetworkSettings.Ports[wantPort]) == 0 { + _ = de.dockerClient.RemoveContainer(ctx, id, true) + return "", errPortNotBound + } + } + return id, nil + } + + preferred := "" + if index == 0 && spec.PublishedPort > 0 { + preferred = strconv.Itoa(int(spec.PublishedPort)) + } + id, err := tryBind(preferred) + if err != nil && preferred != "" && (isPortConflict(err) || errors.Is(err, errPortNotBound)) { + // Stored port got taken since last deploy — fall back to ephemeral; the + // new assignment is persisted on the next runtime read. + id, err = tryBind("") + } if err != nil { return "", err } - if err := de.dockerClient.StartContainer(ctx, id); err != nil { - return "", fmt.Errorf("start: %w", err) - } return id, nil } +// errPortNotBound marks a container that started but failed to publish the +// requested host port (seen on Docker Desktop, which reports success while +// vpnkit drops a conflicting binding). +var errPortNotBound = errors.New("requested host port not bound") + +// isPortConflict reports whether a container create failed because the +// requested host port was already bound. +func isPortConflict(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "port is already allocated") || + strings.Contains(msg, "address already in use") || + strings.Contains(msg, "cannot assign requested address") +} + // RuntimeState reads live container state for a service. func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, desired int) (*RuntimeState, error) { containers, err := de.ListServiceContainers(ctx, serviceID) diff --git a/app/backend/migrations_goose/20261003120000_service_published_port.sql b/app/backend/migrations_goose/20261003120000_service_published_port.sql new file mode 100644 index 0000000..7939107 --- /dev/null +++ b/app/backend/migrations_goose/20261003120000_service_published_port.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- Stable public host port for service replica 0. 0 means "not yet assigned"; +-- the runtime writes the ephemeral port it picked back here so restarts and +-- redeploys keep the same public endpoint instead of churning. +ALTER TABLE public.services ADD COLUMN IF NOT EXISTS published_port integer NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE public.services DROP COLUMN IF EXISTS published_port; diff --git a/app/frontend/src/lib/demo-data.ts b/app/frontend/src/lib/demo-data.ts index e0f13cc..30d2eb2 100644 --- a/app/frontend/src/lib/demo-data.ts +++ b/app/frontend/src/lib/demo-data.ts @@ -456,6 +456,7 @@ export const demoHostMonitoring: HostMonitoring = { uptimeSeconds: 1_828_800, dockerAvailable: true, docker: { containers: 11, images: 34, driver: 'overlay2', server: '27.3.1' }, + scope: 'host', collectedAt: new Date().toISOString(), };