Skip to content

Commit 93f273f

Browse files
authored
fix: validate service port conflicts on same host (#326)
* fix: validate service port conflicts on same host Reject database specs where two services bind the same explicit port on the same host. Previously the conflict was only caught at Docker deploy time, resulting in an opaque context deadline exceeded error. Nil ports and port 0 (random assignment) are excluded from checking. * fix: include postgres instance ports in validation check This ensures all instances (database and service) avoid port conflicts.
1 parent 5109a1d commit 93f273f

2 files changed

Lines changed: 354 additions & 1 deletion

File tree

server/internal/api/apiv1/validate.go

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,10 @@ func validateDatabaseSpec(orchestrator config.Orchestrator, spec *api.DatabaseSp
130130
// Validate orchestrator_opts (spec-level)
131131
errs = append(errs, validateOrchestratorOpts(spec.OrchestratorOpts, []string{"orchestrator_opts"})...)
132132

133-
// Validate services
133+
// Validate services — seed portOwner with Postgres ports so services can't collide with the database.
134+
portOwner := make(servicePortOwnerMap)
135+
seedPostgresPorts(spec, portOwner)
136+
134137
seenServiceIDs := make(ds.Set[string], len(spec.Services))
135138
for i, svc := range spec.Services {
136139
svcPath := []string{"services", arrayIndexPath(i)}
@@ -142,6 +145,7 @@ func validateDatabaseSpec(orchestrator config.Orchestrator, spec *api.DatabaseSp
142145
}
143146
seenServiceIDs.Add(string(svc.ServiceID))
144147

148+
errs = append(errs, validateServicePortConflicts(svc, svcPath, portOwner)...)
145149
errs = append(errs, validateServiceSpec(svc, svcPath, false, seenNodeNames)...)
146150
}
147151

@@ -197,12 +201,18 @@ func validateDatabaseUpdate(old *database.Spec, new *api.DatabaseSpec) error {
197201
existingServiceIDs.Add(svc.ServiceID)
198202
}
199203

204+
// Seed portOwner with Postgres ports so services can't collide with the database.
205+
portOwner := make(servicePortOwnerMap)
206+
seedPostgresPorts(new, portOwner)
207+
200208
// Validate each service. Pass isUpdate=false for services being added for the
201209
// first time so that bootstrap-only fields are accepted. For service types that
202210
// have no bootstrap fields (e.g. postgrest) the flag has no effect.
203211
for i, svc := range new.Services {
204212
svcPath := []string{"services", arrayIndexPath(i)}
205213
isExistingService := existingServiceIDs.Has(string(svc.ServiceID))
214+
215+
errs = append(errs, validateServicePortConflicts(svc, svcPath, portOwner)...)
206216
errs = append(errs, validateServiceSpec(svc, svcPath, isExistingService, newNodeNames)...)
207217
}
208218

@@ -504,6 +514,56 @@ func validateUsers(users []*api.DatabaseUserSpec, path []string) []error {
504514
return errs
505515
}
506516

517+
// seedPostgresPorts registers each node's effective Postgres port in the
518+
// portOwner map so that service port validation can detect collisions with
519+
// the database. A node-level port override (node.Port) takes precedence
520+
// over the spec-level default (spec.Port).
521+
func seedPostgresPorts(spec *api.DatabaseSpec, owner servicePortOwnerMap) {
522+
for _, node := range spec.Nodes {
523+
pgPort := utils.FromPointer(spec.Port)
524+
if node.Port != nil {
525+
pgPort = *node.Port
526+
}
527+
if pgPort > 0 {
528+
for _, hostID := range node.HostIds {
529+
owner[hostPort{hostID: string(hostID), port: pgPort}] = "postgres"
530+
}
531+
}
532+
}
533+
}
534+
535+
// hostPort identifies a unique (host, port) binding for cross-service
536+
// port conflict detection.
537+
type hostPort struct {
538+
hostID string
539+
port int
540+
}
541+
542+
// servicePortOwnerMap tracks which service owns a given (host, port) pair.
543+
// Callers create one map and pass it to validateServicePortConflicts for
544+
// each service in the spec.
545+
type servicePortOwnerMap map[hostPort]string
546+
547+
// validateServicePortConflicts checks that the service's explicit port (if any)
548+
// does not collide with a port already claimed by another service on the same host.
549+
func validateServicePortConflicts(svc *api.ServiceSpec, path []string, owner servicePortOwnerMap) []error {
550+
if svc.Port == nil || *svc.Port <= 0 {
551+
return nil
552+
}
553+
554+
var errs []error
555+
for _, hostID := range svc.HostIds {
556+
key := hostPort{hostID: string(hostID), port: *svc.Port}
557+
if prev, exists := owner[key]; exists {
558+
err := fmt.Errorf("port %d conflicts with service %q on the same host", *svc.Port, prev)
559+
errs = append(errs, newValidationError(err, appendPath(path, "port")))
560+
} else {
561+
owner[key] = string(svc.ServiceID)
562+
}
563+
}
564+
return errs
565+
}
566+
507567
func validateBackupConfig(cfg *api.BackupConfigSpec, path []string) []error {
508568
var errs []error
509569

0 commit comments

Comments
 (0)