Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions server/internal/api/apiv1/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,8 @@ func validateServiceSpec(svc *api.ServiceSpec, path []string, isUpdate bool, nod
errs = append(errs, validateMemory(svc.Memory, appendPath(path, "memory"))...)
}

// Validate orchestrator_opts
errs = append(errs, validateOrchestratorOpts(svc.OrchestratorOpts, appendPath(path, "orchestrator_opts"))...)
// Validate orchestrator_opts (service-specific restrictions on top of shared checks)
errs = append(errs, validateServiceOrchestratorOpts(svc.OrchestratorOpts, appendPath(path, "orchestrator_opts"))...)

return errs
}
Expand Down Expand Up @@ -662,6 +662,32 @@ func validateOrchestratorOpts(opts *api.OrchestratorOpts, path []string) []error
return errs
}

// validateServiceOrchestratorOpts runs the shared orchestrator_opts checks and
// adds service-specific restrictions. Services do not support extra_volumes
// (bind mounts are configured per service type) or driver_opts on extra_networks.
func validateServiceOrchestratorOpts(opts *api.OrchestratorOpts, path []string) []error {
errs := validateOrchestratorOpts(opts, path)

if opts == nil || opts.Swarm == nil {
return errs
}

if len(opts.Swarm.ExtraVolumes) > 0 {
err := errors.New("extra_volumes is not supported for services")
errs = append(errs, newValidationError(err, appendPath(path, "swarm", "extra_volumes")))
}

for i, net := range opts.Swarm.ExtraNetworks {
if len(net.DriverOpts) > 0 {
netPath := appendPath(path, "swarm", "extra_networks", arrayIndexPath(i), "driver_opts")
err := errors.New("driver_opts is not supported for services")
errs = append(errs, newValidationError(err, netPath))
}
}

return errs
}

func validatePgBackRestOptions(opts map[string]string, path []string) []error {
var errs []error

Expand Down
57 changes: 57 additions & 0 deletions server/internal/api/apiv1/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1264,6 +1264,63 @@ func TestValidateServiceSpec(t *testing.T) {
"cpus: failed to parse CPUs",
},
},
{
name: "extra_volumes rejected for services",
svc: &api.ServiceSpec{
ServiceID: "mcp-server",
ServiceType: "mcp",
Version: "latest",
HostIds: []api.Identifier{"host-1"},
Config: map[string]any{},
OrchestratorOpts: &api.OrchestratorOpts{
Swarm: &api.SwarmOpts{
ExtraVolumes: []*api.ExtraVolumesSpec{
{HostPath: "/data", DestinationPath: "/mnt/data"},
},
},
},
},
expected: []string{
"orchestrator_opts.swarm.extra_volumes: extra_volumes is not supported for services",
},
},
{
name: "driver_opts rejected for service extra_networks",
svc: &api.ServiceSpec{
ServiceID: "mcp-server",
ServiceType: "mcp",
Version: "latest",
HostIds: []api.Identifier{"host-1"},
Config: map[string]any{},
OrchestratorOpts: &api.OrchestratorOpts{
Swarm: &api.SwarmOpts{
ExtraNetworks: []*api.ExtraNetworkSpec{
{ID: "traefik", DriverOpts: map[string]string{"com.docker.network.driver.mtu": "1500"}},
},
},
},
},
expected: []string{
"orchestrator_opts.swarm.extra_networks[0].driver_opts: driver_opts is not supported for services",
},
},
{
name: "valid service with extra_networks and no driver_opts",
svc: &api.ServiceSpec{
ServiceID: "mcp-server",
ServiceType: "mcp",
Version: "latest",
HostIds: []api.Identifier{"host-1"},
Config: map[string]any{},
OrchestratorOpts: &api.OrchestratorOpts{
Swarm: &api.SwarmOpts{
ExtraNetworks: []*api.ExtraNetworkSpec{
{ID: "traefik"},
},
},
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
err := errors.Join(validateServiceSpec(tc.svc, nil, false)...)
Expand Down
24 changes: 21 additions & 3 deletions server/internal/orchestrator/swarm/service_spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,17 @@ func ServiceContainerSpec(opts *ServiceContainerSpecOptions) (swarm.ServiceSpec,
"pgedge.host.id": opts.HostID,
}

// Merge user-provided extra labels (matches Postgres ExtraLabels behavior)
if opts.ServiceSpec.OrchestratorOpts != nil && opts.ServiceSpec.OrchestratorOpts.Swarm != nil {
for k, v := range opts.ServiceSpec.OrchestratorOpts.Swarm.ExtraLabels {
// Extract swarm orchestrator options (matches Postgres pattern in spec.go).
// ExtraVolumes and DriverOpts are rejected at the API validation layer
// (validateServiceOrchestratorOpts).
var swarmOpts *database.SwarmOpts
if opts.ServiceSpec.OrchestratorOpts != nil {
swarmOpts = opts.ServiceSpec.OrchestratorOpts.Swarm
}

// Merge user-provided extra labels
if swarmOpts != nil {
for k, v := range swarmOpts.ExtraLabels {
labels[k] = v
}
}
Expand All @@ -90,6 +98,16 @@ func ServiceContainerSpec(opts *ServiceContainerSpecOptions) (swarm.ServiceSpec,
},
}

// Append user-requested extra networks (e.g. Traefik, reverse proxy).
if swarmOpts != nil {
for _, net := range swarmOpts.ExtraNetworks {
networks = append(networks, swarm.NetworkAttachmentConfig{
Target: net.ID,
Aliases: net.Aliases,
})
}
}

// Get container image (already resolved in ServiceImage)
image := opts.ServiceImage.Tag

Expand Down
80 changes: 80 additions & 0 deletions server/internal/orchestrator/swarm/service_spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,86 @@ func intPtr(i int) *int {
return &i
}

func TestServiceContainerSpec_ExtraNetworks(t *testing.T) {
opts := &ServiceContainerSpecOptions{
ServiceSpec: &database.ServiceSpec{
ServiceID: "mcp-server",
ServiceType: "mcp",
OrchestratorOpts: &database.OrchestratorOpts{
Swarm: &database.SwarmOpts{
ExtraNetworks: []database.ExtraNetworkSpec{
{ID: "traefik_us-west-2a", Aliases: []string{"mcp"}},
{ID: "monitoring"},
},
},
},
},
ServiceInstanceID: "db1-mcp-host1",
DatabaseID: "db1",
DatabaseName: "testdb",
HostID: "host1",
ServiceName: "db1-mcp-host1",
Hostname: "mcp-host1",
CohortMemberID: "node-123",
ServiceImage: &ServiceImage{Tag: "ghcr.io/pgedge/postgres-mcp:latest"},
Credentials: &database.ServiceUser{Username: "svc_mcp", Password: "pw"},
DatabaseNetworkID: "db1-database",
Port: intPtr(8080),
DataPath: "/var/lib/pgedge/services/db1-mcp-host1",
}

spec, err := ServiceContainerSpec(opts)
if err != nil {
t.Fatalf("ServiceContainerSpec() error = %v", err)
}

networks := spec.TaskTemplate.Networks
// [0]=bridge, [1]=database overlay, [2..]=extra networks
if len(networks) != 4 {
t.Fatalf("got %d networks, want 4", len(networks))
}
if networks[2].Target != "traefik_us-west-2a" {
t.Errorf("networks[2].Target = %q, want %q", networks[2].Target, "traefik_us-west-2a")
}
if len(networks[2].Aliases) != 1 || networks[2].Aliases[0] != "mcp" {
t.Errorf("networks[2].Aliases = %v, want [mcp]", networks[2].Aliases)
}
if networks[3].Target != "monitoring" {
t.Errorf("networks[3].Target = %q, want %q", networks[3].Target, "monitoring")
}
}

func TestServiceContainerSpec_NoExtraNetworks(t *testing.T) {
opts := &ServiceContainerSpecOptions{
ServiceSpec: &database.ServiceSpec{
ServiceID: "mcp-server",
ServiceType: "mcp",
},
ServiceInstanceID: "db1-mcp-host1",
DatabaseID: "db1",
DatabaseName: "testdb",
HostID: "host1",
ServiceName: "db1-mcp-host1",
Hostname: "mcp-host1",
CohortMemberID: "node-123",
ServiceImage: &ServiceImage{Tag: "ghcr.io/pgedge/postgres-mcp:latest"},
Credentials: &database.ServiceUser{Username: "svc_mcp", Password: "pw"},
DatabaseNetworkID: "db1-database",
Port: intPtr(8080),
DataPath: "/var/lib/pgedge/services/db1-mcp-host1",
}

spec, err := ServiceContainerSpec(opts)
if err != nil {
t.Fatalf("ServiceContainerSpec() error = %v", err)
}

networks := spec.TaskTemplate.Networks
if len(networks) != 2 {
t.Fatalf("got %d networks, want 2", len(networks))
}
}

// --- PostgREST container spec tests ---

func makePostgRESTSpecOpts() *ServiceContainerSpecOptions {
Expand Down
2 changes: 2 additions & 0 deletions server/internal/orchestrator/swarm/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ func DatabaseServiceSpec(
mounts = append(mounts, docker.BuildMount(vol.HostPath, vol.DestinationPath, false))
}

// NOTE: DriverOpts on ExtraNetworkSpec is accepted by the API but not
// passed through here — add if needed.
for _, net := range instance.OrchestratorOpts.Swarm.ExtraNetworks {
networks = append(networks, swarm.NetworkAttachmentConfig{
Target: net.ID,
Expand Down