mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
fix: persist service host port; demo-data scope for telemetry
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>
This commit is contained in:
1 parent
a91e6531f5
commit
c15e6514df
7 files changed
+103
-7
No files matched your search
@@ -356,6 +356,12 @@ func runDeploymentAndSyncWithImage(
|
|||||||
replicas = 1
|
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{
|
deployReq := &deployment.DeploymentRequest{
|
||||||
ProjectID: service.ProjectID.String(),
|
ProjectID: service.ProjectID.String(),
|
||||||
ServiceID: service.ID.String(),
|
ServiceID: service.ID.String(),
|
||||||
@@ -367,6 +373,7 @@ func runDeploymentAndSyncWithImage(
|
|||||||
Environment: env,
|
Environment: env,
|
||||||
Replicas: replicas,
|
Replicas: replicas,
|
||||||
PublicPort: int32(service.Port),
|
PublicPort: int32(service.Port),
|
||||||
|
PublishedPort: publishedPort,
|
||||||
Domain: service.Domain,
|
Domain: service.Domain,
|
||||||
HealthPath: service.HealthCheckPath,
|
HealthPath: service.HealthCheckPath,
|
||||||
RestartPolicy: service.RestartPolicy,
|
RestartPolicy: service.RestartPolicy,
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
|||||||
HealthPath: service.HealthCheckPath,
|
HealthPath: service.HealthCheckPath,
|
||||||
RestartPolicy: service.RestartPolicy,
|
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 != "" {
|
if cmd := strings.TrimSpace(service.Command); cmd != "" {
|
||||||
spec.Command = strings.Fields(cmd)
|
spec.Command = strings.Fields(cmd)
|
||||||
}
|
}
|
||||||
@@ -284,6 +288,7 @@ func handleGetServiceRuntime(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath)
|
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 != "" {
|
if host := requestHostname(c); host != "" {
|
||||||
for i, u := range state.URLs {
|
for i, u := range state.URLs {
|
||||||
state.URLs[i] = rewriteLoopbackURL(u, host)
|
state.URLs[i] = rewriteLoopbackURL(u, host)
|
||||||
@@ -308,6 +313,7 @@ func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *datab
|
|||||||
status := "stopped"
|
status := "stopped"
|
||||||
if state != nil {
|
if state != nil {
|
||||||
status = state.Status
|
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)
|
_, _ = 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})
|
c.JSON(http.StatusOK, gin.H{"runtime": state})
|
||||||
@@ -403,6 +409,18 @@ func handleServiceRedeploy(c *gin.Context) {
|
|||||||
reconcileNow(c, engine, db, service)
|
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.
|
// liveServiceStatus reconciles the stored status with real container state.
|
||||||
// No-op when Docker is unavailable — the stored status is returned as-is.
|
// No-op when Docker is unavailable — the stored status is returned as-is.
|
||||||
func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
|
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
|
service.Status = state.Status
|
||||||
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID)
|
_, _ = 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 != "" {
|
if service.Domain != "" {
|
||||||
service.PublicURL = "https://" + service.Domain
|
service.PublicURL = "https://" + service.Domain
|
||||||
} else if len(state.URLs) > 0 {
|
} 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)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if _, err := engine.ReconcileService(ctx, spec); err != nil {
|
state, err := engine.ReconcileService(ctx, spec)
|
||||||
|
if err != nil {
|
||||||
return err
|
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)
|
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -520,7 +520,9 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
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)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ type ServiceConfig struct {
|
|||||||
Domain string `json:"domain,omitempty"`
|
Domain string `json:"domain,omitempty"`
|
||||||
// HealthPath is an HTTP path probed on PublicPort for container health.
|
// HealthPath is an HTTP path probed on PublicPort for container health.
|
||||||
HealthPath string `json:"health_path,omitempty"`
|
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 {
|
type PortMapping struct {
|
||||||
@@ -315,6 +318,7 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
|||||||
Env: cfg.Environment,
|
Env: cfg.Environment,
|
||||||
Replicas: cfg.Replicas,
|
Replicas: cfg.Replicas,
|
||||||
Port: cfg.PublicPort,
|
Port: cfg.PublicPort,
|
||||||
|
PublishedPort: cfg.PublishedPort,
|
||||||
Domain: cfg.Domain,
|
Domain: cfg.Domain,
|
||||||
HealthPath: cfg.HealthPath,
|
HealthPath: cfg.HealthPath,
|
||||||
RestartPolicy: cfg.RestartPolicy,
|
RestartPolicy: cfg.RestartPolicy,
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ type RuntimeSpec struct {
|
|||||||
Env map[string]string // already resolved
|
Env map[string]string // already resolved
|
||||||
Replicas int
|
Replicas int
|
||||||
Port int32 // container port to expose publicly; 0 = none
|
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
|
Domain string // public hostname routed via Traefik; empty = none
|
||||||
HealthPath string // http path probed on Port for container healthcheck
|
HealthPath string // http path probed on Port for container healthcheck
|
||||||
RestartPolicy string
|
RestartPolicy string
|
||||||
@@ -240,22 +243,70 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
|||||||
Networks: endpoints,
|
Networks: endpoints,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var wantPort nat.Port
|
||||||
if spec.Port > 0 {
|
if spec.Port > 0 {
|
||||||
port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
||||||
cfg.ExposedPorts = nat.PortSet{port: struct{}{}}
|
cfg.ExposedPorts = nat.PortSet{wantPort: 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: ""}}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
id, err := de.dockerClient.CreateContainer(ctx, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := de.dockerClient.StartContainer(ctx, id); err != nil {
|
if err := de.dockerClient.StartContainer(ctx, id); err != nil {
|
||||||
|
_ = de.dockerClient.RemoveContainer(ctx, id, true)
|
||||||
return "", fmt.Errorf("start: %w", err)
|
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
|
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
|
||||||
|
}
|
||||||
|
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.
|
// RuntimeState reads live container state for a service.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -456,6 +456,7 @@ export const demoHostMonitoring: HostMonitoring = {
|
|||||||
uptimeSeconds: 1_828_800,
|
uptimeSeconds: 1_828_800,
|
||||||
dockerAvailable: true,
|
dockerAvailable: true,
|
||||||
docker: { containers: 11, images: 34, driver: 'overlay2', server: '27.3.1' },
|
docker: { containers: 11, images: 34, driver: 'overlay2', server: '27.3.1' },
|
||||||
|
scope: 'host',
|
||||||
collectedAt: new Date().toISOString(),
|
collectedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user