From 0726290492f7b09447d6469c27f4391dc89aca1b Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Mon, 9 Feb 2026 14:41:07 -0500 Subject: [PATCH 01/12] Initial implementation of supported services. --- .gitignore | 1 + Makefile | 10 +- api/apiv1/design/database.go | 72 +- api/apiv1/design/instance.go | 143 + api/apiv1/gen/control_plane/service.go | 452 + api/apiv1/gen/control_plane/views/view.go | 349 + api/apiv1/gen/http/cli/control_plane/cli.go | 2 +- .../gen/http/control_plane/client/cli.go | 2 +- .../control_plane/client/encode_decode.go | 476 +- .../gen/http/control_plane/client/types.go | 354 +- .../control_plane/server/encode_decode.go | 360 +- .../gen/http/control_plane/server/types.go | 337 +- api/apiv1/gen/http/openapi.json | 1749 ++-- api/apiv1/gen/http/openapi.yaml | 1318 ++- api/apiv1/gen/http/openapi3.json | 8015 ++++++++++------- api/apiv1/gen/http/openapi3.yaml | 4692 ++++++---- docs/development/service-credentials.md | 267 + e2e/service_provisioning_test.go | 605 ++ server/internal/api/apiv1/convert.go | 200 +- server/internal/api/apiv1/validate.go | 113 + server/internal/api/apiv1/validate_test.go | 451 + server/internal/database/database.go | 42 +- server/internal/database/instance.go | 5 +- server/internal/database/orchestrator.go | 12 + server/internal/database/service.go | 268 +- server/internal/database/service_instance.go | 224 + .../database/service_instance_status_store.go | 63 + .../database/service_instance_store.go | 99 + .../database/service_instance_test.go | 212 + server/internal/database/spec.go | 33 + server/internal/database/store.go | 24 +- server/internal/docker/docker.go | 73 +- server/internal/docker/provide.go | 8 +- server/internal/monitor/host_monitor.go | 2 +- server/internal/monitor/instance_monitor.go | 2 +- server/internal/monitor/resources.go | 1 + server/internal/monitor/service.go | 114 +- .../monitor/service_instance_monitor.go | 318 + .../service_instance_monitor_resource.go | 96 + .../monitor/service_instance_monitor_store.go | 63 + server/internal/monitor/store.go | 10 +- .../orchestrator/swarm/orchestrator.go | 150 +- .../internal/orchestrator/swarm/resources.go | 3 + .../orchestrator/swarm/service_images.go | 89 + .../orchestrator/swarm/service_images_test.go | 143 + .../orchestrator/swarm/service_instance.go | 246 + .../swarm/service_instance_spec.go | 113 + .../orchestrator/swarm/service_spec.go | 196 + .../orchestrator/swarm/service_spec_test.go | 600 ++ .../orchestrator/swarm/service_user_role.go | 185 + server/internal/orchestrator/swarm/utils.go | 17 + .../workflows/activities/activities.go | 14 +- .../activities/create_service_user.go | 216 + .../generate_service_instance_resources.go | 53 + .../activities/get_service_instance_status.go | 56 + .../internal/workflows/activities/provide.go | 13 +- .../activities/store_service_instance.go | 57 + .../update_service_instance_state.go | 60 + .../internal/workflows/provision_services.go | 423 + server/internal/workflows/update_database.go | 33 + server/internal/workflows/workflows.go | 7 +- 61 files changed, 17833 insertions(+), 6478 deletions(-) create mode 100644 docs/development/service-credentials.md create mode 100644 e2e/service_provisioning_test.go create mode 100644 server/internal/database/service_instance.go create mode 100644 server/internal/database/service_instance_status_store.go create mode 100644 server/internal/database/service_instance_store.go create mode 100644 server/internal/database/service_instance_test.go create mode 100644 server/internal/monitor/service_instance_monitor.go create mode 100644 server/internal/monitor/service_instance_monitor_resource.go create mode 100644 server/internal/monitor/service_instance_monitor_store.go create mode 100644 server/internal/orchestrator/swarm/service_images.go create mode 100644 server/internal/orchestrator/swarm/service_images_test.go create mode 100644 server/internal/orchestrator/swarm/service_instance.go create mode 100644 server/internal/orchestrator/swarm/service_instance_spec.go create mode 100644 server/internal/orchestrator/swarm/service_spec.go create mode 100644 server/internal/orchestrator/swarm/service_spec_test.go create mode 100644 server/internal/orchestrator/swarm/service_user_role.go create mode 100644 server/internal/workflows/activities/create_service_user.go create mode 100644 server/internal/workflows/activities/generate_service_instance_resources.go create mode 100644 server/internal/workflows/activities/get_service_instance_status.go create mode 100644 server/internal/workflows/activities/store_service_instance.go create mode 100644 server/internal/workflows/activities/update_service_instance_state.go create mode 100644 server/internal/workflows/provision_services.go diff --git a/.gitignore b/.gitignore index b965316f..bd61081b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ bruno/test-scenarios/**/repos bruno/test-scenarios/environments !bruno/test-scenarios/environments/compose.bru **/.claude/settings.local.json +.idea # build artifacts *-results.xml diff --git a/Makefile b/Makefile index 6ff2ee99..0180edcb 100644 --- a/Makefile +++ b/Makefile @@ -368,10 +368,12 @@ dev-down: .PHONY: dev-teardown dev-teardown: dev-down - docker service ls \ - --filter=label=pgedge.component=postgres \ - --format '{{ .ID }}' \ - | xargs docker service rm + # remove postgres and supported services + docker service ls -q \ + | xargs -r docker service inspect \ + --format '{{.ID}} {{index .Spec.Labels "pgedge.component"}}' \ + | awk '$$2=="postgres" || $$2=="service" {print $$1}' \ + | xargs -r docker service rm docker network ls \ --filter=scope=swarm \ --format '{{ .Name }}' \ diff --git a/api/apiv1/design/database.go b/api/apiv1/design/database.go index 3a8e4f3b..43d33794 100644 --- a/api/apiv1/design/database.go +++ b/api/apiv1/design/database.go @@ -9,6 +9,7 @@ const ( cpuPattern = `^[0-9]+(\.[0-9]{1,3}|m)?$` postgresVersionPattern = `^\d{2}\.\d{1,2}$` spockVersionPattern = `^\d{1}$` + serviceVersionPattern = `^(\d+\.\d+\.\d+|latest)$` ) var HostIDs = g.ArrayOf(Identifier, func() { @@ -105,6 +106,66 @@ var DatabaseUserSpec = g.Type("DatabaseUserSpec", func() { g.Required("username") }) +var ServiceSpec = g.Type("ServiceSpec", func() { + g.Attribute("service_id", Identifier, func() { + g.Description("The unique identifier for this service.") + g.Example("mcp-server") + g.Example("analytics-service") + }) + g.Attribute("service_type", g.String, func() { + g.Description("The type of service to run.") + g.Enum("mcp") + g.Example("mcp") + }) + g.Attribute("version", g.String, func() { + g.Description("The version of the service in semver format (e.g., '1.0.0') or the literal 'latest'.") + g.Pattern(serviceVersionPattern) + g.Example("1.0.0") + g.Example("1.2.3") + g.Example("latest") + }) + g.Attribute("host_ids", HostIDs, func() { + g.Description("The IDs of the hosts that should run this service. One service instance will be created per host.") + g.MinLength(1) + }) + g.Attribute("port", g.Int, func() { + g.Description("The port to publish the service on the host. If 0, Docker assigns a random port. If unspecified, no port is published and the service is not accessible from outside the Docker network.") + g.Minimum(0) + g.Maximum(65535) + g.Example(8080) + g.Example(9000) + g.Example(0) + }) + g.Attribute("config", g.MapOf(g.String, g.Any), func() { + g.Description("Service-specific configuration. For MCP services, this includes llm_provider, llm_model, and provider-specific API keys.") + g.Example(map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }) + g.Example(map[string]any{ + "llm_provider": "openai", + "llm_model": "gpt-4", + "openai_api_key": "sk-...", + }) + }) + g.Attribute("cpus", g.String, func() { + g.Description("The number of CPUs to allocate for this service. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if unspecified.") + g.Pattern(cpuPattern) + g.Example("1") + g.Example("0.5") + g.Example("500m") + }) + g.Attribute("memory", g.String, func() { + g.Description("The amount of memory in SI or IEC notation to allocate for this service. Defaults to container defaults if unspecified.") + g.MaxLength(16) + g.Example("1GiB") + g.Example("512M") + }) + + g.Required("service_id", "service_type", "version", "host_ids", "config") +}) + var BackupRepositorySpec = g.Type("BackupRepositorySpec", func() { g.Attribute("id", Identifier, func() { g.Description("The unique identifier of this repository.") @@ -425,6 +486,9 @@ var DatabaseSpec = g.Type("DatabaseSpec", func() { g.Description("The users to create for this database.") g.MaxLength(16) }) + g.Attribute("services", g.ArrayOf(ServiceSpec), func() { + g.Description("Service instances to run alongside the database (e.g., MCP servers).") + }) g.Attribute("backup_config", BackupConfigSpec, func() { g.Description("The backup configuration for this database.") }) @@ -485,6 +549,9 @@ var Database = g.ResultType("Database", func() { g.Attribute("instances", g.CollectionOf(Instance), func() { g.Description("All of the instances in the database.") }) + g.Attribute("service_instances", g.CollectionOf(ServiceInstance), func() { + g.Description("Service instances running alongside this database.") + }) g.Attribute("spec", DatabaseSpec, func() { g.Description("The user-provided specification for the database.") }) @@ -499,6 +566,9 @@ var Database = g.ResultType("Database", func() { g.Attribute("instances", func() { g.View("default") }) + g.Attribute("service_instances", func() { + g.View("default") + }) g.Attribute("spec") g.Example(exampleDatabase) @@ -515,7 +585,7 @@ var Database = g.ResultType("Database", func() { }) }) - g.Required("id", "created_at", "updated_at", "state") + g.Required("id", "created_at", "updated_at", "state", "instances", "service_instances") }) var CreateDatabaseRequest = g.Type("CreateDatabaseRequest", func() { diff --git a/api/apiv1/design/instance.go b/api/apiv1/design/instance.go index 71e056fa..825d699a 100644 --- a/api/apiv1/design/instance.go +++ b/api/apiv1/design/instance.go @@ -186,3 +186,146 @@ var Instance = g.ResultType("Instance", func() { g.Required("id", "host_id", "node_name", "created_at", "updated_at", "state") }) + +var PortMapping = g.Type("PortMapping", func() { + g.Description("Port mapping information for a service instance.") + g.Attribute("name", g.String, func() { + g.Description("The name of the port (e.g., 'http', 'web-client').") + g.Example("http") + g.Example("web-client") + }) + g.Attribute("container_port", g.Int, func() { + g.Description("The port number inside the container.") + g.Minimum(1) + g.Maximum(65535) + g.Example(8080) + }) + g.Attribute("host_port", g.Int, func() { + g.Description("The port number on the host (if port-forwarded).") + g.Minimum(1) + g.Maximum(65535) + g.Example(8080) + }) + + g.Required("name", "container_port") +}) + +var HealthCheckResult = g.Type("HealthCheckResult", func() { + g.Description("Health check result for a service instance.") + g.Attribute("status", g.String, func() { + g.Description("The health status.") + g.Enum("healthy", "unhealthy", "unknown") + g.Example("healthy") + }) + g.Attribute("message", g.String, func() { + g.Description("Optional message about the health status.") + g.Example("Service responding normally") + g.Example("Connection refused") + }) + g.Attribute("checked_at", g.String, func() { + g.Format(g.FormatDateTime) + g.Description("The time this health check was performed.") + g.Example("2025-01-28T10:00:00Z") + }) + + g.Required("status", "checked_at") +}) + +var ServiceInstanceStatus = g.Type("ServiceInstanceStatus", func() { + g.Description("Runtime status information for a service instance.") + g.Attribute("container_id", g.String, func() { + g.Description("The Docker container ID.") + g.Example("a1b2c3d4e5f6") + }) + g.Attribute("image_version", g.String, func() { + g.Description("The container image version currently running.") + g.Example("1.0.0") + }) + g.Attribute("hostname", g.String, func() { + g.Description("The hostname of the service instance.") + g.Example("mcp-server-host-1.internal") + }) + g.Attribute("ipv4_address", g.String, func() { + g.Description("The IPv4 address of the service instance.") + g.Format(g.FormatIPv4) + g.Example("10.0.1.5") + }) + g.Attribute("ports", g.ArrayOf(PortMapping), func() { + g.Description("Port mappings for this service instance.") + }) + g.Attribute("health_check", HealthCheckResult, func() { + g.Description("Most recent health check result.") + }) + g.Attribute("last_health_at", g.String, func() { + g.Format(g.FormatDateTime) + g.Description("The time of the last health check attempt.") + g.Example("2025-01-28T10:00:00Z") + }) + g.Attribute("service_ready", g.Boolean, func() { + g.Description("Whether the service is ready to accept requests.") + g.Example(true) + }) +}) + +var ServiceInstance = g.ResultType("ServiceInstance", func() { + g.Description("A service instance running on a host alongside the database.") + g.Attributes(func() { + g.Attribute("service_instance_id", g.String, func() { + g.Description("Unique identifier for the service instance.") + g.Example("mcp-server-host-1") + }) + g.Attribute("service_id", g.String, func() { + g.Description("The service ID from the DatabaseSpec.") + g.Example("mcp-server") + }) + g.Attribute("database_id", Identifier, func() { + g.Description("The ID of the database this service belongs to.") + g.Example("production") + }) + g.Attribute("host_id", g.String, func() { + g.Description("The ID of the host this service instance is running on.") + g.Example("host-1") + }) + g.Attribute("state", g.String, func() { + g.Description("Current state of the service instance.") + g.Enum( + "creating", + "running", + "failed", + "deleting", + ) + g.Example("running") + }) + g.Attribute("status", ServiceInstanceStatus, func() { + g.Description("Runtime status information for the service instance.") + }) + g.Attribute("created_at", g.String, func() { + g.Format(g.FormatDateTime) + g.Description("The time that the service instance was created.") + g.Example("2025-01-28T10:00:00Z") + }) + g.Attribute("updated_at", g.String, func() { + g.Format(g.FormatDateTime) + g.Description("The time that the service instance was last updated.") + g.Example("2025-01-28T10:05:00Z") + }) + g.Attribute("error", g.String, func() { + g.Description("An error message if the service instance is in an error state.") + g.Example("failed to start container: image not found") + }) + }) + + g.View("default", func() { + g.Attribute("service_instance_id") + g.Attribute("service_id") + g.Attribute("database_id") + g.Attribute("host_id") + g.Attribute("state") + g.Attribute("status") + g.Attribute("created_at") + g.Attribute("updated_at") + g.Attribute("error") + }) + + g.Required("service_instance_id", "service_id", "database_id", "host_id", "state", "created_at", "updated_at") +}) diff --git a/api/apiv1/gen/control_plane/service.go b/api/apiv1/gen/control_plane/service.go index da76d1d6..22524854 100644 --- a/api/apiv1/gen/control_plane/service.go +++ b/api/apiv1/gen/control_plane/service.go @@ -317,6 +317,8 @@ type Database struct { State string // All of the instances in the database. Instances InstanceCollection + // Service instances running alongside this database. + ServiceInstances ServiceinstanceCollection // The user-provided specification for the database. Spec *DatabaseSpec } @@ -388,6 +390,8 @@ type DatabaseSpec struct { Nodes []*DatabaseNodeSpec // The users to create for this database. DatabaseUsers []*DatabaseUserSpec + // Service instances to run alongside the database (e.g., MCP servers). + Services []*ServiceSpec // The backup configuration for this database. BackupConfig *BackupConfigSpec // The restore configuration for this database. @@ -537,6 +541,16 @@ type GetHostTaskPayload struct { TaskID string } +// Health check result for a service instance. +type HealthCheckResult struct { + // The health status. + Status string + // Optional message about the health status. + Message *string + // The time this health check was performed. + CheckedAt string +} + // Host is the result type of the control-plane service get-host method. type Host struct { // Unique identifier for the host. @@ -748,6 +762,16 @@ type PgEdgeVersion struct { SpockVersion string } +// Port mapping information for a service instance. +type PortMapping struct { + // The name of the port (e.g., 'http', 'web-client'). + Name string + // The port number inside the container. + ContainerPort int + // The port number on the host (if port-forwarded). + HostPort *int +} + // RemoveHostPayload is the payload type of the control-plane service // remove-host method. type RemoveHostPayload struct { @@ -870,6 +894,77 @@ type RestoreRepositorySpec struct { CustomOptions map[string]string } +// Runtime status information for a service instance. +type ServiceInstanceStatus struct { + // The Docker container ID. + ContainerID *string + // The container image version currently running. + ImageVersion *string + // The hostname of the service instance. + Hostname *string + // The IPv4 address of the service instance. + Ipv4Address *string + // Port mappings for this service instance. + Ports []*PortMapping + // Most recent health check result. + HealthCheck *HealthCheckResult + // The time of the last health check attempt. + LastHealthAt *string + // Whether the service is ready to accept requests. + ServiceReady *bool +} + +type ServiceSpec struct { + // The unique identifier for this service. + ServiceID Identifier + // The type of service to run. + ServiceType string + // The version of the service in semver format (e.g., '1.0.0') or the literal + // 'latest'. + Version string + // The IDs of the hosts that should run this service. One service instance will + // be created per host. + HostIds []Identifier + // The port to publish the service on the host. If 0, Docker assigns a random + // port. If unspecified, no port is published and the service is not accessible + // from outside the Docker network. + Port *int + // Service-specific configuration. For MCP services, this includes + // llm_provider, llm_model, and provider-specific API keys. + Config map[string]any + // The number of CPUs to allocate for this service. It can include the SI + // suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if + // unspecified. + Cpus *string + // The amount of memory in SI or IEC notation to allocate for this service. + // Defaults to container defaults if unspecified. + Memory *string +} + +// A service instance running on a host alongside the database. +type Serviceinstance struct { + // Unique identifier for the service instance. + ServiceInstanceID string + // The service ID from the DatabaseSpec. + ServiceID string + // The ID of the database this service belongs to. + DatabaseID Identifier + // The ID of the host this service instance is running on. + HostID string + // Current state of the service instance. + State string + // Runtime status information for the service instance. + Status *ServiceInstanceStatus + // The time that the service instance was created. + CreatedAt string + // The time that the service instance was last updated. + UpdatedAt string + // An error message if the service instance is in an error state. + Error *string +} + +type ServiceinstanceCollection []*Serviceinstance + // StartInstancePayload is the payload type of the control-plane service // start-instance method. type StartInstancePayload struct { @@ -1238,6 +1333,16 @@ func newDatabase(vres *controlplaneviews.DatabaseView) *Database { if vres.State != nil { res.State = *vres.State } + if vres.ServiceInstances != nil { + res.ServiceInstances = make([]*Serviceinstance, len(vres.ServiceInstances)) + for i, val := range vres.ServiceInstances { + if val == nil { + res.ServiceInstances[i] = nil + continue + } + res.ServiceInstances[i] = transformControlplaneviewsServiceinstanceViewToServiceinstance(val) + } + } if vres.Spec != nil { res.Spec = transformControlplaneviewsDatabaseSpecViewToDatabaseSpec(vres.Spec) } @@ -1287,6 +1392,18 @@ func newDatabaseView(res *Database) *controlplaneviews.DatabaseView { tenantID := controlplaneviews.IdentifierView(*res.TenantID) vres.TenantID = &tenantID } + if res.ServiceInstances != nil { + vres.ServiceInstances = make([]*controlplaneviews.ServiceinstanceView, len(res.ServiceInstances)) + for i, val := range res.ServiceInstances { + if val == nil { + vres.ServiceInstances[i] = nil + continue + } + vres.ServiceInstances[i] = transformServiceinstanceToControlplaneviewsServiceinstanceView(val) + } + } else { + vres.ServiceInstances = []*controlplaneviews.ServiceinstanceView{} + } if res.Spec != nil { vres.Spec = transformDatabaseSpecToControlplaneviewsDatabaseSpecView(res.Spec) } @@ -1448,6 +1565,167 @@ func newInstanceViewAbbreviated(res *Instance) *controlplaneviews.InstanceView { return vres } +// newServiceinstanceCollection converts projected type +// ServiceinstanceCollection to service type ServiceinstanceCollection. +func newServiceinstanceCollection(vres controlplaneviews.ServiceinstanceCollectionView) ServiceinstanceCollection { + res := make(ServiceinstanceCollection, len(vres)) + for i, n := range vres { + res[i] = newServiceinstance(n) + } + return res +} + +// newServiceinstanceCollectionView projects result type +// ServiceinstanceCollection to projected type ServiceinstanceCollectionView +// using the "default" view. +func newServiceinstanceCollectionView(res ServiceinstanceCollection) controlplaneviews.ServiceinstanceCollectionView { + vres := make(controlplaneviews.ServiceinstanceCollectionView, len(res)) + for i, n := range res { + vres[i] = newServiceinstanceView(n) + } + return vres +} + +// newServiceinstance converts projected type Serviceinstance to service type +// Serviceinstance. +func newServiceinstance(vres *controlplaneviews.ServiceinstanceView) *Serviceinstance { + res := &Serviceinstance{ + Error: vres.Error, + } + if vres.ServiceInstanceID != nil { + res.ServiceInstanceID = *vres.ServiceInstanceID + } + if vres.ServiceID != nil { + res.ServiceID = *vres.ServiceID + } + if vres.DatabaseID != nil { + res.DatabaseID = Identifier(*vres.DatabaseID) + } + if vres.HostID != nil { + res.HostID = *vres.HostID + } + if vres.State != nil { + res.State = *vres.State + } + if vres.CreatedAt != nil { + res.CreatedAt = *vres.CreatedAt + } + if vres.UpdatedAt != nil { + res.UpdatedAt = *vres.UpdatedAt + } + if vres.Status != nil { + res.Status = transformControlplaneviewsServiceInstanceStatusViewToServiceInstanceStatus(vres.Status) + } + return res +} + +// newServiceinstanceView projects result type Serviceinstance to projected +// type ServiceinstanceView using the "default" view. +func newServiceinstanceView(res *Serviceinstance) *controlplaneviews.ServiceinstanceView { + vres := &controlplaneviews.ServiceinstanceView{ + ServiceInstanceID: &res.ServiceInstanceID, + ServiceID: &res.ServiceID, + HostID: &res.HostID, + State: &res.State, + CreatedAt: &res.CreatedAt, + UpdatedAt: &res.UpdatedAt, + Error: res.Error, + } + databaseID := controlplaneviews.IdentifierView(res.DatabaseID) + vres.DatabaseID = &databaseID + if res.Status != nil { + vres.Status = transformServiceInstanceStatusToControlplaneviewsServiceInstanceStatusView(res.Status) + } + return vres +} + +// transformControlplaneviewsServiceinstanceViewToServiceinstance builds a +// value of type *Serviceinstance from a value of type +// *controlplaneviews.ServiceinstanceView. +func transformControlplaneviewsServiceinstanceViewToServiceinstance(v *controlplaneviews.ServiceinstanceView) *Serviceinstance { + if v == nil { + return nil + } + res := &Serviceinstance{ + ServiceInstanceID: *v.ServiceInstanceID, + ServiceID: *v.ServiceID, + DatabaseID: Identifier(*v.DatabaseID), + HostID: *v.HostID, + State: *v.State, + CreatedAt: *v.CreatedAt, + UpdatedAt: *v.UpdatedAt, + Error: v.Error, + } + if v.Status != nil { + res.Status = transformControlplaneviewsServiceInstanceStatusViewToServiceInstanceStatus(v.Status) + } + + return res +} + +// transformControlplaneviewsServiceInstanceStatusViewToServiceInstanceStatus +// builds a value of type *ServiceInstanceStatus from a value of type +// *controlplaneviews.ServiceInstanceStatusView. +func transformControlplaneviewsServiceInstanceStatusViewToServiceInstanceStatus(v *controlplaneviews.ServiceInstanceStatusView) *ServiceInstanceStatus { + if v == nil { + return nil + } + res := &ServiceInstanceStatus{ + ContainerID: v.ContainerID, + ImageVersion: v.ImageVersion, + Hostname: v.Hostname, + Ipv4Address: v.Ipv4Address, + LastHealthAt: v.LastHealthAt, + ServiceReady: v.ServiceReady, + } + if v.Ports != nil { + res.Ports = make([]*PortMapping, len(v.Ports)) + for i, val := range v.Ports { + if val == nil { + res.Ports[i] = nil + continue + } + res.Ports[i] = transformControlplaneviewsPortMappingViewToPortMapping(val) + } + } + if v.HealthCheck != nil { + res.HealthCheck = transformControlplaneviewsHealthCheckResultViewToHealthCheckResult(v.HealthCheck) + } + + return res +} + +// transformControlplaneviewsPortMappingViewToPortMapping builds a value of +// type *PortMapping from a value of type *controlplaneviews.PortMappingView. +func transformControlplaneviewsPortMappingViewToPortMapping(v *controlplaneviews.PortMappingView) *PortMapping { + if v == nil { + return nil + } + res := &PortMapping{ + Name: *v.Name, + ContainerPort: *v.ContainerPort, + HostPort: v.HostPort, + } + + return res +} + +// transformControlplaneviewsHealthCheckResultViewToHealthCheckResult builds a +// value of type *HealthCheckResult from a value of type +// *controlplaneviews.HealthCheckResultView. +func transformControlplaneviewsHealthCheckResultViewToHealthCheckResult(v *controlplaneviews.HealthCheckResultView) *HealthCheckResult { + if v == nil { + return nil + } + res := &HealthCheckResult{ + Status: *v.Status, + Message: v.Message, + CheckedAt: *v.CheckedAt, + } + + return res +} + // transformControlplaneviewsDatabaseSpecViewToDatabaseSpec builds a value of // type *DatabaseSpec from a value of type *controlplaneviews.DatabaseSpecView. func transformControlplaneviewsDatabaseSpecViewToDatabaseSpec(v *controlplaneviews.DatabaseSpecView) *DatabaseSpec { @@ -1484,6 +1762,16 @@ func transformControlplaneviewsDatabaseSpecViewToDatabaseSpec(v *controlplanevie res.DatabaseUsers[i] = transformControlplaneviewsDatabaseUserSpecViewToDatabaseUserSpec(val) } } + if v.Services != nil { + res.Services = make([]*ServiceSpec, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = transformControlplaneviewsServiceSpecViewToServiceSpec(val) + } + } if v.BackupConfig != nil { res.BackupConfig = transformControlplaneviewsBackupConfigSpecViewToBackupConfigSpec(v.BackupConfig) } @@ -1822,6 +2110,125 @@ func transformControlplaneviewsDatabaseUserSpecViewToDatabaseUserSpec(v *control return res } +// transformControlplaneviewsServiceSpecViewToServiceSpec builds a value of +// type *ServiceSpec from a value of type *controlplaneviews.ServiceSpecView. +func transformControlplaneviewsServiceSpecViewToServiceSpec(v *controlplaneviews.ServiceSpecView) *ServiceSpec { + if v == nil { + return nil + } + res := &ServiceSpec{ + ServiceID: Identifier(*v.ServiceID), + ServiceType: *v.ServiceType, + Version: *v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + if v.HostIds != nil { + res.HostIds = make([]Identifier, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = Identifier(val) + } + } else { + res.HostIds = []Identifier{} + } + if v.Config != nil { + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + } + + return res +} + +// transformServiceinstanceToControlplaneviewsServiceinstanceView builds a +// value of type *controlplaneviews.ServiceinstanceView from a value of type +// *Serviceinstance. +func transformServiceinstanceToControlplaneviewsServiceinstanceView(v *Serviceinstance) *controlplaneviews.ServiceinstanceView { + res := &controlplaneviews.ServiceinstanceView{ + ServiceInstanceID: &v.ServiceInstanceID, + ServiceID: &v.ServiceID, + HostID: &v.HostID, + State: &v.State, + CreatedAt: &v.CreatedAt, + UpdatedAt: &v.UpdatedAt, + Error: v.Error, + } + databaseID := controlplaneviews.IdentifierView(v.DatabaseID) + res.DatabaseID = &databaseID + if v.Status != nil { + res.Status = transformServiceInstanceStatusToControlplaneviewsServiceInstanceStatusView(v.Status) + } + + return res +} + +// transformServiceInstanceStatusToControlplaneviewsServiceInstanceStatusView +// builds a value of type *controlplaneviews.ServiceInstanceStatusView from a +// value of type *ServiceInstanceStatus. +func transformServiceInstanceStatusToControlplaneviewsServiceInstanceStatusView(v *ServiceInstanceStatus) *controlplaneviews.ServiceInstanceStatusView { + if v == nil { + return nil + } + res := &controlplaneviews.ServiceInstanceStatusView{ + ContainerID: v.ContainerID, + ImageVersion: v.ImageVersion, + Hostname: v.Hostname, + Ipv4Address: v.Ipv4Address, + LastHealthAt: v.LastHealthAt, + ServiceReady: v.ServiceReady, + } + if v.Ports != nil { + res.Ports = make([]*controlplaneviews.PortMappingView, len(v.Ports)) + for i, val := range v.Ports { + if val == nil { + res.Ports[i] = nil + continue + } + res.Ports[i] = transformPortMappingToControlplaneviewsPortMappingView(val) + } + } + if v.HealthCheck != nil { + res.HealthCheck = transformHealthCheckResultToControlplaneviewsHealthCheckResultView(v.HealthCheck) + } + + return res +} + +// transformPortMappingToControlplaneviewsPortMappingView builds a value of +// type *controlplaneviews.PortMappingView from a value of type *PortMapping. +func transformPortMappingToControlplaneviewsPortMappingView(v *PortMapping) *controlplaneviews.PortMappingView { + if v == nil { + return nil + } + res := &controlplaneviews.PortMappingView{ + Name: &v.Name, + ContainerPort: &v.ContainerPort, + HostPort: v.HostPort, + } + + return res +} + +// transformHealthCheckResultToControlplaneviewsHealthCheckResultView builds a +// value of type *controlplaneviews.HealthCheckResultView from a value of type +// *HealthCheckResult. +func transformHealthCheckResultToControlplaneviewsHealthCheckResultView(v *HealthCheckResult) *controlplaneviews.HealthCheckResultView { + if v == nil { + return nil + } + res := &controlplaneviews.HealthCheckResultView{ + Status: &v.Status, + Message: v.Message, + CheckedAt: &v.CheckedAt, + } + + return res +} + // transformDatabaseSpecToControlplaneviewsDatabaseSpecView builds a value of // type *controlplaneviews.DatabaseSpecView from a value of type *DatabaseSpec. func transformDatabaseSpecToControlplaneviewsDatabaseSpecView(v *DatabaseSpec) *controlplaneviews.DatabaseSpecView { @@ -1858,6 +2265,16 @@ func transformDatabaseSpecToControlplaneviewsDatabaseSpecView(v *DatabaseSpec) * res.DatabaseUsers[i] = transformDatabaseUserSpecToControlplaneviewsDatabaseUserSpecView(val) } } + if v.Services != nil { + res.Services = make([]*controlplaneviews.ServiceSpecView, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = transformServiceSpecToControlplaneviewsServiceSpecView(val) + } + } if v.BackupConfig != nil { res.BackupConfig = transformBackupConfigSpecToControlplaneviewsBackupConfigSpecView(v.BackupConfig) } @@ -2197,6 +2614,41 @@ func transformDatabaseUserSpecToControlplaneviewsDatabaseUserSpecView(v *Databas return res } +// transformServiceSpecToControlplaneviewsServiceSpecView builds a value of +// type *controlplaneviews.ServiceSpecView from a value of type *ServiceSpec. +func transformServiceSpecToControlplaneviewsServiceSpecView(v *ServiceSpec) *controlplaneviews.ServiceSpecView { + if v == nil { + return nil + } + res := &controlplaneviews.ServiceSpecView{ + ServiceType: &v.ServiceType, + Version: &v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + serviceID := controlplaneviews.IdentifierView(v.ServiceID) + res.ServiceID = &serviceID + if v.HostIds != nil { + res.HostIds = make([]controlplaneviews.IdentifierView, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = controlplaneviews.IdentifierView(val) + } + } else { + res.HostIds = []controlplaneviews.IdentifierView{} + } + if v.Config != nil { + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + } + + return res +} + // transformControlplaneviewsInstanceConnectionInfoViewToInstanceConnectionInfo // builds a value of type *InstanceConnectionInfo from a value of type // *controlplaneviews.InstanceConnectionInfoView. diff --git a/api/apiv1/gen/control_plane/views/view.go b/api/apiv1/gen/control_plane/views/view.go index 1745f0fe..1af649a9 100644 --- a/api/apiv1/gen/control_plane/views/view.go +++ b/api/apiv1/gen/control_plane/views/view.go @@ -53,6 +53,8 @@ type DatabaseView struct { State *string // All of the instances in the database. Instances InstanceCollectionView + // Service instances running alongside this database. + ServiceInstances ServiceinstanceCollectionView // The user-provided specification for the database. Spec *DatabaseSpecView } @@ -132,6 +134,73 @@ type InstanceSubscriptionView struct { Status *string } +// ServiceinstanceCollectionView is a type that runs validations on a projected +// type. +type ServiceinstanceCollectionView []*ServiceinstanceView + +// ServiceinstanceView is a type that runs validations on a projected type. +type ServiceinstanceView struct { + // Unique identifier for the service instance. + ServiceInstanceID *string + // The service ID from the DatabaseSpec. + ServiceID *string + // The ID of the database this service belongs to. + DatabaseID *IdentifierView + // The ID of the host this service instance is running on. + HostID *string + // Current state of the service instance. + State *string + // Runtime status information for the service instance. + Status *ServiceInstanceStatusView + // The time that the service instance was created. + CreatedAt *string + // The time that the service instance was last updated. + UpdatedAt *string + // An error message if the service instance is in an error state. + Error *string +} + +// ServiceInstanceStatusView is a type that runs validations on a projected +// type. +type ServiceInstanceStatusView struct { + // The Docker container ID. + ContainerID *string + // The container image version currently running. + ImageVersion *string + // The hostname of the service instance. + Hostname *string + // The IPv4 address of the service instance. + Ipv4Address *string + // Port mappings for this service instance. + Ports []*PortMappingView + // Most recent health check result. + HealthCheck *HealthCheckResultView + // The time of the last health check attempt. + LastHealthAt *string + // Whether the service is ready to accept requests. + ServiceReady *bool +} + +// PortMappingView is a type that runs validations on a projected type. +type PortMappingView struct { + // The name of the port (e.g., 'http', 'web-client'). + Name *string + // The port number inside the container. + ContainerPort *int + // The port number on the host (if port-forwarded). + HostPort *int +} + +// HealthCheckResultView is a type that runs validations on a projected type. +type HealthCheckResultView struct { + // The health status. + Status *string + // Optional message about the health status. + Message *string + // The time this health check was performed. + CheckedAt *string +} + // DatabaseSpecView is a type that runs validations on a projected type. type DatabaseSpecView struct { // The name of the Postgres database. @@ -157,6 +226,8 @@ type DatabaseSpecView struct { Nodes []*DatabaseNodeSpecView // The users to create for this database. DatabaseUsers []*DatabaseUserSpecView + // Service instances to run alongside the database (e.g., MCP servers). + Services []*ServiceSpecView // The backup configuration for this database. BackupConfig *BackupConfigSpecView // The restore configuration for this database. @@ -396,6 +467,34 @@ type DatabaseUserSpecView struct { Roles []string } +// ServiceSpecView is a type that runs validations on a projected type. +type ServiceSpecView struct { + // The unique identifier for this service. + ServiceID *IdentifierView + // The type of service to run. + ServiceType *string + // The version of the service in semver format (e.g., '1.0.0') or the literal + // 'latest'. + Version *string + // The IDs of the hosts that should run this service. One service instance will + // be created per host. + HostIds []IdentifierView + // The port to publish the service on the host. If 0, Docker assigns a random + // port. If unspecified, no port is published and the service is not accessible + // from outside the Docker network. + Port *int + // Service-specific configuration. For MCP services, this includes + // llm_provider, llm_model, and provider-specific API keys. + Config map[string]any + // The number of CPUs to allocate for this service. It can include the SI + // suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if + // unspecified. + Cpus *string + // The amount of memory in SI or IEC notation to allocate for this service. + // Defaults to container defaults if unspecified. + Memory *string +} + // CreateDatabaseResponseView is a type that runs validations on a projected // type. type CreateDatabaseResponseView struct { @@ -472,6 +571,7 @@ var ( "updated_at", "state", "instances", + "service_instances", "spec", }, "abbreviated": { @@ -493,6 +593,7 @@ var ( "updated_at", "state", "instances", + "service_instances", "spec", }, "abbreviated": { @@ -549,6 +650,36 @@ var ( "state", }, } + // ServiceinstanceCollectionMap is a map indexing the attribute names of + // ServiceinstanceCollection by view name. + ServiceinstanceCollectionMap = map[string][]string{ + "default": { + "service_instance_id", + "service_id", + "database_id", + "host_id", + "state", + "status", + "created_at", + "updated_at", + "error", + }, + } + // ServiceinstanceMap is a map indexing the attribute names of Serviceinstance + // by view name. + ServiceinstanceMap = map[string][]string{ + "default": { + "service_instance_id", + "service_id", + "database_id", + "host_id", + "state", + "status", + "created_at", + "updated_at", + "error", + }, + } ) // ValidateListDatabasesResponse runs the validations defined on the viewed @@ -667,6 +798,11 @@ func ValidateDatabaseView(result *DatabaseView) (err error) { err = goa.MergeErrors(err, err2) } } + if result.ServiceInstances != nil { + if err2 := ValidateServiceinstanceCollectionView(result.ServiceInstances); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } return } @@ -875,6 +1011,145 @@ func ValidateInstanceSubscriptionView(result *InstanceSubscriptionView) (err err return } +// ValidateServiceinstanceCollectionView runs the validations defined on +// ServiceinstanceCollectionView using the "default" view. +func ValidateServiceinstanceCollectionView(result ServiceinstanceCollectionView) (err error) { + for _, item := range result { + if err2 := ValidateServiceinstanceView(item); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateServiceinstanceView runs the validations defined on +// ServiceinstanceView using the "default" view. +func ValidateServiceinstanceView(result *ServiceinstanceView) (err error) { + if result.ServiceInstanceID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("service_instance_id", "result")) + } + if result.ServiceID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("service_id", "result")) + } + if result.DatabaseID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("database_id", "result")) + } + if result.HostID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("host_id", "result")) + } + if result.State == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("state", "result")) + } + if result.CreatedAt == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("created_at", "result")) + } + if result.UpdatedAt == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("updated_at", "result")) + } + if result.DatabaseID != nil { + if utf8.RuneCountInString(string(*result.DatabaseID)) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("result.database_id", string(*result.DatabaseID), utf8.RuneCountInString(string(*result.DatabaseID)), 1, true)) + } + } + if result.DatabaseID != nil { + if utf8.RuneCountInString(string(*result.DatabaseID)) > 63 { + err = goa.MergeErrors(err, goa.InvalidLengthError("result.database_id", string(*result.DatabaseID), utf8.RuneCountInString(string(*result.DatabaseID)), 63, false)) + } + } + if result.State != nil { + if !(*result.State == "creating" || *result.State == "running" || *result.State == "failed" || *result.State == "deleting") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("result.state", *result.State, []any{"creating", "running", "failed", "deleting"})) + } + } + if result.Status != nil { + if err2 := ValidateServiceInstanceStatusView(result.Status); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + if result.CreatedAt != nil { + err = goa.MergeErrors(err, goa.ValidateFormat("result.created_at", *result.CreatedAt, goa.FormatDateTime)) + } + if result.UpdatedAt != nil { + err = goa.MergeErrors(err, goa.ValidateFormat("result.updated_at", *result.UpdatedAt, goa.FormatDateTime)) + } + return +} + +// ValidateServiceInstanceStatusView runs the validations defined on +// ServiceInstanceStatusView. +func ValidateServiceInstanceStatusView(result *ServiceInstanceStatusView) (err error) { + if result.Ipv4Address != nil { + err = goa.MergeErrors(err, goa.ValidateFormat("result.ipv4_address", *result.Ipv4Address, goa.FormatIPv4)) + } + for _, e := range result.Ports { + if e != nil { + if err2 := ValidatePortMappingView(e); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + if result.HealthCheck != nil { + if err2 := ValidateHealthCheckResultView(result.HealthCheck); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + if result.LastHealthAt != nil { + err = goa.MergeErrors(err, goa.ValidateFormat("result.last_health_at", *result.LastHealthAt, goa.FormatDateTime)) + } + return +} + +// ValidatePortMappingView runs the validations defined on PortMappingView. +func ValidatePortMappingView(result *PortMappingView) (err error) { + if result.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "result")) + } + if result.ContainerPort == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("container_port", "result")) + } + if result.ContainerPort != nil { + if *result.ContainerPort < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("result.container_port", *result.ContainerPort, 1, true)) + } + } + if result.ContainerPort != nil { + if *result.ContainerPort > 65535 { + err = goa.MergeErrors(err, goa.InvalidRangeError("result.container_port", *result.ContainerPort, 65535, false)) + } + } + if result.HostPort != nil { + if *result.HostPort < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("result.host_port", *result.HostPort, 1, true)) + } + } + if result.HostPort != nil { + if *result.HostPort > 65535 { + err = goa.MergeErrors(err, goa.InvalidRangeError("result.host_port", *result.HostPort, 65535, false)) + } + } + return +} + +// ValidateHealthCheckResultView runs the validations defined on +// HealthCheckResultView. +func ValidateHealthCheckResultView(result *HealthCheckResultView) (err error) { + if result.Status == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("status", "result")) + } + if result.CheckedAt == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("checked_at", "result")) + } + if result.Status != nil { + if !(*result.Status == "healthy" || *result.Status == "unhealthy" || *result.Status == "unknown") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("result.status", *result.Status, []any{"healthy", "unhealthy", "unknown"})) + } + } + if result.CheckedAt != nil { + err = goa.MergeErrors(err, goa.ValidateFormat("result.checked_at", *result.CheckedAt, goa.FormatDateTime)) + } + return +} + // ValidateDatabaseSpecView runs the validations defined on DatabaseSpecView. func ValidateDatabaseSpecView(result *DatabaseSpecView) (err error) { if result.DatabaseName == nil { @@ -940,6 +1215,13 @@ func ValidateDatabaseSpecView(result *DatabaseSpecView) (err error) { } } } + for _, e := range result.Services { + if e != nil { + if err2 := ValidateServiceSpecView(e); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } if result.BackupConfig != nil { if err2 := ValidateBackupConfigSpecView(result.BackupConfig); err2 != nil { err = goa.MergeErrors(err, err2) @@ -1499,6 +1781,73 @@ func ValidateDatabaseUserSpecView(result *DatabaseUserSpecView) (err error) { return } +// ValidateServiceSpecView runs the validations defined on ServiceSpecView. +func ValidateServiceSpecView(result *ServiceSpecView) (err error) { + if result.ServiceID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("service_id", "result")) + } + if result.ServiceType == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("service_type", "result")) + } + if result.Version == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("version", "result")) + } + if result.HostIds == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("host_ids", "result")) + } + if result.Config == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("config", "result")) + } + if result.ServiceID != nil { + if utf8.RuneCountInString(string(*result.ServiceID)) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("result.service_id", string(*result.ServiceID), utf8.RuneCountInString(string(*result.ServiceID)), 1, true)) + } + } + if result.ServiceID != nil { + if utf8.RuneCountInString(string(*result.ServiceID)) > 63 { + err = goa.MergeErrors(err, goa.InvalidLengthError("result.service_id", string(*result.ServiceID), utf8.RuneCountInString(string(*result.ServiceID)), 63, false)) + } + } + if result.ServiceType != nil { + if !(*result.ServiceType == "mcp") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("result.service_type", *result.ServiceType, []any{"mcp"})) + } + } + if result.Version != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("result.version", *result.Version, "^(\\d+\\.\\d+\\.\\d+|latest)$")) + } + if len(result.HostIds) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("result.host_ids", result.HostIds, len(result.HostIds), 1, true)) + } + for _, e := range result.HostIds { + if utf8.RuneCountInString(string(e)) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("result.host_ids[*]", string(e), utf8.RuneCountInString(string(e)), 1, true)) + } + if utf8.RuneCountInString(string(e)) > 63 { + err = goa.MergeErrors(err, goa.InvalidLengthError("result.host_ids[*]", string(e), utf8.RuneCountInString(string(e)), 63, false)) + } + } + if result.Port != nil { + if *result.Port < 0 { + err = goa.MergeErrors(err, goa.InvalidRangeError("result.port", *result.Port, 0, true)) + } + } + if result.Port != nil { + if *result.Port > 65535 { + err = goa.MergeErrors(err, goa.InvalidRangeError("result.port", *result.Port, 65535, false)) + } + } + if result.Cpus != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("result.cpus", *result.Cpus, "^[0-9]+(\\.[0-9]{1,3}|m)?$")) + } + if result.Memory != nil { + if utf8.RuneCountInString(*result.Memory) > 16 { + err = goa.MergeErrors(err, goa.InvalidLengthError("result.memory", *result.Memory, utf8.RuneCountInString(*result.Memory), 16, false)) + } + } + return +} + // ValidateCreateDatabaseResponseView runs the validations defined on // CreateDatabaseResponseView. func ValidateCreateDatabaseResponseView(result *CreateDatabaseResponseView) (err error) { diff --git a/api/apiv1/gen/http/cli/control_plane/cli.go b/api/apiv1/gen/http/cli/control_plane/cli.go index 80cbc3b0..e5db9cda 100644 --- a/api/apiv1/gen/http/cli/control_plane/cli.go +++ b/api/apiv1/gen/http/cli/control_plane/cli.go @@ -772,7 +772,7 @@ func controlPlaneFailoverDatabaseNodeUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "control-plane failover-database-node --body '{\n \"candidate_instance_id\": \"68f50878-44d2-4524-a823-e31bd478706d-n1-689qacsi\",\n \"skip_validation\": true\n }' --database-id \"76f9b8c0-4958-11f0-a489-3bb29577c696\" --node-name \"n1\"") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "control-plane failover-database-node --body '{\n \"candidate_instance_id\": \"68f50878-44d2-4524-a823-e31bd478706d-n1-689qacsi\",\n \"skip_validation\": false\n }' --database-id \"76f9b8c0-4958-11f0-a489-3bb29577c696\" --node-name \"n1\"") } func controlPlaneListDatabaseTasksUsage() { diff --git a/api/apiv1/gen/http/control_plane/client/cli.go b/api/apiv1/gen/http/control_plane/client/cli.go index 41eceae3..5a21652f 100644 --- a/api/apiv1/gen/http/control_plane/client/cli.go +++ b/api/apiv1/gen/http/control_plane/client/cli.go @@ -480,7 +480,7 @@ func BuildFailoverDatabaseNodePayload(controlPlaneFailoverDatabaseNodeBody strin { err = json.Unmarshal([]byte(controlPlaneFailoverDatabaseNodeBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"candidate_instance_id\": \"68f50878-44d2-4524-a823-e31bd478706d-n1-689qacsi\",\n \"skip_validation\": true\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"candidate_instance_id\": \"68f50878-44d2-4524-a823-e31bd478706d-n1-689qacsi\",\n \"skip_validation\": false\n }'") } } var databaseID string diff --git a/api/apiv1/gen/http/control_plane/client/encode_decode.go b/api/apiv1/gen/http/control_plane/client/encode_decode.go index 219e8d27..0197ff67 100644 --- a/api/apiv1/gen/http/control_plane/client/encode_decode.go +++ b/api/apiv1/gen/http/control_plane/client/encode_decode.go @@ -4192,15 +4192,21 @@ func unmarshalDatabaseResponseBodyToControlplaneviewsDatabaseView(v *DatabaseRes tenantID := controlplaneviews.IdentifierView(*v.TenantID) res.TenantID = &tenantID } - if v.Instances != nil { - res.Instances = make([]*controlplaneviews.InstanceView, len(v.Instances)) - for i, val := range v.Instances { - if val == nil { - res.Instances[i] = nil - continue - } - res.Instances[i] = unmarshalInstanceResponseBodyToControlplaneviewsInstanceView(val) + res.Instances = make([]*controlplaneviews.InstanceView, len(v.Instances)) + for i, val := range v.Instances { + if val == nil { + res.Instances[i] = nil + continue } + res.Instances[i] = unmarshalInstanceResponseBodyToControlplaneviewsInstanceView(val) + } + res.ServiceInstances = make([]*controlplaneviews.ServiceinstanceView, len(v.ServiceInstances)) + for i, val := range v.ServiceInstances { + if val == nil { + res.ServiceInstances[i] = nil + continue + } + res.ServiceInstances[i] = unmarshalServiceinstanceResponseBodyToControlplaneviewsServiceinstanceView(val) } if v.Spec != nil { res.Spec = unmarshalDatabaseSpecResponseBodyToControlplaneviewsDatabaseSpecView(v.Spec) @@ -4213,9 +4219,6 @@ func unmarshalDatabaseResponseBodyToControlplaneviewsDatabaseView(v *DatabaseRes // of type *controlplaneviews.InstanceView from a value of type // *InstanceResponseBody. func unmarshalInstanceResponseBodyToControlplaneviewsInstanceView(v *InstanceResponseBody) *controlplaneviews.InstanceView { - if v == nil { - return nil - } res := &controlplaneviews.InstanceView{ ID: v.ID, HostID: v.HostID, @@ -4314,6 +4317,92 @@ func unmarshalInstanceSubscriptionResponseBodyToControlplaneviewsInstanceSubscri return res } +// unmarshalServiceinstanceResponseBodyToControlplaneviewsServiceinstanceView +// builds a value of type *controlplaneviews.ServiceinstanceView from a value +// of type *ServiceinstanceResponseBody. +func unmarshalServiceinstanceResponseBodyToControlplaneviewsServiceinstanceView(v *ServiceinstanceResponseBody) *controlplaneviews.ServiceinstanceView { + res := &controlplaneviews.ServiceinstanceView{ + ServiceInstanceID: v.ServiceInstanceID, + ServiceID: v.ServiceID, + HostID: v.HostID, + State: v.State, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + Error: v.Error, + } + databaseID := controlplaneviews.IdentifierView(*v.DatabaseID) + res.DatabaseID = &databaseID + if v.Status != nil { + res.Status = unmarshalServiceInstanceStatusResponseBodyToControlplaneviewsServiceInstanceStatusView(v.Status) + } + + return res +} + +// unmarshalServiceInstanceStatusResponseBodyToControlplaneviewsServiceInstanceStatusView +// builds a value of type *controlplaneviews.ServiceInstanceStatusView from a +// value of type *ServiceInstanceStatusResponseBody. +func unmarshalServiceInstanceStatusResponseBodyToControlplaneviewsServiceInstanceStatusView(v *ServiceInstanceStatusResponseBody) *controlplaneviews.ServiceInstanceStatusView { + if v == nil { + return nil + } + res := &controlplaneviews.ServiceInstanceStatusView{ + ContainerID: v.ContainerID, + ImageVersion: v.ImageVersion, + Hostname: v.Hostname, + Ipv4Address: v.Ipv4Address, + LastHealthAt: v.LastHealthAt, + ServiceReady: v.ServiceReady, + } + if v.Ports != nil { + res.Ports = make([]*controlplaneviews.PortMappingView, len(v.Ports)) + for i, val := range v.Ports { + if val == nil { + res.Ports[i] = nil + continue + } + res.Ports[i] = unmarshalPortMappingResponseBodyToControlplaneviewsPortMappingView(val) + } + } + if v.HealthCheck != nil { + res.HealthCheck = unmarshalHealthCheckResultResponseBodyToControlplaneviewsHealthCheckResultView(v.HealthCheck) + } + + return res +} + +// unmarshalPortMappingResponseBodyToControlplaneviewsPortMappingView builds a +// value of type *controlplaneviews.PortMappingView from a value of type +// *PortMappingResponseBody. +func unmarshalPortMappingResponseBodyToControlplaneviewsPortMappingView(v *PortMappingResponseBody) *controlplaneviews.PortMappingView { + if v == nil { + return nil + } + res := &controlplaneviews.PortMappingView{ + Name: v.Name, + ContainerPort: v.ContainerPort, + HostPort: v.HostPort, + } + + return res +} + +// unmarshalHealthCheckResultResponseBodyToControlplaneviewsHealthCheckResultView +// builds a value of type *controlplaneviews.HealthCheckResultView from a value +// of type *HealthCheckResultResponseBody. +func unmarshalHealthCheckResultResponseBodyToControlplaneviewsHealthCheckResultView(v *HealthCheckResultResponseBody) *controlplaneviews.HealthCheckResultView { + if v == nil { + return nil + } + res := &controlplaneviews.HealthCheckResultView{ + Status: v.Status, + Message: v.Message, + CheckedAt: v.CheckedAt, + } + + return res +} + // unmarshalDatabaseSpecResponseBodyToControlplaneviewsDatabaseSpecView builds // a value of type *controlplaneviews.DatabaseSpecView from a value of type // *DatabaseSpecResponseBody. @@ -4347,6 +4436,16 @@ func unmarshalDatabaseSpecResponseBodyToControlplaneviewsDatabaseSpecView(v *Dat res.DatabaseUsers[i] = unmarshalDatabaseUserSpecResponseBodyToControlplaneviewsDatabaseUserSpecView(val) } } + if v.Services != nil { + res.Services = make([]*controlplaneviews.ServiceSpecView, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = unmarshalServiceSpecResponseBodyToControlplaneviewsServiceSpecView(val) + } + } if v.BackupConfig != nil { res.BackupConfig = unmarshalBackupConfigSpecResponseBodyToControlplaneviewsBackupConfigSpecView(v.BackupConfig) } @@ -4677,6 +4776,36 @@ func unmarshalDatabaseUserSpecResponseBodyToControlplaneviewsDatabaseUserSpecVie return res } +// unmarshalServiceSpecResponseBodyToControlplaneviewsServiceSpecView builds a +// value of type *controlplaneviews.ServiceSpecView from a value of type +// *ServiceSpecResponseBody. +func unmarshalServiceSpecResponseBodyToControlplaneviewsServiceSpecView(v *ServiceSpecResponseBody) *controlplaneviews.ServiceSpecView { + if v == nil { + return nil + } + res := &controlplaneviews.ServiceSpecView{ + ServiceType: v.ServiceType, + Version: v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + serviceID := controlplaneviews.IdentifierView(*v.ServiceID) + res.ServiceID = &serviceID + res.HostIds = make([]controlplaneviews.IdentifierView, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = controlplaneviews.IdentifierView(val) + } + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + + return res +} + // marshalControlplaneDatabaseSpecToDatabaseSpecRequestBody builds a value of // type *DatabaseSpecRequestBody from a value of type // *controlplane.DatabaseSpec. @@ -4711,6 +4840,16 @@ func marshalControlplaneDatabaseSpecToDatabaseSpecRequestBody(v *controlplane.Da res.DatabaseUsers[i] = marshalControlplaneDatabaseUserSpecToDatabaseUserSpecRequestBody(val) } } + if v.Services != nil { + res.Services = make([]*ServiceSpecRequestBody, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = marshalControlplaneServiceSpecToServiceSpecRequestBody(val) + } + } if v.BackupConfig != nil { res.BackupConfig = marshalControlplaneBackupConfigSpecToBackupConfigSpecRequestBody(v.BackupConfig) } @@ -5049,6 +5188,40 @@ func marshalControlplaneDatabaseUserSpecToDatabaseUserSpecRequestBody(v *control return res } +// marshalControlplaneServiceSpecToServiceSpecRequestBody builds a value of +// type *ServiceSpecRequestBody from a value of type *controlplane.ServiceSpec. +func marshalControlplaneServiceSpecToServiceSpecRequestBody(v *controlplane.ServiceSpec) *ServiceSpecRequestBody { + if v == nil { + return nil + } + res := &ServiceSpecRequestBody{ + ServiceID: string(v.ServiceID), + ServiceType: v.ServiceType, + Version: v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + if v.HostIds != nil { + res.HostIds = make([]string, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = string(val) + } + } else { + res.HostIds = []string{} + } + if v.Config != nil { + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + } + + return res +} + // marshalDatabaseSpecRequestBodyToControlplaneDatabaseSpec builds a value of // type *controlplane.DatabaseSpec from a value of type // *DatabaseSpecRequestBody. @@ -5083,6 +5256,16 @@ func marshalDatabaseSpecRequestBodyToControlplaneDatabaseSpec(v *DatabaseSpecReq res.DatabaseUsers[i] = marshalDatabaseUserSpecRequestBodyToControlplaneDatabaseUserSpec(val) } } + if v.Services != nil { + res.Services = make([]*controlplane.ServiceSpec, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = marshalServiceSpecRequestBodyToControlplaneServiceSpec(val) + } + } if v.BackupConfig != nil { res.BackupConfig = marshalBackupConfigSpecRequestBodyToControlplaneBackupConfigSpec(v.BackupConfig) } @@ -5421,6 +5604,40 @@ func marshalDatabaseUserSpecRequestBodyToControlplaneDatabaseUserSpec(v *Databas return res } +// marshalServiceSpecRequestBodyToControlplaneServiceSpec builds a value of +// type *controlplane.ServiceSpec from a value of type *ServiceSpecRequestBody. +func marshalServiceSpecRequestBodyToControlplaneServiceSpec(v *ServiceSpecRequestBody) *controlplane.ServiceSpec { + if v == nil { + return nil + } + res := &controlplane.ServiceSpec{ + ServiceID: controlplane.Identifier(v.ServiceID), + ServiceType: v.ServiceType, + Version: v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + if v.HostIds != nil { + res.HostIds = make([]controlplane.Identifier, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = controlplane.Identifier(val) + } + } else { + res.HostIds = []controlplane.Identifier{} + } + if v.Config != nil { + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + } + + return res +} + // unmarshalDatabaseResponseBodyToControlplaneDatabase builds a value of type // *controlplane.Database from a value of type *DatabaseResponseBody. func unmarshalDatabaseResponseBodyToControlplaneDatabase(v *DatabaseResponseBody) *controlplane.Database { @@ -5434,15 +5651,21 @@ func unmarshalDatabaseResponseBodyToControlplaneDatabase(v *DatabaseResponseBody tenantID := controlplane.Identifier(*v.TenantID) res.TenantID = &tenantID } - if v.Instances != nil { - res.Instances = make([]*controlplane.Instance, len(v.Instances)) - for i, val := range v.Instances { - if val == nil { - res.Instances[i] = nil - continue - } - res.Instances[i] = unmarshalInstanceResponseBodyToControlplaneInstance(val) + res.Instances = make([]*controlplane.Instance, len(v.Instances)) + for i, val := range v.Instances { + if val == nil { + res.Instances[i] = nil + continue } + res.Instances[i] = unmarshalInstanceResponseBodyToControlplaneInstance(val) + } + res.ServiceInstances = make([]*controlplane.Serviceinstance, len(v.ServiceInstances)) + for i, val := range v.ServiceInstances { + if val == nil { + res.ServiceInstances[i] = nil + continue + } + res.ServiceInstances[i] = unmarshalServiceinstanceResponseBodyToControlplaneServiceinstance(val) } if v.Spec != nil { res.Spec = unmarshalDatabaseSpecResponseBodyToControlplaneDatabaseSpec(v.Spec) @@ -5454,9 +5677,6 @@ func unmarshalDatabaseResponseBodyToControlplaneDatabase(v *DatabaseResponseBody // unmarshalInstanceResponseBodyToControlplaneInstance builds a value of type // *controlplane.Instance from a value of type *InstanceResponseBody. func unmarshalInstanceResponseBodyToControlplaneInstance(v *InstanceResponseBody) *controlplane.Instance { - if v == nil { - return nil - } res := &controlplane.Instance{ ID: *v.ID, HostID: *v.HostID, @@ -5555,6 +5775,90 @@ func unmarshalInstanceSubscriptionResponseBodyToControlplaneInstanceSubscription return res } +// unmarshalServiceinstanceResponseBodyToControlplaneServiceinstance builds a +// value of type *controlplane.Serviceinstance from a value of type +// *ServiceinstanceResponseBody. +func unmarshalServiceinstanceResponseBodyToControlplaneServiceinstance(v *ServiceinstanceResponseBody) *controlplane.Serviceinstance { + res := &controlplane.Serviceinstance{ + ServiceInstanceID: *v.ServiceInstanceID, + ServiceID: *v.ServiceID, + DatabaseID: controlplane.Identifier(*v.DatabaseID), + HostID: *v.HostID, + State: *v.State, + CreatedAt: *v.CreatedAt, + UpdatedAt: *v.UpdatedAt, + Error: v.Error, + } + if v.Status != nil { + res.Status = unmarshalServiceInstanceStatusResponseBodyToControlplaneServiceInstanceStatus(v.Status) + } + + return res +} + +// unmarshalServiceInstanceStatusResponseBodyToControlplaneServiceInstanceStatus +// builds a value of type *controlplane.ServiceInstanceStatus from a value of +// type *ServiceInstanceStatusResponseBody. +func unmarshalServiceInstanceStatusResponseBodyToControlplaneServiceInstanceStatus(v *ServiceInstanceStatusResponseBody) *controlplane.ServiceInstanceStatus { + if v == nil { + return nil + } + res := &controlplane.ServiceInstanceStatus{ + ContainerID: v.ContainerID, + ImageVersion: v.ImageVersion, + Hostname: v.Hostname, + Ipv4Address: v.Ipv4Address, + LastHealthAt: v.LastHealthAt, + ServiceReady: v.ServiceReady, + } + if v.Ports != nil { + res.Ports = make([]*controlplane.PortMapping, len(v.Ports)) + for i, val := range v.Ports { + if val == nil { + res.Ports[i] = nil + continue + } + res.Ports[i] = unmarshalPortMappingResponseBodyToControlplanePortMapping(val) + } + } + if v.HealthCheck != nil { + res.HealthCheck = unmarshalHealthCheckResultResponseBodyToControlplaneHealthCheckResult(v.HealthCheck) + } + + return res +} + +// unmarshalPortMappingResponseBodyToControlplanePortMapping builds a value of +// type *controlplane.PortMapping from a value of type *PortMappingResponseBody. +func unmarshalPortMappingResponseBodyToControlplanePortMapping(v *PortMappingResponseBody) *controlplane.PortMapping { + if v == nil { + return nil + } + res := &controlplane.PortMapping{ + Name: *v.Name, + ContainerPort: *v.ContainerPort, + HostPort: v.HostPort, + } + + return res +} + +// unmarshalHealthCheckResultResponseBodyToControlplaneHealthCheckResult builds +// a value of type *controlplane.HealthCheckResult from a value of type +// *HealthCheckResultResponseBody. +func unmarshalHealthCheckResultResponseBodyToControlplaneHealthCheckResult(v *HealthCheckResultResponseBody) *controlplane.HealthCheckResult { + if v == nil { + return nil + } + res := &controlplane.HealthCheckResult{ + Status: *v.Status, + Message: v.Message, + CheckedAt: *v.CheckedAt, + } + + return res +} + // unmarshalDatabaseSpecResponseBodyToControlplaneDatabaseSpec builds a value // of type *controlplane.DatabaseSpec from a value of type // *DatabaseSpecResponseBody. @@ -5588,6 +5892,16 @@ func unmarshalDatabaseSpecResponseBodyToControlplaneDatabaseSpec(v *DatabaseSpec res.DatabaseUsers[i] = unmarshalDatabaseUserSpecResponseBodyToControlplaneDatabaseUserSpec(val) } } + if v.Services != nil { + res.Services = make([]*controlplane.ServiceSpec, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = unmarshalServiceSpecResponseBodyToControlplaneServiceSpec(val) + } + } if v.BackupConfig != nil { res.BackupConfig = unmarshalBackupConfigSpecResponseBodyToControlplaneBackupConfigSpec(v.BackupConfig) } @@ -5916,6 +6230,34 @@ func unmarshalDatabaseUserSpecResponseBodyToControlplaneDatabaseUserSpec(v *Data return res } +// unmarshalServiceSpecResponseBodyToControlplaneServiceSpec builds a value of +// type *controlplane.ServiceSpec from a value of type *ServiceSpecResponseBody. +func unmarshalServiceSpecResponseBodyToControlplaneServiceSpec(v *ServiceSpecResponseBody) *controlplane.ServiceSpec { + if v == nil { + return nil + } + res := &controlplane.ServiceSpec{ + ServiceID: controlplane.Identifier(*v.ServiceID), + ServiceType: *v.ServiceType, + Version: *v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + res.HostIds = make([]controlplane.Identifier, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = controlplane.Identifier(val) + } + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + + return res +} + // marshalControlplaneDatabaseSpecToDatabaseSpecRequestBodyRequestBody builds a // value of type *DatabaseSpecRequestBodyRequestBody from a value of type // *controlplane.DatabaseSpec. @@ -5950,6 +6292,16 @@ func marshalControlplaneDatabaseSpecToDatabaseSpecRequestBodyRequestBody(v *cont res.DatabaseUsers[i] = marshalControlplaneDatabaseUserSpecToDatabaseUserSpecRequestBodyRequestBody(val) } } + if v.Services != nil { + res.Services = make([]*ServiceSpecRequestBodyRequestBody, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = marshalControlplaneServiceSpecToServiceSpecRequestBodyRequestBody(val) + } + } if v.BackupConfig != nil { res.BackupConfig = marshalControlplaneBackupConfigSpecToBackupConfigSpecRequestBodyRequestBody(v.BackupConfig) } @@ -6289,6 +6641,41 @@ func marshalControlplaneDatabaseUserSpecToDatabaseUserSpecRequestBodyRequestBody return res } +// marshalControlplaneServiceSpecToServiceSpecRequestBodyRequestBody builds a +// value of type *ServiceSpecRequestBodyRequestBody from a value of type +// *controlplane.ServiceSpec. +func marshalControlplaneServiceSpecToServiceSpecRequestBodyRequestBody(v *controlplane.ServiceSpec) *ServiceSpecRequestBodyRequestBody { + if v == nil { + return nil + } + res := &ServiceSpecRequestBodyRequestBody{ + ServiceID: string(v.ServiceID), + ServiceType: v.ServiceType, + Version: v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + if v.HostIds != nil { + res.HostIds = make([]string, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = string(val) + } + } else { + res.HostIds = []string{} + } + if v.Config != nil { + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + } + + return res +} + // marshalDatabaseSpecRequestBodyRequestBodyToControlplaneDatabaseSpec builds a // value of type *controlplane.DatabaseSpec from a value of type // *DatabaseSpecRequestBodyRequestBody. @@ -6323,6 +6710,16 @@ func marshalDatabaseSpecRequestBodyRequestBodyToControlplaneDatabaseSpec(v *Data res.DatabaseUsers[i] = marshalDatabaseUserSpecRequestBodyRequestBodyToControlplaneDatabaseUserSpec(val) } } + if v.Services != nil { + res.Services = make([]*controlplane.ServiceSpec, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = marshalServiceSpecRequestBodyRequestBodyToControlplaneServiceSpec(val) + } + } if v.BackupConfig != nil { res.BackupConfig = marshalBackupConfigSpecRequestBodyRequestBodyToControlplaneBackupConfigSpec(v.BackupConfig) } @@ -6662,6 +7059,41 @@ func marshalDatabaseUserSpecRequestBodyRequestBodyToControlplaneDatabaseUserSpec return res } +// marshalServiceSpecRequestBodyRequestBodyToControlplaneServiceSpec builds a +// value of type *controlplane.ServiceSpec from a value of type +// *ServiceSpecRequestBodyRequestBody. +func marshalServiceSpecRequestBodyRequestBodyToControlplaneServiceSpec(v *ServiceSpecRequestBodyRequestBody) *controlplane.ServiceSpec { + if v == nil { + return nil + } + res := &controlplane.ServiceSpec{ + ServiceID: controlplane.Identifier(v.ServiceID), + ServiceType: v.ServiceType, + Version: v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + if v.HostIds != nil { + res.HostIds = make([]controlplane.Identifier, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = controlplane.Identifier(val) + } + } else { + res.HostIds = []controlplane.Identifier{} + } + if v.Config != nil { + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + } + + return res +} + // unmarshalTaskLogEntryResponseBodyToControlplaneTaskLogEntry builds a value // of type *controlplane.TaskLogEntry from a value of type // *TaskLogEntryResponseBody. diff --git a/api/apiv1/gen/http/control_plane/client/types.go b/api/apiv1/gen/http/control_plane/client/types.go index 069ad765..9982ace5 100644 --- a/api/apiv1/gen/http/control_plane/client/types.go +++ b/api/apiv1/gen/http/control_plane/client/types.go @@ -213,6 +213,8 @@ type GetDatabaseResponseBody struct { State *string `form:"state,omitempty" json:"state,omitempty" xml:"state,omitempty"` // All of the instances in the database. Instances InstanceResponseBodyCollection `form:"instances,omitempty" json:"instances,omitempty" xml:"instances,omitempty"` + // Service instances running alongside this database. + ServiceInstances ServiceinstanceResponseBodyCollection `form:"service_instances,omitempty" json:"service_instances,omitempty" xml:"service_instances,omitempty"` // The user-provided specification for the database. Spec *DatabaseSpecResponseBody `form:"spec,omitempty" json:"spec,omitempty" xml:"spec,omitempty"` } @@ -1732,6 +1734,8 @@ type DatabaseResponseBody struct { State *string `form:"state,omitempty" json:"state,omitempty" xml:"state,omitempty"` // All of the instances in the database. Instances InstanceCollectionResponseBody `form:"instances,omitempty" json:"instances,omitempty" xml:"instances,omitempty"` + // Service instances running alongside this database. + ServiceInstances ServiceinstanceCollectionResponseBody `form:"service_instances,omitempty" json:"service_instances,omitempty" xml:"service_instances,omitempty"` // The user-provided specification for the database. Spec *DatabaseSpecResponseBody `form:"spec,omitempty" json:"spec,omitempty" xml:"spec,omitempty"` } @@ -1811,6 +1815,74 @@ type InstanceSubscriptionResponseBody struct { Status *string `form:"status,omitempty" json:"status,omitempty" xml:"status,omitempty"` } +// ServiceinstanceCollectionResponseBody is used to define fields on response +// body types. +type ServiceinstanceCollectionResponseBody []*ServiceinstanceResponseBody + +// ServiceinstanceResponseBody is used to define fields on response body types. +type ServiceinstanceResponseBody struct { + // Unique identifier for the service instance. + ServiceInstanceID *string `form:"service_instance_id,omitempty" json:"service_instance_id,omitempty" xml:"service_instance_id,omitempty"` + // The service ID from the DatabaseSpec. + ServiceID *string `form:"service_id,omitempty" json:"service_id,omitempty" xml:"service_id,omitempty"` + // The ID of the database this service belongs to. + DatabaseID *string `form:"database_id,omitempty" json:"database_id,omitempty" xml:"database_id,omitempty"` + // The ID of the host this service instance is running on. + HostID *string `form:"host_id,omitempty" json:"host_id,omitempty" xml:"host_id,omitempty"` + // Current state of the service instance. + State *string `form:"state,omitempty" json:"state,omitempty" xml:"state,omitempty"` + // Runtime status information for the service instance. + Status *ServiceInstanceStatusResponseBody `form:"status,omitempty" json:"status,omitempty" xml:"status,omitempty"` + // The time that the service instance was created. + CreatedAt *string `form:"created_at,omitempty" json:"created_at,omitempty" xml:"created_at,omitempty"` + // The time that the service instance was last updated. + UpdatedAt *string `form:"updated_at,omitempty" json:"updated_at,omitempty" xml:"updated_at,omitempty"` + // An error message if the service instance is in an error state. + Error *string `form:"error,omitempty" json:"error,omitempty" xml:"error,omitempty"` +} + +// ServiceInstanceStatusResponseBody is used to define fields on response body +// types. +type ServiceInstanceStatusResponseBody struct { + // The Docker container ID. + ContainerID *string `form:"container_id,omitempty" json:"container_id,omitempty" xml:"container_id,omitempty"` + // The container image version currently running. + ImageVersion *string `form:"image_version,omitempty" json:"image_version,omitempty" xml:"image_version,omitempty"` + // The hostname of the service instance. + Hostname *string `form:"hostname,omitempty" json:"hostname,omitempty" xml:"hostname,omitempty"` + // The IPv4 address of the service instance. + Ipv4Address *string `form:"ipv4_address,omitempty" json:"ipv4_address,omitempty" xml:"ipv4_address,omitempty"` + // Port mappings for this service instance. + Ports []*PortMappingResponseBody `form:"ports,omitempty" json:"ports,omitempty" xml:"ports,omitempty"` + // Most recent health check result. + HealthCheck *HealthCheckResultResponseBody `form:"health_check,omitempty" json:"health_check,omitempty" xml:"health_check,omitempty"` + // The time of the last health check attempt. + LastHealthAt *string `form:"last_health_at,omitempty" json:"last_health_at,omitempty" xml:"last_health_at,omitempty"` + // Whether the service is ready to accept requests. + ServiceReady *bool `form:"service_ready,omitempty" json:"service_ready,omitempty" xml:"service_ready,omitempty"` +} + +// PortMappingResponseBody is used to define fields on response body types. +type PortMappingResponseBody struct { + // The name of the port (e.g., 'http', 'web-client'). + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // The port number inside the container. + ContainerPort *int `form:"container_port,omitempty" json:"container_port,omitempty" xml:"container_port,omitempty"` + // The port number on the host (if port-forwarded). + HostPort *int `form:"host_port,omitempty" json:"host_port,omitempty" xml:"host_port,omitempty"` +} + +// HealthCheckResultResponseBody is used to define fields on response body +// types. +type HealthCheckResultResponseBody struct { + // The health status. + Status *string `form:"status,omitempty" json:"status,omitempty" xml:"status,omitempty"` + // Optional message about the health status. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + // The time this health check was performed. + CheckedAt *string `form:"checked_at,omitempty" json:"checked_at,omitempty" xml:"checked_at,omitempty"` +} + // DatabaseSpecResponseBody is used to define fields on response body types. type DatabaseSpecResponseBody struct { // The name of the Postgres database. @@ -1836,6 +1908,8 @@ type DatabaseSpecResponseBody struct { Nodes []*DatabaseNodeSpecResponseBody `form:"nodes,omitempty" json:"nodes,omitempty" xml:"nodes,omitempty"` // The users to create for this database. DatabaseUsers []*DatabaseUserSpecResponseBody `form:"database_users,omitempty" json:"database_users,omitempty" xml:"database_users,omitempty"` + // Service instances to run alongside the database (e.g., MCP servers). + Services []*ServiceSpecResponseBody `form:"services,omitempty" json:"services,omitempty" xml:"services,omitempty"` // The backup configuration for this database. BackupConfig *BackupConfigSpecResponseBody `form:"backup_config,omitempty" json:"backup_config,omitempty" xml:"backup_config,omitempty"` // The restore configuration for this database. @@ -2078,6 +2152,34 @@ type DatabaseUserSpecResponseBody struct { Roles []string `form:"roles,omitempty" json:"roles,omitempty" xml:"roles,omitempty"` } +// ServiceSpecResponseBody is used to define fields on response body types. +type ServiceSpecResponseBody struct { + // The unique identifier for this service. + ServiceID *string `form:"service_id,omitempty" json:"service_id,omitempty" xml:"service_id,omitempty"` + // The type of service to run. + ServiceType *string `form:"service_type,omitempty" json:"service_type,omitempty" xml:"service_type,omitempty"` + // The version of the service in semver format (e.g., '1.0.0') or the literal + // 'latest'. + Version *string `form:"version,omitempty" json:"version,omitempty" xml:"version,omitempty"` + // The IDs of the hosts that should run this service. One service instance will + // be created per host. + HostIds []string `form:"host_ids,omitempty" json:"host_ids,omitempty" xml:"host_ids,omitempty"` + // The port to publish the service on the host. If 0, Docker assigns a random + // port. If unspecified, no port is published and the service is not accessible + // from outside the Docker network. + Port *int `form:"port,omitempty" json:"port,omitempty" xml:"port,omitempty"` + // Service-specific configuration. For MCP services, this includes + // llm_provider, llm_model, and provider-specific API keys. + Config map[string]any `form:"config,omitempty" json:"config,omitempty" xml:"config,omitempty"` + // The number of CPUs to allocate for this service. It can include the SI + // suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if + // unspecified. + Cpus *string `form:"cpus,omitempty" json:"cpus,omitempty" xml:"cpus,omitempty"` + // The amount of memory in SI or IEC notation to allocate for this service. + // Defaults to container defaults if unspecified. + Memory *string `form:"memory,omitempty" json:"memory,omitempty" xml:"memory,omitempty"` +} + // DatabaseSpecRequestBody is used to define fields on request body types. type DatabaseSpecRequestBody struct { // The name of the Postgres database. @@ -2103,6 +2205,8 @@ type DatabaseSpecRequestBody struct { Nodes []*DatabaseNodeSpecRequestBody `form:"nodes" json:"nodes" xml:"nodes"` // The users to create for this database. DatabaseUsers []*DatabaseUserSpecRequestBody `form:"database_users,omitempty" json:"database_users,omitempty" xml:"database_users,omitempty"` + // Service instances to run alongside the database (e.g., MCP servers). + Services []*ServiceSpecRequestBody `form:"services,omitempty" json:"services,omitempty" xml:"services,omitempty"` // The backup configuration for this database. BackupConfig *BackupConfigSpecRequestBody `form:"backup_config,omitempty" json:"backup_config,omitempty" xml:"backup_config,omitempty"` // The restore configuration for this database. @@ -2343,10 +2447,42 @@ type DatabaseUserSpecRequestBody struct { Roles []string `form:"roles,omitempty" json:"roles,omitempty" xml:"roles,omitempty"` } +// ServiceSpecRequestBody is used to define fields on request body types. +type ServiceSpecRequestBody struct { + // The unique identifier for this service. + ServiceID string `form:"service_id" json:"service_id" xml:"service_id"` + // The type of service to run. + ServiceType string `form:"service_type" json:"service_type" xml:"service_type"` + // The version of the service in semver format (e.g., '1.0.0') or the literal + // 'latest'. + Version string `form:"version" json:"version" xml:"version"` + // The IDs of the hosts that should run this service. One service instance will + // be created per host. + HostIds []string `form:"host_ids" json:"host_ids" xml:"host_ids"` + // The port to publish the service on the host. If 0, Docker assigns a random + // port. If unspecified, no port is published and the service is not accessible + // from outside the Docker network. + Port *int `form:"port,omitempty" json:"port,omitempty" xml:"port,omitempty"` + // Service-specific configuration. For MCP services, this includes + // llm_provider, llm_model, and provider-specific API keys. + Config map[string]any `form:"config" json:"config" xml:"config"` + // The number of CPUs to allocate for this service. It can include the SI + // suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if + // unspecified. + Cpus *string `form:"cpus,omitempty" json:"cpus,omitempty" xml:"cpus,omitempty"` + // The amount of memory in SI or IEC notation to allocate for this service. + // Defaults to container defaults if unspecified. + Memory *string `form:"memory,omitempty" json:"memory,omitempty" xml:"memory,omitempty"` +} + // InstanceResponseBodyCollection is used to define fields on response body // types. type InstanceResponseBodyCollection []*InstanceResponseBody +// ServiceinstanceResponseBodyCollection is used to define fields on response +// body types. +type ServiceinstanceResponseBodyCollection []*ServiceinstanceResponseBody + // DatabaseSpecRequestBodyRequestBody is used to define fields on request body // types. type DatabaseSpecRequestBodyRequestBody struct { @@ -2373,6 +2509,8 @@ type DatabaseSpecRequestBodyRequestBody struct { Nodes []*DatabaseNodeSpecRequestBodyRequestBody `form:"nodes" json:"nodes" xml:"nodes"` // The users to create for this database. DatabaseUsers []*DatabaseUserSpecRequestBodyRequestBody `form:"database_users,omitempty" json:"database_users,omitempty" xml:"database_users,omitempty"` + // Service instances to run alongside the database (e.g., MCP servers). + Services []*ServiceSpecRequestBodyRequestBody `form:"services,omitempty" json:"services,omitempty" xml:"services,omitempty"` // The backup configuration for this database. BackupConfig *BackupConfigSpecRequestBodyRequestBody `form:"backup_config,omitempty" json:"backup_config,omitempty" xml:"backup_config,omitempty"` // The restore configuration for this database. @@ -2622,6 +2760,35 @@ type DatabaseUserSpecRequestBodyRequestBody struct { Roles []string `form:"roles,omitempty" json:"roles,omitempty" xml:"roles,omitempty"` } +// ServiceSpecRequestBodyRequestBody is used to define fields on request body +// types. +type ServiceSpecRequestBodyRequestBody struct { + // The unique identifier for this service. + ServiceID string `form:"service_id" json:"service_id" xml:"service_id"` + // The type of service to run. + ServiceType string `form:"service_type" json:"service_type" xml:"service_type"` + // The version of the service in semver format (e.g., '1.0.0') or the literal + // 'latest'. + Version string `form:"version" json:"version" xml:"version"` + // The IDs of the hosts that should run this service. One service instance will + // be created per host. + HostIds []string `form:"host_ids" json:"host_ids" xml:"host_ids"` + // The port to publish the service on the host. If 0, Docker assigns a random + // port. If unspecified, no port is published and the service is not accessible + // from outside the Docker network. + Port *int `form:"port,omitempty" json:"port,omitempty" xml:"port,omitempty"` + // Service-specific configuration. For MCP services, this includes + // llm_provider, llm_model, and provider-specific API keys. + Config map[string]any `form:"config" json:"config" xml:"config"` + // The number of CPUs to allocate for this service. It can include the SI + // suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if + // unspecified. + Cpus *string `form:"cpus,omitempty" json:"cpus,omitempty" xml:"cpus,omitempty"` + // The amount of memory in SI or IEC notation to allocate for this service. + // Defaults to container defaults if unspecified. + Memory *string `form:"memory,omitempty" json:"memory,omitempty" xml:"memory,omitempty"` +} + // TaskLogEntryResponseBody is used to define fields on response body types. type TaskLogEntryResponseBody struct { // The timestamp of the log entry. @@ -3269,15 +3436,21 @@ func NewGetDatabaseDatabaseOK(body *GetDatabaseResponseBody) *controlplaneviews. tenantID := controlplaneviews.IdentifierView(*body.TenantID) v.TenantID = &tenantID } - if body.Instances != nil { - v.Instances = make([]*controlplaneviews.InstanceView, len(body.Instances)) - for i, val := range body.Instances { - if val == nil { - v.Instances[i] = nil - continue - } - v.Instances[i] = unmarshalInstanceResponseBodyToControlplaneviewsInstanceView(val) + v.Instances = make([]*controlplaneviews.InstanceView, len(body.Instances)) + for i, val := range body.Instances { + if val == nil { + v.Instances[i] = nil + continue } + v.Instances[i] = unmarshalInstanceResponseBodyToControlplaneviewsInstanceView(val) + } + v.ServiceInstances = make([]*controlplaneviews.ServiceinstanceView, len(body.ServiceInstances)) + for i, val := range body.ServiceInstances { + if val == nil { + v.ServiceInstances[i] = nil + continue + } + v.ServiceInstances[i] = unmarshalServiceinstanceResponseBodyToControlplaneviewsServiceinstanceView(val) } if body.Spec != nil { v.Spec = unmarshalDatabaseSpecResponseBodyToControlplaneviewsDatabaseSpecView(body.Spec) @@ -5410,6 +5583,36 @@ func ValidateInstanceSubscriptionResponseBody(body *InstanceSubscriptionResponse return } +// ValidateServiceinstanceCollectionResponseBody runs a no-op validation on +// ServiceinstanceCollectionResponseBody +func ValidateServiceinstanceCollectionResponseBody(body ServiceinstanceCollectionResponseBody) (err error) { + return +} + +// ValidateServiceinstanceResponseBody runs a no-op validation on +// ServiceinstanceResponseBody +func ValidateServiceinstanceResponseBody(body *ServiceinstanceResponseBody) (err error) { + return +} + +// ValidateServiceInstanceStatusResponseBody runs a no-op validation on +// ServiceInstanceStatusResponseBody +func ValidateServiceInstanceStatusResponseBody(body *ServiceInstanceStatusResponseBody) (err error) { + return +} + +// ValidatePortMappingResponseBody runs a no-op validation on +// PortMappingResponseBody +func ValidatePortMappingResponseBody(body *PortMappingResponseBody) (err error) { + return +} + +// ValidateHealthCheckResultResponseBody runs a no-op validation on +// HealthCheckResultResponseBody +func ValidateHealthCheckResultResponseBody(body *HealthCheckResultResponseBody) (err error) { + return +} + // ValidateDatabaseSpecResponseBody runs a no-op validation on // DatabaseSpecResponseBody func ValidateDatabaseSpecResponseBody(body *DatabaseSpecResponseBody) (err error) { @@ -5482,6 +5685,12 @@ func ValidateDatabaseUserSpecResponseBody(body *DatabaseUserSpecResponseBody) (e return } +// ValidateServiceSpecResponseBody runs a no-op validation on +// ServiceSpecResponseBody +func ValidateServiceSpecResponseBody(body *ServiceSpecResponseBody) (err error) { + return +} + // ValidateDatabaseSpecRequestBody runs the validations defined on // DatabaseSpecRequestBody func ValidateDatabaseSpecRequestBody(body *DatabaseSpecRequestBody) (err error) { @@ -5541,6 +5750,13 @@ func ValidateDatabaseSpecRequestBody(body *DatabaseSpecRequestBody) (err error) } } } + for _, e := range body.Services { + if e != nil { + if err2 := ValidateServiceSpecRequestBody(e); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } if body.BackupConfig != nil { if err2 := ValidateBackupConfigSpecRequestBody(body.BackupConfig); err2 != nil { err = goa.MergeErrors(err, err2) @@ -6034,6 +6250,57 @@ func ValidateDatabaseUserSpecRequestBody(body *DatabaseUserSpecRequestBody) (err return } +// ValidateServiceSpecRequestBody runs the validations defined on +// ServiceSpecRequestBody +func ValidateServiceSpecRequestBody(body *ServiceSpecRequestBody) (err error) { + if body.HostIds == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("host_ids", "body")) + } + if body.Config == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("config", "body")) + } + if utf8.RuneCountInString(body.ServiceID) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.service_id", body.ServiceID, utf8.RuneCountInString(body.ServiceID), 1, true)) + } + if utf8.RuneCountInString(body.ServiceID) > 63 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.service_id", body.ServiceID, utf8.RuneCountInString(body.ServiceID), 63, false)) + } + if !(body.ServiceType == "mcp") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("body.service_type", body.ServiceType, []any{"mcp"})) + } + err = goa.MergeErrors(err, goa.ValidatePattern("body.version", body.Version, "^(\\d+\\.\\d+\\.\\d+|latest)$")) + if len(body.HostIds) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids", body.HostIds, len(body.HostIds), 1, true)) + } + for _, e := range body.HostIds { + if utf8.RuneCountInString(e) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids[*]", e, utf8.RuneCountInString(e), 1, true)) + } + if utf8.RuneCountInString(e) > 63 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids[*]", e, utf8.RuneCountInString(e), 63, false)) + } + } + if body.Port != nil { + if *body.Port < 0 { + err = goa.MergeErrors(err, goa.InvalidRangeError("body.port", *body.Port, 0, true)) + } + } + if body.Port != nil { + if *body.Port > 65535 { + err = goa.MergeErrors(err, goa.InvalidRangeError("body.port", *body.Port, 65535, false)) + } + } + if body.Cpus != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.cpus", *body.Cpus, "^[0-9]+(\\.[0-9]{1,3}|m)?$")) + } + if body.Memory != nil { + if utf8.RuneCountInString(*body.Memory) > 16 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.memory", *body.Memory, utf8.RuneCountInString(*body.Memory), 16, false)) + } + } + return +} + // ValidateInstanceResponseBodyCollection runs the validations defined on // InstanceResponseBodyCollection func ValidateInstanceResponseBodyCollection(body InstanceResponseBodyCollection) (err error) { @@ -6047,6 +6314,19 @@ func ValidateInstanceResponseBodyCollection(body InstanceResponseBodyCollection) return } +// ValidateServiceinstanceResponseBodyCollection runs the validations defined +// on ServiceinstanceResponseBodyCollection +func ValidateServiceinstanceResponseBodyCollection(body ServiceinstanceResponseBodyCollection) (err error) { + for _, e := range body { + if e != nil { + if err2 := ValidateServiceinstanceResponseBody(e); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + return +} + // ValidateDatabaseSpecRequestBodyRequestBody runs the validations defined on // DatabaseSpecRequestBodyRequestBody func ValidateDatabaseSpecRequestBodyRequestBody(body *DatabaseSpecRequestBodyRequestBody) (err error) { @@ -6106,6 +6386,13 @@ func ValidateDatabaseSpecRequestBodyRequestBody(body *DatabaseSpecRequestBodyReq } } } + for _, e := range body.Services { + if e != nil { + if err2 := ValidateServiceSpecRequestBodyRequestBody(e); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } if body.BackupConfig != nil { if err2 := ValidateBackupConfigSpecRequestBodyRequestBody(body.BackupConfig); err2 != nil { err = goa.MergeErrors(err, err2) @@ -6599,6 +6886,57 @@ func ValidateDatabaseUserSpecRequestBodyRequestBody(body *DatabaseUserSpecReques return } +// ValidateServiceSpecRequestBodyRequestBody runs the validations defined on +// ServiceSpecRequestBodyRequestBody +func ValidateServiceSpecRequestBodyRequestBody(body *ServiceSpecRequestBodyRequestBody) (err error) { + if body.HostIds == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("host_ids", "body")) + } + if body.Config == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("config", "body")) + } + if utf8.RuneCountInString(body.ServiceID) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.service_id", body.ServiceID, utf8.RuneCountInString(body.ServiceID), 1, true)) + } + if utf8.RuneCountInString(body.ServiceID) > 63 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.service_id", body.ServiceID, utf8.RuneCountInString(body.ServiceID), 63, false)) + } + if !(body.ServiceType == "mcp") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("body.service_type", body.ServiceType, []any{"mcp"})) + } + err = goa.MergeErrors(err, goa.ValidatePattern("body.version", body.Version, "^(\\d+\\.\\d+\\.\\d+|latest)$")) + if len(body.HostIds) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids", body.HostIds, len(body.HostIds), 1, true)) + } + for _, e := range body.HostIds { + if utf8.RuneCountInString(e) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids[*]", e, utf8.RuneCountInString(e), 1, true)) + } + if utf8.RuneCountInString(e) > 63 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids[*]", e, utf8.RuneCountInString(e), 63, false)) + } + } + if body.Port != nil { + if *body.Port < 0 { + err = goa.MergeErrors(err, goa.InvalidRangeError("body.port", *body.Port, 0, true)) + } + } + if body.Port != nil { + if *body.Port > 65535 { + err = goa.MergeErrors(err, goa.InvalidRangeError("body.port", *body.Port, 65535, false)) + } + } + if body.Cpus != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.cpus", *body.Cpus, "^[0-9]+(\\.[0-9]{1,3}|m)?$")) + } + if body.Memory != nil { + if utf8.RuneCountInString(*body.Memory) > 16 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.memory", *body.Memory, utf8.RuneCountInString(*body.Memory), 16, false)) + } + } + return +} + // ValidateTaskLogEntryResponseBody runs a no-op validation on // TaskLogEntryResponseBody func ValidateTaskLogEntryResponseBody(body *TaskLogEntryResponseBody) (err error) { diff --git a/api/apiv1/gen/http/control_plane/server/encode_decode.go b/api/apiv1/gen/http/control_plane/server/encode_decode.go index 3d5f661d..e529aced 100644 --- a/api/apiv1/gen/http/control_plane/server/encode_decode.go +++ b/api/apiv1/gen/http/control_plane/server/encode_decode.go @@ -3569,6 +3569,8 @@ func marshalControlplaneviewsDatabaseViewToDatabaseResponseBodyAbbreviated(v *co } res.Instances[i] = marshalControlplaneviewsInstanceViewToInstanceResponseBodyAbbreviated(val) } + } else { + res.Instances = []*InstanceResponseBodyAbbreviated{} } return res @@ -3578,9 +3580,6 @@ func marshalControlplaneviewsDatabaseViewToDatabaseResponseBodyAbbreviated(v *co // a value of type *InstanceResponseBodyAbbreviated from a value of type // *controlplaneviews.InstanceView. func marshalControlplaneviewsInstanceViewToInstanceResponseBodyAbbreviated(v *controlplaneviews.InstanceView) *InstanceResponseBodyAbbreviated { - if v == nil { - return nil - } res := &InstanceResponseBodyAbbreviated{ ID: *v.ID, HostID: *v.HostID, @@ -3621,6 +3620,16 @@ func unmarshalDatabaseSpecRequestBodyToControlplaneDatabaseSpec(v *DatabaseSpecR res.DatabaseUsers[i] = unmarshalDatabaseUserSpecRequestBodyToControlplaneDatabaseUserSpec(val) } } + if v.Services != nil { + res.Services = make([]*controlplane.ServiceSpec, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = unmarshalServiceSpecRequestBodyToControlplaneServiceSpec(val) + } + } if v.BackupConfig != nil { res.BackupConfig = unmarshalBackupConfigSpecRequestBodyToControlplaneBackupConfigSpec(v.BackupConfig) } @@ -3949,6 +3958,34 @@ func unmarshalDatabaseUserSpecRequestBodyToControlplaneDatabaseUserSpec(v *Datab return res } +// unmarshalServiceSpecRequestBodyToControlplaneServiceSpec builds a value of +// type *controlplane.ServiceSpec from a value of type *ServiceSpecRequestBody. +func unmarshalServiceSpecRequestBodyToControlplaneServiceSpec(v *ServiceSpecRequestBody) *controlplane.ServiceSpec { + if v == nil { + return nil + } + res := &controlplane.ServiceSpec{ + ServiceID: controlplane.Identifier(*v.ServiceID), + ServiceType: *v.ServiceType, + Version: *v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + res.HostIds = make([]controlplane.Identifier, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = controlplane.Identifier(val) + } + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + + return res +} + // marshalControlplaneDatabaseToDatabaseResponseBody builds a value of type // *DatabaseResponseBody from a value of type *controlplane.Database. func marshalControlplaneDatabaseToDatabaseResponseBody(v *controlplane.Database) *DatabaseResponseBody { @@ -3971,6 +4008,20 @@ func marshalControlplaneDatabaseToDatabaseResponseBody(v *controlplane.Database) } res.Instances[i] = marshalControlplaneInstanceToInstanceResponseBody(val) } + } else { + res.Instances = []*InstanceResponseBody{} + } + if v.ServiceInstances != nil { + res.ServiceInstances = make([]*ServiceinstanceResponseBody, len(v.ServiceInstances)) + for i, val := range v.ServiceInstances { + if val == nil { + res.ServiceInstances[i] = nil + continue + } + res.ServiceInstances[i] = marshalControlplaneServiceinstanceToServiceinstanceResponseBody(val) + } + } else { + res.ServiceInstances = []*ServiceinstanceResponseBody{} } if v.Spec != nil { res.Spec = marshalControlplaneDatabaseSpecToDatabaseSpecResponseBody(v.Spec) @@ -3982,9 +4033,6 @@ func marshalControlplaneDatabaseToDatabaseResponseBody(v *controlplane.Database) // marshalControlplaneInstanceToInstanceResponseBody builds a value of type // *InstanceResponseBody from a value of type *controlplane.Instance. func marshalControlplaneInstanceToInstanceResponseBody(v *controlplane.Instance) *InstanceResponseBody { - if v == nil { - return nil - } res := &InstanceResponseBody{ ID: v.ID, HostID: v.HostID, @@ -4083,6 +4131,90 @@ func marshalControlplaneInstanceSubscriptionToInstanceSubscriptionResponseBody(v return res } +// marshalControlplaneServiceinstanceToServiceinstanceResponseBody builds a +// value of type *ServiceinstanceResponseBody from a value of type +// *controlplane.Serviceinstance. +func marshalControlplaneServiceinstanceToServiceinstanceResponseBody(v *controlplane.Serviceinstance) *ServiceinstanceResponseBody { + res := &ServiceinstanceResponseBody{ + ServiceInstanceID: v.ServiceInstanceID, + ServiceID: v.ServiceID, + DatabaseID: string(v.DatabaseID), + HostID: v.HostID, + State: v.State, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + Error: v.Error, + } + if v.Status != nil { + res.Status = marshalControlplaneServiceInstanceStatusToServiceInstanceStatusResponseBody(v.Status) + } + + return res +} + +// marshalControlplaneServiceInstanceStatusToServiceInstanceStatusResponseBody +// builds a value of type *ServiceInstanceStatusResponseBody from a value of +// type *controlplane.ServiceInstanceStatus. +func marshalControlplaneServiceInstanceStatusToServiceInstanceStatusResponseBody(v *controlplane.ServiceInstanceStatus) *ServiceInstanceStatusResponseBody { + if v == nil { + return nil + } + res := &ServiceInstanceStatusResponseBody{ + ContainerID: v.ContainerID, + ImageVersion: v.ImageVersion, + Hostname: v.Hostname, + Ipv4Address: v.Ipv4Address, + LastHealthAt: v.LastHealthAt, + ServiceReady: v.ServiceReady, + } + if v.Ports != nil { + res.Ports = make([]*PortMappingResponseBody, len(v.Ports)) + for i, val := range v.Ports { + if val == nil { + res.Ports[i] = nil + continue + } + res.Ports[i] = marshalControlplanePortMappingToPortMappingResponseBody(val) + } + } + if v.HealthCheck != nil { + res.HealthCheck = marshalControlplaneHealthCheckResultToHealthCheckResultResponseBody(v.HealthCheck) + } + + return res +} + +// marshalControlplanePortMappingToPortMappingResponseBody builds a value of +// type *PortMappingResponseBody from a value of type *controlplane.PortMapping. +func marshalControlplanePortMappingToPortMappingResponseBody(v *controlplane.PortMapping) *PortMappingResponseBody { + if v == nil { + return nil + } + res := &PortMappingResponseBody{ + Name: v.Name, + ContainerPort: v.ContainerPort, + HostPort: v.HostPort, + } + + return res +} + +// marshalControlplaneHealthCheckResultToHealthCheckResultResponseBody builds a +// value of type *HealthCheckResultResponseBody from a value of type +// *controlplane.HealthCheckResult. +func marshalControlplaneHealthCheckResultToHealthCheckResultResponseBody(v *controlplane.HealthCheckResult) *HealthCheckResultResponseBody { + if v == nil { + return nil + } + res := &HealthCheckResultResponseBody{ + Status: v.Status, + Message: v.Message, + CheckedAt: v.CheckedAt, + } + + return res +} + // marshalControlplaneDatabaseSpecToDatabaseSpecResponseBody builds a value of // type *DatabaseSpecResponseBody from a value of type // *controlplane.DatabaseSpec. @@ -4120,6 +4252,16 @@ func marshalControlplaneDatabaseSpecToDatabaseSpecResponseBody(v *controlplane.D res.DatabaseUsers[i] = marshalControlplaneDatabaseUserSpecToDatabaseUserSpecResponseBody(val) } } + if v.Services != nil { + res.Services = make([]*ServiceSpecResponseBody, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = marshalControlplaneServiceSpecToServiceSpecResponseBody(val) + } + } if v.BackupConfig != nil { res.BackupConfig = marshalControlplaneBackupConfigSpecToBackupConfigSpecResponseBody(v.BackupConfig) } @@ -4458,13 +4600,44 @@ func marshalControlplaneDatabaseUserSpecToDatabaseUserSpecResponseBody(v *contro return res } +// marshalControlplaneServiceSpecToServiceSpecResponseBody builds a value of +// type *ServiceSpecResponseBody from a value of type *controlplane.ServiceSpec. +func marshalControlplaneServiceSpecToServiceSpecResponseBody(v *controlplane.ServiceSpec) *ServiceSpecResponseBody { + if v == nil { + return nil + } + res := &ServiceSpecResponseBody{ + ServiceID: string(v.ServiceID), + ServiceType: v.ServiceType, + Version: v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + if v.HostIds != nil { + res.HostIds = make([]string, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = string(val) + } + } else { + res.HostIds = []string{} + } + if v.Config != nil { + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + } + + return res +} + // marshalControlplaneviewsInstanceViewToInstanceResponseBody builds a value of // type *InstanceResponseBody from a value of type // *controlplaneviews.InstanceView. func marshalControlplaneviewsInstanceViewToInstanceResponseBody(v *controlplaneviews.InstanceView) *InstanceResponseBody { - if v == nil { - return nil - } res := &InstanceResponseBody{ ID: *v.ID, HostID: *v.HostID, @@ -4563,6 +4736,91 @@ func marshalControlplaneviewsInstanceSubscriptionViewToInstanceSubscriptionRespo return res } +// marshalControlplaneviewsServiceinstanceViewToServiceinstanceResponseBody +// builds a value of type *ServiceinstanceResponseBody from a value of type +// *controlplaneviews.ServiceinstanceView. +func marshalControlplaneviewsServiceinstanceViewToServiceinstanceResponseBody(v *controlplaneviews.ServiceinstanceView) *ServiceinstanceResponseBody { + res := &ServiceinstanceResponseBody{ + ServiceInstanceID: *v.ServiceInstanceID, + ServiceID: *v.ServiceID, + DatabaseID: string(*v.DatabaseID), + HostID: *v.HostID, + State: *v.State, + CreatedAt: *v.CreatedAt, + UpdatedAt: *v.UpdatedAt, + Error: v.Error, + } + if v.Status != nil { + res.Status = marshalControlplaneviewsServiceInstanceStatusViewToServiceInstanceStatusResponseBody(v.Status) + } + + return res +} + +// marshalControlplaneviewsServiceInstanceStatusViewToServiceInstanceStatusResponseBody +// builds a value of type *ServiceInstanceStatusResponseBody from a value of +// type *controlplaneviews.ServiceInstanceStatusView. +func marshalControlplaneviewsServiceInstanceStatusViewToServiceInstanceStatusResponseBody(v *controlplaneviews.ServiceInstanceStatusView) *ServiceInstanceStatusResponseBody { + if v == nil { + return nil + } + res := &ServiceInstanceStatusResponseBody{ + ContainerID: v.ContainerID, + ImageVersion: v.ImageVersion, + Hostname: v.Hostname, + Ipv4Address: v.Ipv4Address, + LastHealthAt: v.LastHealthAt, + ServiceReady: v.ServiceReady, + } + if v.Ports != nil { + res.Ports = make([]*PortMappingResponseBody, len(v.Ports)) + for i, val := range v.Ports { + if val == nil { + res.Ports[i] = nil + continue + } + res.Ports[i] = marshalControlplaneviewsPortMappingViewToPortMappingResponseBody(val) + } + } + if v.HealthCheck != nil { + res.HealthCheck = marshalControlplaneviewsHealthCheckResultViewToHealthCheckResultResponseBody(v.HealthCheck) + } + + return res +} + +// marshalControlplaneviewsPortMappingViewToPortMappingResponseBody builds a +// value of type *PortMappingResponseBody from a value of type +// *controlplaneviews.PortMappingView. +func marshalControlplaneviewsPortMappingViewToPortMappingResponseBody(v *controlplaneviews.PortMappingView) *PortMappingResponseBody { + if v == nil { + return nil + } + res := &PortMappingResponseBody{ + Name: *v.Name, + ContainerPort: *v.ContainerPort, + HostPort: v.HostPort, + } + + return res +} + +// marshalControlplaneviewsHealthCheckResultViewToHealthCheckResultResponseBody +// builds a value of type *HealthCheckResultResponseBody from a value of type +// *controlplaneviews.HealthCheckResultView. +func marshalControlplaneviewsHealthCheckResultViewToHealthCheckResultResponseBody(v *controlplaneviews.HealthCheckResultView) *HealthCheckResultResponseBody { + if v == nil { + return nil + } + res := &HealthCheckResultResponseBody{ + Status: *v.Status, + Message: v.Message, + CheckedAt: *v.CheckedAt, + } + + return res +} + // marshalControlplaneviewsDatabaseSpecViewToDatabaseSpecResponseBody builds a // value of type *DatabaseSpecResponseBody from a value of type // *controlplaneviews.DatabaseSpecView. @@ -4600,6 +4858,16 @@ func marshalControlplaneviewsDatabaseSpecViewToDatabaseSpecResponseBody(v *contr res.DatabaseUsers[i] = marshalControlplaneviewsDatabaseUserSpecViewToDatabaseUserSpecResponseBody(val) } } + if v.Services != nil { + res.Services = make([]*ServiceSpecResponseBody, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = marshalControlplaneviewsServiceSpecViewToServiceSpecResponseBody(val) + } + } if v.BackupConfig != nil { res.BackupConfig = marshalControlplaneviewsBackupConfigSpecViewToBackupConfigSpecResponseBody(v.BackupConfig) } @@ -4939,6 +5207,41 @@ func marshalControlplaneviewsDatabaseUserSpecViewToDatabaseUserSpecResponseBody( return res } +// marshalControlplaneviewsServiceSpecViewToServiceSpecResponseBody builds a +// value of type *ServiceSpecResponseBody from a value of type +// *controlplaneviews.ServiceSpecView. +func marshalControlplaneviewsServiceSpecViewToServiceSpecResponseBody(v *controlplaneviews.ServiceSpecView) *ServiceSpecResponseBody { + if v == nil { + return nil + } + res := &ServiceSpecResponseBody{ + ServiceID: string(*v.ServiceID), + ServiceType: *v.ServiceType, + Version: *v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + if v.HostIds != nil { + res.HostIds = make([]string, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = string(val) + } + } else { + res.HostIds = []string{} + } + if v.Config != nil { + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + } + + return res +} + // unmarshalDatabaseSpecRequestBodyRequestBodyToControlplaneDatabaseSpec builds // a value of type *controlplane.DatabaseSpec from a value of type // *DatabaseSpecRequestBodyRequestBody. @@ -4969,6 +5272,16 @@ func unmarshalDatabaseSpecRequestBodyRequestBodyToControlplaneDatabaseSpec(v *Da res.DatabaseUsers[i] = unmarshalDatabaseUserSpecRequestBodyRequestBodyToControlplaneDatabaseUserSpec(val) } } + if v.Services != nil { + res.Services = make([]*controlplane.ServiceSpec, len(v.Services)) + for i, val := range v.Services { + if val == nil { + res.Services[i] = nil + continue + } + res.Services[i] = unmarshalServiceSpecRequestBodyRequestBodyToControlplaneServiceSpec(val) + } + } if v.BackupConfig != nil { res.BackupConfig = unmarshalBackupConfigSpecRequestBodyRequestBodyToControlplaneBackupConfigSpec(v.BackupConfig) } @@ -5298,6 +5611,35 @@ func unmarshalDatabaseUserSpecRequestBodyRequestBodyToControlplaneDatabaseUserSp return res } +// unmarshalServiceSpecRequestBodyRequestBodyToControlplaneServiceSpec builds a +// value of type *controlplane.ServiceSpec from a value of type +// *ServiceSpecRequestBodyRequestBody. +func unmarshalServiceSpecRequestBodyRequestBodyToControlplaneServiceSpec(v *ServiceSpecRequestBodyRequestBody) *controlplane.ServiceSpec { + if v == nil { + return nil + } + res := &controlplane.ServiceSpec{ + ServiceID: controlplane.Identifier(*v.ServiceID), + ServiceType: *v.ServiceType, + Version: *v.Version, + Port: v.Port, + Cpus: v.Cpus, + Memory: v.Memory, + } + res.HostIds = make([]controlplane.Identifier, len(v.HostIds)) + for i, val := range v.HostIds { + res.HostIds[i] = controlplane.Identifier(val) + } + res.Config = make(map[string]any, len(v.Config)) + for key, val := range v.Config { + tk := key + tv := val + res.Config[tk] = tv + } + + return res +} + // marshalControlplaneTaskLogEntryToTaskLogEntryResponseBody builds a value of // type *TaskLogEntryResponseBody from a value of type // *controlplane.TaskLogEntry. diff --git a/api/apiv1/gen/http/control_plane/server/types.go b/api/apiv1/gen/http/control_plane/server/types.go index 4308a547..b7d0b338 100644 --- a/api/apiv1/gen/http/control_plane/server/types.go +++ b/api/apiv1/gen/http/control_plane/server/types.go @@ -212,7 +212,9 @@ type GetDatabaseResponseBody struct { // Current state of the database. State string `form:"state" json:"state" xml:"state"` // All of the instances in the database. - Instances InstanceResponseBodyCollection `form:"instances,omitempty" json:"instances,omitempty" xml:"instances,omitempty"` + Instances InstanceResponseBodyCollection `form:"instances" json:"instances" xml:"instances"` + // Service instances running alongside this database. + ServiceInstances ServiceinstanceResponseBodyCollection `form:"service_instances" json:"service_instances" xml:"service_instances"` // The user-provided specification for the database. Spec *DatabaseSpecResponseBody `form:"spec,omitempty" json:"spec,omitempty" xml:"spec,omitempty"` } @@ -1732,7 +1734,7 @@ type DatabaseResponseBodyAbbreviated struct { // Current state of the database. State string `form:"state" json:"state" xml:"state"` // All of the instances in the database. - Instances InstanceResponseBodyAbbreviatedCollection `form:"instances,omitempty" json:"instances,omitempty" xml:"instances,omitempty"` + Instances InstanceResponseBodyAbbreviatedCollection `form:"instances" json:"instances" xml:"instances"` } // InstanceResponseBodyAbbreviatedCollection is used to define fields on @@ -1764,7 +1766,9 @@ type DatabaseResponseBody struct { // Current state of the database. State string `form:"state" json:"state" xml:"state"` // All of the instances in the database. - Instances InstanceCollectionResponseBody `form:"instances,omitempty" json:"instances,omitempty" xml:"instances,omitempty"` + Instances InstanceCollectionResponseBody `form:"instances" json:"instances" xml:"instances"` + // Service instances running alongside this database. + ServiceInstances ServiceinstanceCollectionResponseBody `form:"service_instances" json:"service_instances" xml:"service_instances"` // The user-provided specification for the database. Spec *DatabaseSpecResponseBody `form:"spec,omitempty" json:"spec,omitempty" xml:"spec,omitempty"` } @@ -1844,6 +1848,74 @@ type InstanceSubscriptionResponseBody struct { Status string `form:"status" json:"status" xml:"status"` } +// ServiceinstanceCollectionResponseBody is used to define fields on response +// body types. +type ServiceinstanceCollectionResponseBody []*ServiceinstanceResponseBody + +// ServiceinstanceResponseBody is used to define fields on response body types. +type ServiceinstanceResponseBody struct { + // Unique identifier for the service instance. + ServiceInstanceID string `form:"service_instance_id" json:"service_instance_id" xml:"service_instance_id"` + // The service ID from the DatabaseSpec. + ServiceID string `form:"service_id" json:"service_id" xml:"service_id"` + // The ID of the database this service belongs to. + DatabaseID string `form:"database_id" json:"database_id" xml:"database_id"` + // The ID of the host this service instance is running on. + HostID string `form:"host_id" json:"host_id" xml:"host_id"` + // Current state of the service instance. + State string `form:"state" json:"state" xml:"state"` + // Runtime status information for the service instance. + Status *ServiceInstanceStatusResponseBody `form:"status,omitempty" json:"status,omitempty" xml:"status,omitempty"` + // The time that the service instance was created. + CreatedAt string `form:"created_at" json:"created_at" xml:"created_at"` + // The time that the service instance was last updated. + UpdatedAt string `form:"updated_at" json:"updated_at" xml:"updated_at"` + // An error message if the service instance is in an error state. + Error *string `form:"error,omitempty" json:"error,omitempty" xml:"error,omitempty"` +} + +// ServiceInstanceStatusResponseBody is used to define fields on response body +// types. +type ServiceInstanceStatusResponseBody struct { + // The Docker container ID. + ContainerID *string `form:"container_id,omitempty" json:"container_id,omitempty" xml:"container_id,omitempty"` + // The container image version currently running. + ImageVersion *string `form:"image_version,omitempty" json:"image_version,omitempty" xml:"image_version,omitempty"` + // The hostname of the service instance. + Hostname *string `form:"hostname,omitempty" json:"hostname,omitempty" xml:"hostname,omitempty"` + // The IPv4 address of the service instance. + Ipv4Address *string `form:"ipv4_address,omitempty" json:"ipv4_address,omitempty" xml:"ipv4_address,omitempty"` + // Port mappings for this service instance. + Ports []*PortMappingResponseBody `form:"ports,omitempty" json:"ports,omitempty" xml:"ports,omitempty"` + // Most recent health check result. + HealthCheck *HealthCheckResultResponseBody `form:"health_check,omitempty" json:"health_check,omitempty" xml:"health_check,omitempty"` + // The time of the last health check attempt. + LastHealthAt *string `form:"last_health_at,omitempty" json:"last_health_at,omitempty" xml:"last_health_at,omitempty"` + // Whether the service is ready to accept requests. + ServiceReady *bool `form:"service_ready,omitempty" json:"service_ready,omitempty" xml:"service_ready,omitempty"` +} + +// PortMappingResponseBody is used to define fields on response body types. +type PortMappingResponseBody struct { + // The name of the port (e.g., 'http', 'web-client'). + Name string `form:"name" json:"name" xml:"name"` + // The port number inside the container. + ContainerPort int `form:"container_port" json:"container_port" xml:"container_port"` + // The port number on the host (if port-forwarded). + HostPort *int `form:"host_port,omitempty" json:"host_port,omitempty" xml:"host_port,omitempty"` +} + +// HealthCheckResultResponseBody is used to define fields on response body +// types. +type HealthCheckResultResponseBody struct { + // The health status. + Status string `form:"status" json:"status" xml:"status"` + // Optional message about the health status. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + // The time this health check was performed. + CheckedAt string `form:"checked_at" json:"checked_at" xml:"checked_at"` +} + // DatabaseSpecResponseBody is used to define fields on response body types. type DatabaseSpecResponseBody struct { // The name of the Postgres database. @@ -1869,6 +1941,8 @@ type DatabaseSpecResponseBody struct { Nodes []*DatabaseNodeSpecResponseBody `form:"nodes" json:"nodes" xml:"nodes"` // The users to create for this database. DatabaseUsers []*DatabaseUserSpecResponseBody `form:"database_users,omitempty" json:"database_users,omitempty" xml:"database_users,omitempty"` + // Service instances to run alongside the database (e.g., MCP servers). + Services []*ServiceSpecResponseBody `form:"services,omitempty" json:"services,omitempty" xml:"services,omitempty"` // The backup configuration for this database. BackupConfig *BackupConfigSpecResponseBody `form:"backup_config,omitempty" json:"backup_config,omitempty" xml:"backup_config,omitempty"` // The restore configuration for this database. @@ -2111,10 +2185,42 @@ type DatabaseUserSpecResponseBody struct { Roles []string `form:"roles,omitempty" json:"roles,omitempty" xml:"roles,omitempty"` } +// ServiceSpecResponseBody is used to define fields on response body types. +type ServiceSpecResponseBody struct { + // The unique identifier for this service. + ServiceID string `form:"service_id" json:"service_id" xml:"service_id"` + // The type of service to run. + ServiceType string `form:"service_type" json:"service_type" xml:"service_type"` + // The version of the service in semver format (e.g., '1.0.0') or the literal + // 'latest'. + Version string `form:"version" json:"version" xml:"version"` + // The IDs of the hosts that should run this service. One service instance will + // be created per host. + HostIds []string `form:"host_ids" json:"host_ids" xml:"host_ids"` + // The port to publish the service on the host. If 0, Docker assigns a random + // port. If unspecified, no port is published and the service is not accessible + // from outside the Docker network. + Port *int `form:"port,omitempty" json:"port,omitempty" xml:"port,omitempty"` + // Service-specific configuration. For MCP services, this includes + // llm_provider, llm_model, and provider-specific API keys. + Config map[string]any `form:"config" json:"config" xml:"config"` + // The number of CPUs to allocate for this service. It can include the SI + // suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if + // unspecified. + Cpus *string `form:"cpus,omitempty" json:"cpus,omitempty" xml:"cpus,omitempty"` + // The amount of memory in SI or IEC notation to allocate for this service. + // Defaults to container defaults if unspecified. + Memory *string `form:"memory,omitempty" json:"memory,omitempty" xml:"memory,omitempty"` +} + // InstanceResponseBodyCollection is used to define fields on response body // types. type InstanceResponseBodyCollection []*InstanceResponseBody +// ServiceinstanceResponseBodyCollection is used to define fields on response +// body types. +type ServiceinstanceResponseBodyCollection []*ServiceinstanceResponseBody + // TaskLogEntryResponseBody is used to define fields on response body types. type TaskLogEntryResponseBody struct { // The timestamp of the log entry. @@ -2150,6 +2256,8 @@ type DatabaseSpecRequestBody struct { Nodes []*DatabaseNodeSpecRequestBody `form:"nodes,omitempty" json:"nodes,omitempty" xml:"nodes,omitempty"` // The users to create for this database. DatabaseUsers []*DatabaseUserSpecRequestBody `form:"database_users,omitempty" json:"database_users,omitempty" xml:"database_users,omitempty"` + // Service instances to run alongside the database (e.g., MCP servers). + Services []*ServiceSpecRequestBody `form:"services,omitempty" json:"services,omitempty" xml:"services,omitempty"` // The backup configuration for this database. BackupConfig *BackupConfigSpecRequestBody `form:"backup_config,omitempty" json:"backup_config,omitempty" xml:"backup_config,omitempty"` // The restore configuration for this database. @@ -2390,6 +2498,34 @@ type DatabaseUserSpecRequestBody struct { Roles []string `form:"roles,omitempty" json:"roles,omitempty" xml:"roles,omitempty"` } +// ServiceSpecRequestBody is used to define fields on request body types. +type ServiceSpecRequestBody struct { + // The unique identifier for this service. + ServiceID *string `form:"service_id,omitempty" json:"service_id,omitempty" xml:"service_id,omitempty"` + // The type of service to run. + ServiceType *string `form:"service_type,omitempty" json:"service_type,omitempty" xml:"service_type,omitempty"` + // The version of the service in semver format (e.g., '1.0.0') or the literal + // 'latest'. + Version *string `form:"version,omitempty" json:"version,omitempty" xml:"version,omitempty"` + // The IDs of the hosts that should run this service. One service instance will + // be created per host. + HostIds []string `form:"host_ids,omitempty" json:"host_ids,omitempty" xml:"host_ids,omitempty"` + // The port to publish the service on the host. If 0, Docker assigns a random + // port. If unspecified, no port is published and the service is not accessible + // from outside the Docker network. + Port *int `form:"port,omitempty" json:"port,omitempty" xml:"port,omitempty"` + // Service-specific configuration. For MCP services, this includes + // llm_provider, llm_model, and provider-specific API keys. + Config map[string]any `form:"config,omitempty" json:"config,omitempty" xml:"config,omitempty"` + // The number of CPUs to allocate for this service. It can include the SI + // suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if + // unspecified. + Cpus *string `form:"cpus,omitempty" json:"cpus,omitempty" xml:"cpus,omitempty"` + // The amount of memory in SI or IEC notation to allocate for this service. + // Defaults to container defaults if unspecified. + Memory *string `form:"memory,omitempty" json:"memory,omitempty" xml:"memory,omitempty"` +} + // DatabaseSpecRequestBodyRequestBody is used to define fields on request body // types. type DatabaseSpecRequestBodyRequestBody struct { @@ -2416,6 +2552,8 @@ type DatabaseSpecRequestBodyRequestBody struct { Nodes []*DatabaseNodeSpecRequestBodyRequestBody `form:"nodes,omitempty" json:"nodes,omitempty" xml:"nodes,omitempty"` // The users to create for this database. DatabaseUsers []*DatabaseUserSpecRequestBodyRequestBody `form:"database_users,omitempty" json:"database_users,omitempty" xml:"database_users,omitempty"` + // Service instances to run alongside the database (e.g., MCP servers). + Services []*ServiceSpecRequestBodyRequestBody `form:"services,omitempty" json:"services,omitempty" xml:"services,omitempty"` // The backup configuration for this database. BackupConfig *BackupConfigSpecRequestBodyRequestBody `form:"backup_config,omitempty" json:"backup_config,omitempty" xml:"backup_config,omitempty"` // The restore configuration for this database. @@ -2665,6 +2803,35 @@ type DatabaseUserSpecRequestBodyRequestBody struct { Roles []string `form:"roles,omitempty" json:"roles,omitempty" xml:"roles,omitempty"` } +// ServiceSpecRequestBodyRequestBody is used to define fields on request body +// types. +type ServiceSpecRequestBodyRequestBody struct { + // The unique identifier for this service. + ServiceID *string `form:"service_id,omitempty" json:"service_id,omitempty" xml:"service_id,omitempty"` + // The type of service to run. + ServiceType *string `form:"service_type,omitempty" json:"service_type,omitempty" xml:"service_type,omitempty"` + // The version of the service in semver format (e.g., '1.0.0') or the literal + // 'latest'. + Version *string `form:"version,omitempty" json:"version,omitempty" xml:"version,omitempty"` + // The IDs of the hosts that should run this service. One service instance will + // be created per host. + HostIds []string `form:"host_ids,omitempty" json:"host_ids,omitempty" xml:"host_ids,omitempty"` + // The port to publish the service on the host. If 0, Docker assigns a random + // port. If unspecified, no port is published and the service is not accessible + // from outside the Docker network. + Port *int `form:"port,omitempty" json:"port,omitempty" xml:"port,omitempty"` + // Service-specific configuration. For MCP services, this includes + // llm_provider, llm_model, and provider-specific API keys. + Config map[string]any `form:"config,omitempty" json:"config,omitempty" xml:"config,omitempty"` + // The number of CPUs to allocate for this service. It can include the SI + // suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if + // unspecified. + Cpus *string `form:"cpus,omitempty" json:"cpus,omitempty" xml:"cpus,omitempty"` + // The amount of memory in SI or IEC notation to allocate for this service. + // Defaults to container defaults if unspecified. + Memory *string `form:"memory,omitempty" json:"memory,omitempty" xml:"memory,omitempty"` +} + // NewInitClusterResponseBody builds the HTTP response body from the result of // the "init-cluster" endpoint of the "control-plane" service. func NewInitClusterResponseBody(res *controlplane.ClusterJoinToken) *InitClusterResponseBody { @@ -2850,6 +3017,20 @@ func NewGetDatabaseResponseBody(res *controlplaneviews.DatabaseView) *GetDatabas } body.Instances[i] = marshalControlplaneviewsInstanceViewToInstanceResponseBody(val) } + } else { + body.Instances = []*InstanceResponseBody{} + } + if res.ServiceInstances != nil { + body.ServiceInstances = make([]*ServiceinstanceResponseBody, len(res.ServiceInstances)) + for i, val := range res.ServiceInstances { + if val == nil { + body.ServiceInstances[i] = nil + continue + } + body.ServiceInstances[i] = marshalControlplaneviewsServiceinstanceViewToServiceinstanceResponseBody(val) + } + } else { + body.ServiceInstances = []*ServiceinstanceResponseBody{} } if res.Spec != nil { body.Spec = marshalControlplaneviewsDatabaseSpecViewToDatabaseSpecResponseBody(res.Spec) @@ -4909,6 +5090,13 @@ func ValidateDatabaseSpecRequestBody(body *DatabaseSpecRequestBody) (err error) } } } + for _, e := range body.Services { + if e != nil { + if err2 := ValidateServiceSpecRequestBody(e); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } if body.BackupConfig != nil { if err2 := ValidateBackupConfigSpecRequestBody(body.BackupConfig); err2 != nil { err = goa.MergeErrors(err, err2) @@ -5469,6 +5657,74 @@ func ValidateDatabaseUserSpecRequestBody(body *DatabaseUserSpecRequestBody) (err return } +// ValidateServiceSpecRequestBody runs the validations defined on +// ServiceSpecRequestBody +func ValidateServiceSpecRequestBody(body *ServiceSpecRequestBody) (err error) { + if body.ServiceID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("service_id", "body")) + } + if body.ServiceType == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("service_type", "body")) + } + if body.Version == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("version", "body")) + } + if body.HostIds == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("host_ids", "body")) + } + if body.Config == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("config", "body")) + } + if body.ServiceID != nil { + if utf8.RuneCountInString(*body.ServiceID) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.service_id", *body.ServiceID, utf8.RuneCountInString(*body.ServiceID), 1, true)) + } + } + if body.ServiceID != nil { + if utf8.RuneCountInString(*body.ServiceID) > 63 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.service_id", *body.ServiceID, utf8.RuneCountInString(*body.ServiceID), 63, false)) + } + } + if body.ServiceType != nil { + if !(*body.ServiceType == "mcp") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("body.service_type", *body.ServiceType, []any{"mcp"})) + } + } + if body.Version != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.version", *body.Version, "^(\\d+\\.\\d+\\.\\d+|latest)$")) + } + if len(body.HostIds) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids", body.HostIds, len(body.HostIds), 1, true)) + } + for _, e := range body.HostIds { + if utf8.RuneCountInString(e) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids[*]", e, utf8.RuneCountInString(e), 1, true)) + } + if utf8.RuneCountInString(e) > 63 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids[*]", e, utf8.RuneCountInString(e), 63, false)) + } + } + if body.Port != nil { + if *body.Port < 0 { + err = goa.MergeErrors(err, goa.InvalidRangeError("body.port", *body.Port, 0, true)) + } + } + if body.Port != nil { + if *body.Port > 65535 { + err = goa.MergeErrors(err, goa.InvalidRangeError("body.port", *body.Port, 65535, false)) + } + } + if body.Cpus != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.cpus", *body.Cpus, "^[0-9]+(\\.[0-9]{1,3}|m)?$")) + } + if body.Memory != nil { + if utf8.RuneCountInString(*body.Memory) > 16 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.memory", *body.Memory, utf8.RuneCountInString(*body.Memory), 16, false)) + } + } + return +} + // ValidateDatabaseSpecRequestBodyRequestBody runs the validations defined on // DatabaseSpecRequestBodyRequestBody func ValidateDatabaseSpecRequestBodyRequestBody(body *DatabaseSpecRequestBodyRequestBody) (err error) { @@ -5535,6 +5791,13 @@ func ValidateDatabaseSpecRequestBodyRequestBody(body *DatabaseSpecRequestBodyReq } } } + for _, e := range body.Services { + if e != nil { + if err2 := ValidateServiceSpecRequestBodyRequestBody(e); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } if body.BackupConfig != nil { if err2 := ValidateBackupConfigSpecRequestBodyRequestBody(body.BackupConfig); err2 != nil { err = goa.MergeErrors(err, err2) @@ -6094,3 +6357,71 @@ func ValidateDatabaseUserSpecRequestBodyRequestBody(body *DatabaseUserSpecReques } return } + +// ValidateServiceSpecRequestBodyRequestBody runs the validations defined on +// ServiceSpecRequestBodyRequestBody +func ValidateServiceSpecRequestBodyRequestBody(body *ServiceSpecRequestBodyRequestBody) (err error) { + if body.ServiceID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("service_id", "body")) + } + if body.ServiceType == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("service_type", "body")) + } + if body.Version == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("version", "body")) + } + if body.HostIds == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("host_ids", "body")) + } + if body.Config == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("config", "body")) + } + if body.ServiceID != nil { + if utf8.RuneCountInString(*body.ServiceID) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.service_id", *body.ServiceID, utf8.RuneCountInString(*body.ServiceID), 1, true)) + } + } + if body.ServiceID != nil { + if utf8.RuneCountInString(*body.ServiceID) > 63 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.service_id", *body.ServiceID, utf8.RuneCountInString(*body.ServiceID), 63, false)) + } + } + if body.ServiceType != nil { + if !(*body.ServiceType == "mcp") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("body.service_type", *body.ServiceType, []any{"mcp"})) + } + } + if body.Version != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.version", *body.Version, "^(\\d+\\.\\d+\\.\\d+|latest)$")) + } + if len(body.HostIds) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids", body.HostIds, len(body.HostIds), 1, true)) + } + for _, e := range body.HostIds { + if utf8.RuneCountInString(e) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids[*]", e, utf8.RuneCountInString(e), 1, true)) + } + if utf8.RuneCountInString(e) > 63 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.host_ids[*]", e, utf8.RuneCountInString(e), 63, false)) + } + } + if body.Port != nil { + if *body.Port < 0 { + err = goa.MergeErrors(err, goa.InvalidRangeError("body.port", *body.Port, 0, true)) + } + } + if body.Port != nil { + if *body.Port > 65535 { + err = goa.MergeErrors(err, goa.InvalidRangeError("body.port", *body.Port, 65535, false)) + } + } + if body.Cpus != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.cpus", *body.Cpus, "^[0-9]+(\\.[0-9]{1,3}|m)?$")) + } + if body.Memory != nil { + if utf8.RuneCountInString(*body.Memory) > 16 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.memory", *body.Memory, utf8.RuneCountInString(*body.Memory), 16, false)) + } + } + return +} diff --git a/api/apiv1/gen/http/openapi.json b/api/apiv1/gen/http/openapi.json index 78294ba7..446862cc 100644 --- a/api/apiv1/gen/http/openapi.json +++ b/api/apiv1/gen/http/openapi.json @@ -2273,6 +2273,29 @@ "s3_region": "us-east-1", "type": "s3" }, + { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -2406,7 +2429,7 @@ }, "additionalProperties": { "type": "string", - "example": "Rem culpa." + "example": "Eos aut dignissimos cum voluptate modi consequatur." } }, "backup_options": { @@ -2417,7 +2440,7 @@ }, "additionalProperties": { "type": "string", - "example": "Minus dolore eos et sunt culpa animi." + "example": "At esse ut possimus error eligendi." } }, "type": { @@ -2490,7 +2513,7 @@ }, "additionalProperties": { "type": "string", - "example": "Et et reprehenderit et nam quo." + "example": "Ab sed exercitationem rerum animi et itaque." } }, "gcs_bucket": { @@ -2959,7 +2982,7 @@ } }, "example": { - "state": "available" + "state": "error" }, "required": [ "state" @@ -3023,13 +3046,16 @@ "instances": { "$ref": "#/definitions/InstanceResponseBodyCollection" }, + "service_instances": { + "$ref": "#/definitions/ServiceinstanceResponseBodyCollection" + }, "spec": { "$ref": "#/definitions/DatabaseSpec" }, "state": { "type": "string", "description": "Current state of the database.", - "example": "restoring", + "example": "deleting", "enum": [ "creating", "modifying", @@ -3210,7 +3236,9 @@ "id", "created_at", "updated_at", - "state" + "state", + "instances", + "service_instances" ] }, "ControlPlaneSwitchoverDatabaseNodeRequestBody": { @@ -3388,13 +3416,16 @@ "instances": { "$ref": "#/definitions/InstanceResponseBodyCollection" }, + "service_instances": { + "$ref": "#/definitions/ServiceinstanceResponseBodyCollection" + }, "spec": { "$ref": "#/definitions/DatabaseSpec" }, "state": { "type": "string", "description": "Current state of the database.", - "example": "degraded", + "example": "deleting", "enum": [ "creating", "modifying", @@ -3575,7 +3606,9 @@ "id", "created_at", "updated_at", - "state" + "state", + "instances", + "service_instances" ] }, "DatabaseNodeSpec": { @@ -3601,7 +3634,6 @@ }, "description": "The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas.", "example": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696" ], "minItems": 1 @@ -3655,29 +3687,6 @@ "example": { "backup_config": { "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -3722,6 +3731,7 @@ }, "cpus": "500m", "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696" ], @@ -3846,7 +3856,7 @@ "state": { "type": "string", "description": "Current state of the database.", - "example": "deleting", + "example": "unknown", "enum": [ "creating", "modifying", @@ -3882,28 +3892,28 @@ "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" } ], - "state": "available", + "state": "creating", "tenant_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", "updated_at": "2025-01-01T02:30:00Z" }, @@ -3911,7 +3921,8 @@ "id", "created_at", "updated_at", - "state" + "state", + "instances" ] }, "DatabaseResponseBodyAbbreviatedCollection": { @@ -3930,28 +3941,61 @@ "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" + }, + { + "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", + "node_name": "n1", + "state": "available" + }, + { + "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", + "node_name": "n1", + "state": "available" + }, + { + "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", + "node_name": "n1", + "state": "available" + } + ], + "state": "failed", + "tenant_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "updated_at": "2025-01-01T02:30:00Z" + }, + { + "created_at": "2025-01-01T01:30:00Z", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "instances": [ + { + "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", + "node_name": "n1", + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" } ], - "state": "unknown", + "state": "failed", "tenant_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", "updated_at": "2025-01-01T02:30:00Z" }, @@ -3963,28 +4007,28 @@ "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" } ], - "state": "unknown", + "state": "failed", "tenant_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", "updated_at": "2025-01-01T02:30:00Z" }, @@ -3996,28 +4040,28 @@ "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" }, { "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "degraded" + "state": "available" } ], - "state": "unknown", + "state": "failed", "tenant_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", "updated_at": "2025-01-01T02:30:00Z" } @@ -4108,29 +4152,6 @@ { "backup_config": { "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -4175,7 +4196,6 @@ }, "cpus": "500m", "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696" ], @@ -4275,29 +4295,6 @@ { "backup_config": { "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -4342,7 +4339,6 @@ }, "cpus": "500m", "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696" ], @@ -4438,206 +4434,82 @@ "source_node_name": "n1" }, "source_node": "n1" - }, + } + ], + "minItems": 1, + "maxItems": 9 + }, + "orchestrator_opts": { + "$ref": "#/definitions/OrchestratorOpts" + }, + "port": { + "type": "integer", + "description": "The port used by the Postgres database. If the port is 0, each instance will be assigned a random port. If the port is unspecified, the database will not be exposed on any port, dependent on orchestrator support for that feature.", + "example": 5432, + "format": "int64", + "minimum": 0, + "maximum": 65535 + }, + "postgres_version": { + "type": "string", + "description": "The Postgres version in 'major.minor' format.", + "example": "17.6", + "pattern": "^\\d{2}\\.\\d{1,2}$" + }, + "postgresql_conf": { + "type": "object", + "description": "Additional postgresql.conf settings. Will be merged with the settings provided by control-plane.", + "example": { + "max_connections": 1000 + }, + "maxLength": 64, + "additionalProperties": true + }, + "restore_config": { + "$ref": "#/definitions/RestoreConfigSpec" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/definitions/ServiceSpec" + }, + "description": "Service instances to run alongside the database (e.g., MCP servers).", + "example": [ { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." }, "cpus": "500m", "host_ids": [ "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696" ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" - }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" } - ], - "minItems": 1, - "maxItems": 9 - }, - "orchestrator_opts": { - "$ref": "#/definitions/OrchestratorOpts" - }, - "port": { - "type": "integer", - "description": "The port used by the Postgres database. If the port is 0, each instance will be assigned a random port. If the port is unspecified, the database will not be exposed on any port, dependent on orchestrator support for that feature.", - "example": 5432, - "format": "int64", - "minimum": 0, - "maximum": 65535 - }, - "postgres_version": { - "type": "string", - "description": "The Postgres version in 'major.minor' format.", - "example": "17.6", - "pattern": "^\\d{2}\\.\\d{1,2}$" - }, - "postgresql_conf": { - "type": "object", - "description": "Additional postgresql.conf settings. Will be merged with the settings provided by control-plane.", - "example": { - "max_connections": 1000 - }, - "maxLength": 64, - "additionalProperties": true - }, - "restore_config": { - "$ref": "#/definitions/RestoreConfigSpec" + ] }, "spock_version": { "type": "string", @@ -4649,29 +4521,6 @@ "example": { "backup_config": { "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -4762,196 +4611,6 @@ { "backup_config": { "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", - "76f9b8c0-4958-11f0-a489-3bb29577c696", - "76f9b8c0-4958-11f0-a489-3bb29577c696" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" - }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" - }, - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -4996,7 +4655,6 @@ }, "cpus": "500m", "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696" ], @@ -5096,29 +4754,6 @@ { "backup_config": { "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -5163,7 +4798,6 @@ }, "cpus": "500m", "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696" ], @@ -5350,6 +4984,42 @@ "source_database_name": "northwind", "source_node_name": "n1" }, + "services": [ + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + } + ], "spock_version": "5" }, "required": [ @@ -5365,7 +5035,7 @@ "type": "array", "items": { "type": "string", - "example": "Et et eius at praesentium ut dolorem." + "example": "Alias ipsa qui ut et." }, "description": "The attributes to assign to this database user.", "example": [ @@ -5378,7 +5048,7 @@ "db_owner": { "type": "boolean", "description": "If true, this user will be granted database ownership.", - "example": false + "example": true }, "password": { "type": "string", @@ -5390,7 +5060,7 @@ "type": "array", "items": { "type": "string", - "example": "Placeat illo dolore totam accusamus alias ipsa." + "example": "Aut doloribus rem culpa ullam minus dolore." }, "description": "The roles to assign to this database user.", "example": [ @@ -5451,7 +5121,7 @@ "type": "array", "items": { "type": "string", - "example": "Placeat a." + "example": "Dolor autem eum." }, "description": "Optional network-scoped aliases for the container.", "example": [ @@ -5468,7 +5138,7 @@ }, "additionalProperties": { "type": "string", - "example": "Sit voluptates maxime." + "example": "Et eius." } }, "id": { @@ -5537,7 +5207,7 @@ }, "example": { "candidate_instance_id": "68f50878-44d2-4524-a823-e31bd478706d-n1-689qacsi", - "skip_validation": true + "skip_validation": false } }, "FailoverDatabaseNodeResponse": { @@ -5561,6 +5231,43 @@ "task" ] }, + "HealthCheckResult": { + "title": "HealthCheckResult", + "type": "object", + "properties": { + "checked_at": { + "type": "string", + "description": "The time this health check was performed.", + "example": "2025-01-28T10:00:00Z", + "format": "date-time" + }, + "message": { + "type": "string", + "description": "Optional message about the health status.", + "example": "Connection refused" + }, + "status": { + "type": "string", + "description": "The health status.", + "example": "healthy", + "enum": [ + "healthy", + "unhealthy", + "unknown" + ] + } + }, + "description": "Health check result for a service instance.", + "example": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "required": [ + "status", + "checked_at" + ] + }, "Host": { "title": "Host", "type": "object", @@ -5629,6 +5336,14 @@ }, "description": "The PgEdge versions supported by this host.", "example": [ + { + "postgres_version": "17.6", + "spock_version": "5" + }, + { + "postgres_version": "17.6", + "spock_version": "5" + }, { "postgres_version": "17.6", "spock_version": "5" @@ -5660,7 +5375,16 @@ "orchestrator": "swarm", "status": { "components": { - "Praesentium repellendus et et harum cum.": { + "Cum possimus minima.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Et et.": { "details": { "alarms": [ "3: NOSPACE" @@ -5669,7 +5393,7 @@ "error": "failed to connect to etcd", "healthy": false }, - "Ullam autem praesentium est.": { + "Pariatur praesentium.": { "details": { "alarms": [ "3: NOSPACE" @@ -5741,7 +5465,7 @@ "type": "object", "description": "The status of each component of the host.", "example": { - "Earum est ea ratione nobis beatae provident.": { + "Ipsa sunt est dolor blanditiis.": { "details": { "alarms": [ "3: NOSPACE" @@ -5750,7 +5474,16 @@ "error": "failed to connect to etcd", "healthy": false }, - "Et qui sed illum ad culpa dolor.": { + "Omnis sit est.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Qui sed illum ad culpa dolor.": { "details": { "alarms": [ "3: NOSPACE" @@ -5783,7 +5516,7 @@ }, "example": { "components": { - "Sunt est dolor.": { + "Laborum corporis ducimus itaque vel ea eaque.": { "details": { "alarms": [ "3: NOSPACE" @@ -5847,7 +5580,7 @@ "pending_restart": { "type": "boolean", "description": "True if this instance has a pending restart from a configuration change.", - "example": true + "example": false }, "role": { "type": "string", @@ -5863,7 +5596,7 @@ "example": { "patroni_paused": false, "patroni_state": "unknown", - "pending_restart": true, + "pending_restart": false, "role": "primary", "version": "18.1" } @@ -5878,7 +5611,7 @@ "created_at": { "type": "string", "description": "The time that the instance was created.", - "example": "1974-01-28T00:40:13Z", + "example": "1997-10-16T04:36:33Z", "format": "date-time" }, "error": { @@ -5924,13 +5657,13 @@ "status_updated_at": { "type": "string", "description": "The time that the instance status information was last updated.", - "example": "1995-06-22T12:36:38Z", + "example": "1993-01-27T22:30:34Z", "format": "date-time" }, "updated_at": { "type": "string", "description": "The time that the instance was last modified.", - "example": "1987-04-15T07:30:36Z", + "example": "2014-01-05T11:54:57Z", "format": "date-time" } }, @@ -5941,7 +5674,7 @@ "ipv4_address": "10.24.34.2", "port": 5432 }, - "created_at": "2014-10-30T11:01:40Z", + "created_at": "1970-12-08T13:31:37Z", "error": "failed to get patroni status: connection refused", "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", @@ -5949,13 +5682,23 @@ "postgres": { "patroni_paused": false, "patroni_state": "unknown", - "pending_restart": false, + "pending_restart": true, "role": "primary", "version": "18.1" }, "spock": { "read_only": "off", "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, { "name": "sub_n1n2", "provider_node": "n2", @@ -5970,8 +5713,8 @@ "version": "4.10.0" }, "state": "modifying", - "status_updated_at": "2015-08-04T19:13:16Z", - "updated_at": "2002-12-21T03:54:59Z" + "status_updated_at": "1987-12-06T22:00:59Z", + "updated_at": "1978-01-28T05:17:31Z" }, "required": [ "id", @@ -6003,7 +5746,7 @@ }, "state": { "type": "string", - "example": "unknown", + "example": "available", "enum": [ "creating", "modifying", @@ -6021,7 +5764,7 @@ "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", "node_name": "n1", - "state": "stopped" + "state": "modifying" }, "required": [ "id", @@ -6044,7 +5787,7 @@ "ipv4_address": "10.24.34.2", "port": 5432 }, - "created_at": "1972-02-12T09:45:07Z", + "created_at": "1995-05-15T08:45:56Z", "error": "failed to get patroni status: connection refused", "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", @@ -6052,13 +5795,23 @@ "postgres": { "patroni_paused": false, "patroni_state": "unknown", - "pending_restart": false, + "pending_restart": true, "role": "primary", "version": "18.1" }, "spock": { "read_only": "off", "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, { "name": "sub_n1n2", "provider_node": "n2", @@ -6072,9 +5825,9 @@ ], "version": "4.10.0" }, - "state": "stopped", - "status_updated_at": "1989-09-01T22:57:29Z", - "updated_at": "1978-08-28T00:21:42Z" + "state": "modifying", + "status_updated_at": "2010-12-24T23:39:10Z", + "updated_at": "2014-01-25T23:48:24Z" }, { "connection_info": { @@ -6082,7 +5835,7 @@ "ipv4_address": "10.24.34.2", "port": 5432 }, - "created_at": "1972-02-12T09:45:07Z", + "created_at": "1995-05-15T08:45:56Z", "error": "failed to get patroni status: connection refused", "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", @@ -6090,13 +5843,23 @@ "postgres": { "patroni_paused": false, "patroni_state": "unknown", - "pending_restart": false, + "pending_restart": true, "role": "primary", "version": "18.1" }, "spock": { "read_only": "off", "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, { "name": "sub_n1n2", "provider_node": "n2", @@ -6110,27 +5873,17 @@ ], "version": "4.10.0" }, - "state": "stopped", - "status_updated_at": "1989-09-01T22:57:29Z", - "updated_at": "1978-08-28T00:21:42Z" - } - ] - }, - "InstanceResponseBodyCollection": { - "title": "Mediatype identifier: instance; type=collection; view=default", - "type": "array", - "items": { - "$ref": "#/definitions/InstanceResponseBody" - }, - "description": "InstanceCollectionResponseBody is the result type for an array of InstanceResponseBody (default view)", - "example": [ + "state": "modifying", + "status_updated_at": "2010-12-24T23:39:10Z", + "updated_at": "2014-01-25T23:48:24Z" + }, { "connection_info": { "hostname": "i-0123456789abcdef.ec2.internal", "ipv4_address": "10.24.34.2", "port": 5432 }, - "created_at": "1972-02-12T09:45:07Z", + "created_at": "1995-05-15T08:45:56Z", "error": "failed to get patroni status: connection refused", "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", @@ -6138,13 +5891,23 @@ "postgres": { "patroni_paused": false, "patroni_state": "unknown", - "pending_restart": false, + "pending_restart": true, "role": "primary", "version": "18.1" }, "spock": { "read_only": "off", "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, { "name": "sub_n1n2", "provider_node": "n2", @@ -6158,17 +5921,27 @@ ], "version": "4.10.0" }, - "state": "stopped", - "status_updated_at": "1989-09-01T22:57:29Z", - "updated_at": "1978-08-28T00:21:42Z" - }, + "state": "modifying", + "status_updated_at": "2010-12-24T23:39:10Z", + "updated_at": "2014-01-25T23:48:24Z" + } + ] + }, + "InstanceResponseBodyCollection": { + "title": "Mediatype identifier: instance; type=collection; view=default", + "type": "array", + "items": { + "$ref": "#/definitions/InstanceResponseBody" + }, + "description": "InstanceCollectionResponseBody is the result type for an array of InstanceResponseBody (default view)", + "example": [ { "connection_info": { "hostname": "i-0123456789abcdef.ec2.internal", "ipv4_address": "10.24.34.2", "port": 5432 }, - "created_at": "1972-02-12T09:45:07Z", + "created_at": "1995-05-15T08:45:56Z", "error": "failed to get patroni status: connection refused", "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", @@ -6176,13 +5949,23 @@ "postgres": { "patroni_paused": false, "patroni_state": "unknown", - "pending_restart": false, + "pending_restart": true, "role": "primary", "version": "18.1" }, "spock": { "read_only": "off", "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, { "name": "sub_n1n2", "provider_node": "n2", @@ -6196,9 +5979,9 @@ ], "version": "4.10.0" }, - "state": "stopped", - "status_updated_at": "1989-09-01T22:57:29Z", - "updated_at": "1978-08-28T00:21:42Z" + "state": "modifying", + "status_updated_at": "2010-12-24T23:39:10Z", + "updated_at": "2014-01-25T23:48:24Z" }, { "connection_info": { @@ -6206,7 +5989,7 @@ "ipv4_address": "10.24.34.2", "port": 5432 }, - "created_at": "1972-02-12T09:45:07Z", + "created_at": "1995-05-15T08:45:56Z", "error": "failed to get patroni status: connection refused", "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", @@ -6214,13 +5997,23 @@ "postgres": { "patroni_paused": false, "patroni_state": "unknown", - "pending_restart": false, + "pending_restart": true, "role": "primary", "version": "18.1" }, "spock": { "read_only": "off", "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, { "name": "sub_n1n2", "provider_node": "n2", @@ -6234,9 +6027,9 @@ ], "version": "4.10.0" }, - "state": "stopped", - "status_updated_at": "1989-09-01T22:57:29Z", - "updated_at": "1978-08-28T00:21:42Z" + "state": "modifying", + "status_updated_at": "2010-12-24T23:39:10Z", + "updated_at": "2014-01-25T23:48:24Z" }, { "connection_info": { @@ -6244,7 +6037,7 @@ "ipv4_address": "10.24.34.2", "port": 5432 }, - "created_at": "1972-02-12T09:45:07Z", + "created_at": "1995-05-15T08:45:56Z", "error": "failed to get patroni status: connection refused", "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", @@ -6252,13 +6045,23 @@ "postgres": { "patroni_paused": false, "patroni_state": "unknown", - "pending_restart": false, + "pending_restart": true, "role": "primary", "version": "18.1" }, "spock": { "read_only": "off", "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, { "name": "sub_n1n2", "provider_node": "n2", @@ -6272,9 +6075,9 @@ ], "version": "4.10.0" }, - "state": "stopped", - "status_updated_at": "1989-09-01T22:57:29Z", - "updated_at": "1978-08-28T00:21:42Z" + "state": "modifying", + "status_updated_at": "2010-12-24T23:39:10Z", + "updated_at": "2014-01-25T23:48:24Z" } ] }, @@ -6304,6 +6107,11 @@ "provider_node": "n2", "status": "down" }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, { "name": "sub_n1n2", "provider_node": "n2", @@ -6393,6 +6201,16 @@ "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", "type": "create" }, + { + "completed_at": "2025-06-18T16:52:35Z", + "created_at": "2025-06-18T16:52:05Z", + "database_id": "storefront", + "entity_id": "storefront", + "scope": "database", + "status": "completed", + "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", + "type": "create" + }, { "completed_at": "2025-06-18T16:52:35Z", "created_at": "2025-06-18T16:52:05Z", @@ -6462,6 +6280,26 @@ "$ref": "#/definitions/Task" }, "example": [ + { + "completed_at": "2025-06-18T16:52:35Z", + "created_at": "2025-06-18T16:52:05Z", + "database_id": "storefront", + "entity_id": "storefront", + "scope": "database", + "status": "completed", + "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", + "type": "create" + }, + { + "completed_at": "2025-06-18T16:52:35Z", + "created_at": "2025-06-18T16:52:05Z", + "database_id": "storefront", + "entity_id": "storefront", + "scope": "database", + "status": "completed", + "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", + "type": "create" + }, { "completed_at": "2025-06-18T16:52:35Z", "created_at": "2025-06-18T16:52:05Z", @@ -6531,7 +6369,156 @@ "orchestrator": "swarm", "status": { "components": { - "Praesentium repellendus et et harum cum.": { + "Cum possimus minima.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Et et.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Pariatur praesentium.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + } + }, + "state": "available", + "updated_at": "2021-07-01T12:34:56Z" + }, + "supported_pgedge_versions": [ + { + "postgres_version": "17.6", + "spock_version": "5" + }, + { + "postgres_version": "17.6", + "spock_version": "5" + }, + { + "postgres_version": "17.6", + "spock_version": "5" + }, + { + "postgres_version": "17.6", + "spock_version": "5" + } + ] + }, + { + "cohort": { + "control_available": true, + "member_id": "lah4bsznw6kc0hp7biylmmmll", + "type": "swarm" + }, + "cpus": 4, + "data_dir": "/data", + "default_pgedge_version": { + "postgres_version": "17.6", + "spock_version": "5" + }, + "etcd_mode": "server", + "hostname": "i-0123456789abcdef.ec2.internal", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "ipv4_address": "10.24.34.2", + "memory": "16GiB", + "orchestrator": "swarm", + "status": { + "components": { + "Cum possimus minima.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Et et.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Pariatur praesentium.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + } + }, + "state": "available", + "updated_at": "2021-07-01T12:34:56Z" + }, + "supported_pgedge_versions": [ + { + "postgres_version": "17.6", + "spock_version": "5" + }, + { + "postgres_version": "17.6", + "spock_version": "5" + }, + { + "postgres_version": "17.6", + "spock_version": "5" + }, + { + "postgres_version": "17.6", + "spock_version": "5" + } + ] + }, + { + "cohort": { + "control_available": true, + "member_id": "lah4bsznw6kc0hp7biylmmmll", + "type": "swarm" + }, + "cpus": 4, + "data_dir": "/data", + "default_pgedge_version": { + "postgres_version": "17.6", + "spock_version": "5" + }, + "etcd_mode": "server", + "hostname": "i-0123456789abcdef.ec2.internal", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "ipv4_address": "10.24.34.2", + "memory": "16GiB", + "orchestrator": "swarm", + "status": { + "components": { + "Cum possimus minima.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Et et.": { "details": { "alarms": [ "3: NOSPACE" @@ -6540,7 +6527,7 @@ "error": "failed to connect to etcd", "healthy": false }, - "Ullam autem praesentium est.": { + "Pariatur praesentium.": { "details": { "alarms": [ "3: NOSPACE" @@ -6592,7 +6579,7 @@ "orchestrator": "swarm", "status": { "components": { - "Praesentium repellendus et et harum cum.": { + "Cum possimus minima.": { "details": { "alarms": [ "3: NOSPACE" @@ -6601,7 +6588,16 @@ "error": "failed to connect to etcd", "healthy": false }, - "Ullam autem praesentium est.": { + "Et et.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Pariatur praesentium.": { "details": { "alarms": [ "3: NOSPACE" @@ -6771,26 +6767,6 @@ "$ref": "#/definitions/Task" }, "example": [ - { - "completed_at": "2025-06-18T16:52:35Z", - "created_at": "2025-06-18T16:52:05Z", - "database_id": "storefront", - "entity_id": "storefront", - "scope": "database", - "status": "completed", - "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", - "type": "create" - }, - { - "completed_at": "2025-06-18T16:52:35Z", - "created_at": "2025-06-18T16:52:05Z", - "database_id": "storefront", - "entity_id": "storefront", - "scope": "database", - "status": "completed", - "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", - "type": "create" - }, { "completed_at": "2025-06-18T16:52:35Z", "created_at": "2025-06-18T16:52:05Z", @@ -6979,19 +6955,56 @@ "description": "The Postgres major and minor version.", "example": "17.6" }, - "spock_version": { + "spock_version": { + "type": "string", + "description": "The Spock major version.", + "example": "5" + } + }, + "example": { + "postgres_version": "17.6", + "spock_version": "5" + }, + "required": [ + "postgres_version", + "spock_version" + ] + }, + "PortMapping": { + "title": "PortMapping", + "type": "object", + "properties": { + "container_port": { + "type": "integer", + "description": "The port number inside the container.", + "example": 8080, + "format": "int64", + "minimum": 1, + "maximum": 65535 + }, + "host_port": { + "type": "integer", + "description": "The port number on the host (if port-forwarded).", + "example": 8080, + "format": "int64", + "minimum": 1, + "maximum": 65535 + }, + "name": { "type": "string", - "description": "The Spock major version.", - "example": "5" + "description": "The name of the port (e.g., 'http', 'web-client').", + "example": "web-client" } }, + "description": "Port mapping information for a service instance.", "example": { - "postgres_version": "17.6", - "spock_version": "5" + "container_port": 8080, + "host_port": 8080, + "name": "web-client" }, "required": [ - "postgres_version", - "spock_version" + "name", + "container_port" ] }, "RemoveHostResponse": { @@ -7028,6 +7041,16 @@ "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", "type": "create" }, + { + "completed_at": "2025-06-18T16:52:35Z", + "created_at": "2025-06-18T16:52:05Z", + "database_id": "storefront", + "entity_id": "storefront", + "scope": "database", + "status": "completed", + "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", + "type": "create" + }, { "completed_at": "2025-06-18T16:52:35Z", "created_at": "2025-06-18T16:52:05Z", @@ -7053,6 +7076,26 @@ "type": "create" }, "update_database_tasks": [ + { + "completed_at": "2025-06-18T16:52:35Z", + "created_at": "2025-06-18T16:52:05Z", + "database_id": "storefront", + "entity_id": "storefront", + "scope": "database", + "status": "completed", + "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", + "type": "create" + }, + { + "completed_at": "2025-06-18T16:52:35Z", + "created_at": "2025-06-18T16:52:05Z", + "database_id": "storefront", + "entity_id": "storefront", + "scope": "database", + "status": "completed", + "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", + "type": "create" + }, { "completed_at": "2025-06-18T16:52:35Z", "created_at": "2025-06-18T16:52:05Z", @@ -7121,7 +7164,7 @@ "maxLength": 32, "additionalProperties": { "type": "string", - "example": "Sed exercitationem rerum animi et." + "example": "Occaecati itaque." } }, "source_database_id": { @@ -7193,7 +7236,7 @@ "type": "array", "items": { "type": "string", - "example": "Cum voluptate modi consequatur non at esse." + "example": "Atque facilis non modi explicabo illum." }, "description": "The nodes to restore. Defaults to all nodes if empty or unspecified.", "example": [ @@ -7244,6 +7287,16 @@ "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", "type": "create" }, + { + "completed_at": "2025-06-18T16:52:35Z", + "created_at": "2025-06-18T16:52:05Z", + "database_id": "storefront", + "entity_id": "storefront", + "scope": "database", + "status": "completed", + "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", + "type": "create" + }, { "completed_at": "2025-06-18T16:52:35Z", "created_at": "2025-06-18T16:52:05Z", @@ -7498,7 +7551,7 @@ }, "additionalProperties": { "type": "string", - "example": "Exercitationem placeat temporibus aut facilis repudiandae." + "example": "Voluptates maxime hic aut omnis magnam." } }, "gcs_bucket": { @@ -7598,6 +7651,442 @@ "type" ] }, + "ServiceInstanceStatus": { + "title": "ServiceInstanceStatus", + "type": "object", + "properties": { + "container_id": { + "type": "string", + "description": "The Docker container ID.", + "example": "a1b2c3d4e5f6" + }, + "health_check": { + "$ref": "#/definitions/HealthCheckResult" + }, + "hostname": { + "type": "string", + "description": "The hostname of the service instance.", + "example": "mcp-server-host-1.internal" + }, + "image_version": { + "type": "string", + "description": "The container image version currently running.", + "example": "1.0.0" + }, + "ipv4_address": { + "type": "string", + "description": "The IPv4 address of the service instance.", + "example": "10.0.1.5", + "format": "ipv4" + }, + "last_health_at": { + "type": "string", + "description": "The time of the last health check attempt.", + "example": "2025-01-28T10:00:00Z", + "format": "date-time" + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/definitions/PortMapping" + }, + "description": "Port mappings for this service instance.", + "example": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ] + }, + "service_ready": { + "type": "boolean", + "description": "Whether the service is ready to accept requests.", + "example": true + } + }, + "description": "Runtime status information for a service instance.", + "example": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + } + }, + "ServiceSpec": { + "title": "ServiceSpec", + "type": "object", + "properties": { + "config": { + "type": "object", + "description": "Service-specific configuration. For MCP services, this includes llm_provider, llm_model, and provider-specific API keys.", + "example": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "additionalProperties": true + }, + "cpus": { + "type": "string", + "description": "The number of CPUs to allocate for this service. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if unspecified.", + "example": "500m", + "pattern": "^[0-9]+(\\.[0-9]{1,3}|m)?$" + }, + "host_ids": { + "type": "array", + "items": { + "type": "string", + "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "minLength": 1, + "maxLength": 63 + }, + "description": "The IDs of the hosts that should run this service. One service instance will be created per host.", + "example": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "minItems": 1 + }, + "memory": { + "type": "string", + "description": "The amount of memory in SI or IEC notation to allocate for this service. Defaults to container defaults if unspecified.", + "example": "512M", + "maxLength": 16 + }, + "port": { + "type": "integer", + "description": "The port to publish the service on the host. If 0, Docker assigns a random port. If unspecified, no port is published and the service is not accessible from outside the Docker network.", + "example": 0, + "format": "int64", + "minimum": 0, + "maximum": 65535 + }, + "service_id": { + "type": "string", + "description": "The unique identifier for this service.", + "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "minLength": 1, + "maxLength": 63 + }, + "service_type": { + "type": "string", + "description": "The type of service to run.", + "example": "mcp", + "enum": [ + "mcp" + ] + }, + "version": { + "type": "string", + "description": "The version of the service in semver format (e.g., '1.0.0') or the literal 'latest'.", + "example": "latest", + "pattern": "^(\\d+\\.\\d+\\.\\d+|latest)$" + } + }, + "example": { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + "required": [ + "service_id", + "service_type", + "version", + "host_ids", + "config" + ] + }, + "ServiceinstanceResponseBody": { + "title": "Mediatype identifier: serviceinstance; view=default", + "type": "object", + "properties": { + "created_at": { + "type": "string", + "description": "The time that the service instance was created.", + "example": "2025-01-28T10:00:00Z", + "format": "date-time" + }, + "database_id": { + "type": "string", + "description": "The ID of the database this service belongs to.", + "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "minLength": 1, + "maxLength": 63 + }, + "error": { + "type": "string", + "description": "An error message if the service instance is in an error state.", + "example": "failed to start container: image not found" + }, + "host_id": { + "type": "string", + "description": "The ID of the host this service instance is running on.", + "example": "host-1" + }, + "service_id": { + "type": "string", + "description": "The service ID from the DatabaseSpec.", + "example": "mcp-server" + }, + "service_instance_id": { + "type": "string", + "description": "Unique identifier for the service instance.", + "example": "mcp-server-host-1" + }, + "state": { + "type": "string", + "description": "Current state of the service instance.", + "example": "running", + "enum": [ + "creating", + "running", + "failed", + "deleting" + ] + }, + "status": { + "$ref": "#/definitions/ServiceInstanceStatus" + }, + "updated_at": { + "type": "string", + "description": "The time that the service instance was last updated.", + "example": "2025-01-28T10:05:00Z", + "format": "date-time" + } + }, + "description": "A service instance running on a host alongside the database. (default view)", + "example": { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + "required": [ + "service_instance_id", + "service_id", + "database_id", + "host_id", + "state", + "created_at", + "updated_at" + ] + }, + "ServiceinstanceResponseBodyCollection": { + "title": "Mediatype identifier: serviceinstance; type=collection; view=default", + "type": "array", + "items": { + "$ref": "#/definitions/ServiceinstanceResponseBody" + }, + "description": "ServiceinstanceCollectionResponseBody is the result type for an array of ServiceinstanceResponseBody (default view)", + "example": [ + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + } + ] + }, "StartInstanceResponse": { "title": "StartInstanceResponse", "type": "object", @@ -7657,7 +8146,7 @@ }, "additionalProperties": { "type": "string", - "example": "Omnis magnam aspernatur occaecati." + "example": "Ut dolorem." } }, "extra_networks": { diff --git a/api/apiv1/gen/http/openapi.yaml b/api/apiv1/gen/http/openapi.yaml index 903fdaf1..ff7bfda2 100644 --- a/api/apiv1/gen/http/openapi.yaml +++ b/api/apiv1/gen/http/openapi.yaml @@ -1613,6 +1613,26 @@ definitions: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 + - azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + storage-upload-chunk-size: 5MiB + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + retention_full: 2 + retention_full_type: count + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 minItems: 1 schedules: type: array @@ -1690,7 +1710,7 @@ definitions: key: value additionalProperties: type: string - example: Rem culpa. + example: Eos aut dignissimos cum voluptate modi consequatur. backup_options: type: object description: Options for the backup. @@ -1698,7 +1718,7 @@ definitions: archive-check: "n" additionalProperties: type: string - example: Minus dolore eos et sunt culpa animi. + example: At esse ut possimus error eligendi. type: type: string description: The type of backup. @@ -1755,7 +1775,7 @@ definitions: storage-upload-chunk-size: 5MiB additionalProperties: type: string - example: Et et reprehenderit et nam quo. + example: Ab sed exercitationem rerum animi et itaque. gcs_bucket: type: string description: The GCS bucket name for this repository. Only applies when type = 'gcs'. @@ -2103,7 +2123,7 @@ definitions: - available - error example: - state: available + state: error required: - state ComponentStatus: @@ -2150,12 +2170,14 @@ definitions: maxLength: 63 instances: $ref: '#/definitions/InstanceResponseBodyCollection' + service_instances: + $ref: '#/definitions/ServiceinstanceResponseBodyCollection' spec: $ref: '#/definitions/DatabaseSpec' state: type: string description: Current state of the database. - example: restoring + example: deleting enum: - creating - modifying @@ -2285,6 +2307,8 @@ definitions: - created_at - updated_at - state + - instances + - service_instances ControlPlaneSwitchoverDatabaseNodeRequestBody: title: ControlPlaneSwitchoverDatabaseNodeRequestBody type: object @@ -2406,12 +2430,14 @@ definitions: maxLength: 63 instances: $ref: '#/definitions/InstanceResponseBodyCollection' + service_instances: + $ref: '#/definitions/ServiceinstanceResponseBodyCollection' spec: $ref: '#/definitions/DatabaseSpec' state: type: string description: Current state of the database. - example: degraded + example: deleting enum: - creating - modifying @@ -2541,6 +2567,8 @@ definitions: - created_at - updated_at - state + - instances + - service_instances DatabaseNodeSpec: title: DatabaseNodeSpec type: object @@ -2562,7 +2590,6 @@ definitions: description: The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas. example: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 minItems: 1 memory: type: string @@ -2623,26 +2650,6 @@ definitions: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 schedules: - cron_expression: 0 6 * * ? id: daily-full-backup @@ -2657,6 +2664,7 @@ definitions: host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 orchestrator_opts: @@ -2744,7 +2752,7 @@ definitions: state: type: string description: Current state of the database. - example: deleting + example: unknown enum: - creating - modifying @@ -2774,20 +2782,20 @@ definitions: - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded - state: available + state: available + state: creating tenant_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 updated_at: "2025-01-01T02:30:00Z" required: @@ -2795,6 +2803,7 @@ definitions: - created_at - updated_at - state + - instances DatabaseResponseBodyAbbreviatedCollection: title: 'Mediatype identifier: database; type=collection; view=default' type: array @@ -2808,20 +2817,42 @@ definitions: - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available + - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec + id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d + node_name: n1 + state: available + - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec + id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d + node_name: n1 + state: available + - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec + id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d + node_name: n1 + state: available + state: failed + tenant_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + updated_at: "2025-01-01T02:30:00Z" + - created_at: "2025-01-01T01:30:00Z" + id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + instances: + - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec + id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d + node_name: n1 + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded - state: unknown + state: available + state: failed tenant_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 updated_at: "2025-01-01T02:30:00Z" - created_at: "2025-01-01T01:30:00Z" @@ -2830,20 +2861,20 @@ definitions: - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded - state: unknown + state: available + state: failed tenant_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 updated_at: "2025-01-01T02:30:00Z" - created_at: "2025-01-01T01:30:00Z" @@ -2852,20 +2883,20 @@ definitions: - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded + state: available - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: degraded - state: unknown + state: available + state: failed tenant_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 updated_at: "2025-01-01T02:30:00Z" DatabaseSpec: @@ -2952,147 +2983,6 @@ definitions: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - source_database_name: northwind - source_node_name: n1 - source_node: n1 - - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 schedules: - cron_expression: 0 6 * * ? id: daily-full-backup @@ -3107,7 +2997,6 @@ definitions: host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 orchestrator_opts: @@ -3194,26 +3083,6 @@ definitions: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 schedules: - cron_expression: 0 6 * * ? id: daily-full-backup @@ -3228,7 +3097,6 @@ definitions: host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 orchestrator_opts: @@ -3318,34 +3186,46 @@ definitions: additionalProperties: true restore_config: $ref: '#/definitions/RestoreConfigSpec' - spock_version: - type: string - description: The major version of the Spock extension. - example: "5" - pattern: ^\d{1}$ - example: - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 + services: + type: array + items: + $ref: '#/definitions/ServiceSpec' + description: Service instances to run alongside the database (e.g., MCP servers). + example: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + spock_version: + type: string + description: The major version of the Spock extension. + example: "5" + pattern: ^\d{1}$ + example: + backup_config: + repositories: - azure_account: pgedge-backups azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 azure_endpoint: blob.core.usgovcloudapi.net @@ -3430,147 +3310,6 @@ definitions: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - source_database_name: northwind - source_node_name: n1 - source_node: n1 - - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 schedules: - cron_expression: 0 6 * * ? id: daily-full-backup @@ -3585,7 +3324,6 @@ definitions: host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 orchestrator_opts: @@ -3672,26 +3410,6 @@ definitions: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 schedules: - cron_expression: 0 6 * * ? id: daily-full-backup @@ -3706,7 +3424,6 @@ definitions: host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 orchestrator_opts: @@ -3832,6 +3549,33 @@ definitions: source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 source_database_name: northwind source_node_name: n1 + services: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest spock_version: "5" required: - database_name @@ -3844,7 +3588,7 @@ definitions: type: array items: type: string - example: Et et eius at praesentium ut dolorem. + example: Alias ipsa qui ut et. description: The attributes to assign to this database user. example: - LOGIN @@ -3854,7 +3598,7 @@ definitions: db_owner: type: boolean description: If true, this user will be granted database ownership. - example: false + example: true password: type: string description: The password for this database user. This field will be excluded from the response of all endpoints. It can also be omitted from update requests to keep the current value. @@ -3864,7 +3608,7 @@ definitions: type: array items: type: string - example: Placeat illo dolore totam accusamus alias ipsa. + example: Aut doloribus rem culpa ullam minus dolore. description: The roles to assign to this database user. example: - pgedge_superuser @@ -3909,7 +3653,7 @@ definitions: type: array items: type: string - example: Placeat a. + example: Dolor autem eum. description: Optional network-scoped aliases for the container. example: - pg-db @@ -3922,7 +3666,7 @@ definitions: com.docker.network.endpoint.expose: "true" additionalProperties: type: string - example: Sit voluptates maxime. + example: Et eius. id: type: string description: The name or ID of the network to connect to. @@ -3973,7 +3717,7 @@ definitions: example: true example: candidate_instance_id: 68f50878-44d2-4524-a823-e31bd478706d-n1-689qacsi - skip_validation: true + skip_validation: false FailoverDatabaseNodeResponse: title: FailoverDatabaseNodeResponse type: object @@ -3989,6 +3733,35 @@ definitions: type: failover required: - task + HealthCheckResult: + title: HealthCheckResult + type: object + properties: + checked_at: + type: string + description: The time this health check was performed. + example: "2025-01-28T10:00:00Z" + format: date-time + message: + type: string + description: Optional message about the health status. + example: Connection refused + status: + type: string + description: The health status. + example: healthy + enum: + - healthy + - unhealthy + - unknown + description: Health check result for a service instance. + example: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + required: + - status + - checked_at Host: title: Host type: object @@ -4048,6 +3821,10 @@ definitions: spock_version: "5" - postgres_version: "17.6" spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" example: cohort: control_available: true @@ -4066,13 +3843,19 @@ definitions: orchestrator: swarm status: components: - Praesentium repellendus et et harum cum.: + Cum possimus minima.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Et et.: details: alarms: - '3: NOSPACE' error: failed to connect to etcd healthy: false - Ullam autem praesentium est.: + Pariatur praesentium.: details: alarms: - '3: NOSPACE' @@ -4124,13 +3907,19 @@ definitions: type: object description: The status of each component of the host. example: - Earum est ea ratione nobis beatae provident.: + Ipsa sunt est dolor blanditiis.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Omnis sit est.: details: alarms: - '3: NOSPACE' error: failed to connect to etcd healthy: false - Et qui sed illum ad culpa dolor.: + Qui sed illum ad culpa dolor.: details: alarms: - '3: NOSPACE' @@ -4153,7 +3942,7 @@ definitions: format: date-time example: components: - Sunt est dolor.: + Laborum corporis ducimus itaque vel ea eaque.: details: alarms: - '3: NOSPACE' @@ -4202,7 +3991,7 @@ definitions: pending_restart: type: boolean description: True if this instance has a pending restart from a configuration change. - example: true + example: false role: type: string example: primary @@ -4214,7 +4003,7 @@ definitions: example: patroni_paused: false patroni_state: unknown - pending_restart: true + pending_restart: false role: primary version: "18.1" InstanceResponseBody: @@ -4226,7 +4015,7 @@ definitions: created_at: type: string description: The time that the instance was created. - example: "1974-01-28T00:40:13Z" + example: "1997-10-16T04:36:33Z" format: date-time error: type: string @@ -4263,12 +4052,12 @@ definitions: status_updated_at: type: string description: The time that the instance status information was last updated. - example: "1995-06-22T12:36:38Z" + example: "1993-01-27T22:30:34Z" format: date-time updated_at: type: string description: The time that the instance was last modified. - example: "1987-04-15T07:30:36Z" + example: "2014-01-05T11:54:57Z" format: date-time description: An instance of pgEdge Postgres running on a host. (default view) example: @@ -4276,7 +4065,7 @@ definitions: hostname: i-0123456789abcdef.ec2.internal ipv4_address: 10.24.34.2 port: 5432 - created_at: "2014-10-30T11:01:40Z" + created_at: "1970-12-08T13:31:37Z" error: 'failed to get patroni status: connection refused' host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d @@ -4284,7 +4073,7 @@ definitions: postgres: patroni_paused: false patroni_state: unknown - pending_restart: false + pending_restart: true role: primary version: "18.1" spock: @@ -4296,10 +4085,16 @@ definitions: - name: sub_n1n2 provider_node: n2 status: down + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down version: 4.10.0 state: modifying - status_updated_at: "2015-08-04T19:13:16Z" - updated_at: "2002-12-21T03:54:59Z" + status_updated_at: "1987-12-06T22:00:59Z" + updated_at: "1978-01-28T05:17:31Z" required: - id - host_id @@ -4325,7 +4120,7 @@ definitions: example: n1 state: type: string - example: unknown + example: available enum: - creating - modifying @@ -4340,7 +4135,7 @@ definitions: host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d node_name: n1 - state: stopped + state: modifying required: - id - host_id @@ -4357,7 +4152,7 @@ definitions: hostname: i-0123456789abcdef.ec2.internal ipv4_address: 10.24.34.2 port: 5432 - created_at: "1972-02-12T09:45:07Z" + created_at: "1995-05-15T08:45:56Z" error: 'failed to get patroni status: connection refused' host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d @@ -4365,7 +4160,7 @@ definitions: postgres: patroni_paused: false patroni_state: unknown - pending_restart: false + pending_restart: true role: primary version: "18.1" spock: @@ -4377,15 +4172,21 @@ definitions: - name: sub_n1n2 provider_node: n2 status: down - version: 4.10.0 - state: stopped - status_updated_at: "1989-09-01T22:57:29Z" - updated_at: "1978-08-28T00:21:42Z" - - connection_info: - hostname: i-0123456789abcdef.ec2.internal - ipv4_address: 10.24.34.2 - port: 5432 - created_at: "1972-02-12T09:45:07Z" + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down + version: 4.10.0 + state: modifying + status_updated_at: "2010-12-24T23:39:10Z" + updated_at: "2014-01-25T23:48:24Z" + - connection_info: + hostname: i-0123456789abcdef.ec2.internal + ipv4_address: 10.24.34.2 + port: 5432 + created_at: "1995-05-15T08:45:56Z" error: 'failed to get patroni status: connection refused' host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d @@ -4393,7 +4194,7 @@ definitions: postgres: patroni_paused: false patroni_state: unknown - pending_restart: false + pending_restart: true role: primary version: "18.1" spock: @@ -4405,22 +4206,21 @@ definitions: - name: sub_n1n2 provider_node: n2 status: down + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down version: 4.10.0 - state: stopped - status_updated_at: "1989-09-01T22:57:29Z" - updated_at: "1978-08-28T00:21:42Z" - InstanceResponseBodyCollection: - title: 'Mediatype identifier: instance; type=collection; view=default' - type: array - items: - $ref: '#/definitions/InstanceResponseBody' - description: InstanceCollectionResponseBody is the result type for an array of InstanceResponseBody (default view) - example: + state: modifying + status_updated_at: "2010-12-24T23:39:10Z" + updated_at: "2014-01-25T23:48:24Z" - connection_info: hostname: i-0123456789abcdef.ec2.internal ipv4_address: 10.24.34.2 port: 5432 - created_at: "1972-02-12T09:45:07Z" + created_at: "1995-05-15T08:45:56Z" error: 'failed to get patroni status: connection refused' host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d @@ -4428,7 +4228,7 @@ definitions: postgres: patroni_paused: false patroni_state: unknown - pending_restart: false + pending_restart: true role: primary version: "18.1" spock: @@ -4440,15 +4240,28 @@ definitions: - name: sub_n1n2 provider_node: n2 status: down + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down version: 4.10.0 - state: stopped - status_updated_at: "1989-09-01T22:57:29Z" - updated_at: "1978-08-28T00:21:42Z" + state: modifying + status_updated_at: "2010-12-24T23:39:10Z" + updated_at: "2014-01-25T23:48:24Z" + InstanceResponseBodyCollection: + title: 'Mediatype identifier: instance; type=collection; view=default' + type: array + items: + $ref: '#/definitions/InstanceResponseBody' + description: InstanceCollectionResponseBody is the result type for an array of InstanceResponseBody (default view) + example: - connection_info: hostname: i-0123456789abcdef.ec2.internal ipv4_address: 10.24.34.2 port: 5432 - created_at: "1972-02-12T09:45:07Z" + created_at: "1995-05-15T08:45:56Z" error: 'failed to get patroni status: connection refused' host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d @@ -4456,7 +4269,7 @@ definitions: postgres: patroni_paused: false patroni_state: unknown - pending_restart: false + pending_restart: true role: primary version: "18.1" spock: @@ -4468,15 +4281,21 @@ definitions: - name: sub_n1n2 provider_node: n2 status: down + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down version: 4.10.0 - state: stopped - status_updated_at: "1989-09-01T22:57:29Z" - updated_at: "1978-08-28T00:21:42Z" + state: modifying + status_updated_at: "2010-12-24T23:39:10Z" + updated_at: "2014-01-25T23:48:24Z" - connection_info: hostname: i-0123456789abcdef.ec2.internal ipv4_address: 10.24.34.2 port: 5432 - created_at: "1972-02-12T09:45:07Z" + created_at: "1995-05-15T08:45:56Z" error: 'failed to get patroni status: connection refused' host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d @@ -4484,7 +4303,7 @@ definitions: postgres: patroni_paused: false patroni_state: unknown - pending_restart: false + pending_restart: true role: primary version: "18.1" spock: @@ -4496,15 +4315,21 @@ definitions: - name: sub_n1n2 provider_node: n2 status: down + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down version: 4.10.0 - state: stopped - status_updated_at: "1989-09-01T22:57:29Z" - updated_at: "1978-08-28T00:21:42Z" + state: modifying + status_updated_at: "2010-12-24T23:39:10Z" + updated_at: "2014-01-25T23:48:24Z" - connection_info: hostname: i-0123456789abcdef.ec2.internal ipv4_address: 10.24.34.2 port: 5432 - created_at: "1972-02-12T09:45:07Z" + created_at: "1995-05-15T08:45:56Z" error: 'failed to get patroni status: connection refused' host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d @@ -4512,7 +4337,7 @@ definitions: postgres: patroni_paused: false patroni_state: unknown - pending_restart: false + pending_restart: true role: primary version: "18.1" spock: @@ -4524,10 +4349,16 @@ definitions: - name: sub_n1n2 provider_node: n2 status: down + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down version: 4.10.0 - state: stopped - status_updated_at: "1989-09-01T22:57:29Z" - updated_at: "1978-08-28T00:21:42Z" + state: modifying + status_updated_at: "2010-12-24T23:39:10Z" + updated_at: "2014-01-25T23:48:24Z" InstanceSpockStatus: title: InstanceSpockStatus type: object @@ -4551,6 +4382,9 @@ definitions: - name: sub_n1n2 provider_node: n2 status: down + - name: sub_n1n2 + provider_node: n2 + status: down version: type: string description: The version of Spock for this instance. @@ -4620,6 +4454,14 @@ definitions: status: completed task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 type: create + - completed_at: "2025-06-18T16:52:35Z" + created_at: "2025-06-18T16:52:05Z" + database_id: storefront + entity_id: storefront + scope: database + status: completed + task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 + type: create example: tasks: - completed_at: "2025-06-18T17:54:36Z" @@ -4680,6 +4522,22 @@ definitions: status: completed task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 type: create + - completed_at: "2025-06-18T16:52:35Z" + created_at: "2025-06-18T16:52:05Z" + database_id: storefront + entity_id: storefront + scope: database + status: completed + task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 + type: create + - completed_at: "2025-06-18T16:52:35Z" + created_at: "2025-06-18T16:52:05Z" + database_id: storefront + entity_id: storefront + scope: database + status: completed + task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 + type: create example: tasks: - completed_at: "2025-06-18T17:54:36Z" @@ -4717,13 +4575,111 @@ definitions: orchestrator: swarm status: components: - Praesentium repellendus et et harum cum.: + Cum possimus minima.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Et et.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Pariatur praesentium.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + state: available + updated_at: "2021-07-01T12:34:56Z" + supported_pgedge_versions: + - postgres_version: "17.6" + spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" + - cohort: + control_available: true + member_id: lah4bsznw6kc0hp7biylmmmll + type: swarm + cpus: 4 + data_dir: /data + default_pgedge_version: + postgres_version: "17.6" + spock_version: "5" + etcd_mode: server + hostname: i-0123456789abcdef.ec2.internal + id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + ipv4_address: 10.24.34.2 + memory: 16GiB + orchestrator: swarm + status: + components: + Cum possimus minima.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Et et.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Pariatur praesentium.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + state: available + updated_at: "2021-07-01T12:34:56Z" + supported_pgedge_versions: + - postgres_version: "17.6" + spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" + - cohort: + control_available: true + member_id: lah4bsznw6kc0hp7biylmmmll + type: swarm + cpus: 4 + data_dir: /data + default_pgedge_version: + postgres_version: "17.6" + spock_version: "5" + etcd_mode: server + hostname: i-0123456789abcdef.ec2.internal + id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + ipv4_address: 10.24.34.2 + memory: 16GiB + orchestrator: swarm + status: + components: + Cum possimus minima.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Et et.: details: alarms: - '3: NOSPACE' error: failed to connect to etcd healthy: false - Ullam autem praesentium est.: + Pariatur praesentium.: details: alarms: - '3: NOSPACE' @@ -4757,13 +4713,19 @@ definitions: orchestrator: swarm status: components: - Praesentium repellendus et et harum cum.: + Cum possimus minima.: details: alarms: - '3: NOSPACE' error: failed to connect to etcd healthy: false - Ullam autem praesentium est.: + Et et.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Pariatur praesentium.: details: alarms: - '3: NOSPACE' @@ -4890,22 +4852,6 @@ definitions: status: completed task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 type: create - - completed_at: "2025-06-18T16:52:35Z" - created_at: "2025-06-18T16:52:05Z" - database_id: storefront - entity_id: storefront - scope: database - status: completed - task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 - type: create - - completed_at: "2025-06-18T16:52:35Z" - created_at: "2025-06-18T16:52:05Z" - database_id: storefront - entity_id: storefront - scope: database - status: completed - task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 - type: create example: tasks: - completed_at: "2025-06-18T17:54:36Z" @@ -5024,6 +4970,36 @@ definitions: required: - postgres_version - spock_version + PortMapping: + title: PortMapping + type: object + properties: + container_port: + type: integer + description: The port number inside the container. + example: 8080 + format: int64 + minimum: 1 + maximum: 65535 + host_port: + type: integer + description: The port number on the host (if port-forwarded). + example: 8080 + format: int64 + minimum: 1 + maximum: 65535 + name: + type: string + description: The name of the port (e.g., 'http', 'web-client'). + example: web-client + description: Port mapping information for a service instance. + example: + container_port: 8080 + host_port: 8080 + name: web-client + required: + - name + - container_port RemoveHostResponse: title: RemoveHostResponse type: object @@ -5060,6 +5036,14 @@ definitions: status: completed task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 type: create + - completed_at: "2025-06-18T16:52:35Z" + created_at: "2025-06-18T16:52:05Z" + database_id: storefront + entity_id: storefront + scope: database + status: completed + task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 + type: create example: task: completed_at: "2025-06-18T16:52:35Z" @@ -5087,6 +5071,22 @@ definitions: status: completed task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 type: create + - completed_at: "2025-06-18T16:52:35Z" + created_at: "2025-06-18T16:52:05Z" + database_id: storefront + entity_id: storefront + scope: database + status: completed + task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 + type: create + - completed_at: "2025-06-18T16:52:35Z" + created_at: "2025-06-18T16:52:05Z" + database_id: storefront + entity_id: storefront + scope: database + status: completed + task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 + type: create required: - task - update_database_tasks @@ -5123,7 +5123,7 @@ definitions: maxLength: 32 additionalProperties: type: string - example: Sed exercitationem rerum animi et. + example: Occaecati itaque. source_database_id: type: string description: The ID of the database to restore this database from. @@ -5182,7 +5182,7 @@ definitions: type: array items: type: string - example: Cum voluptate modi consequatur non at esse. + example: Atque facilis non modi explicabo illum. description: The nodes to restore. Defaults to all nodes if empty or unspecified. example: - n1 @@ -5227,6 +5227,14 @@ definitions: status: completed task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 type: create + - completed_at: "2025-06-18T16:52:35Z" + created_at: "2025-06-18T16:52:05Z" + database_id: storefront + entity_id: storefront + scope: database + status: completed + task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 + type: create task: $ref: '#/definitions/Task' example: @@ -5403,7 +5411,7 @@ definitions: s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab additionalProperties: type: string - example: Exercitationem placeat temporibus aut facilis repudiandae. + example: Voluptates maxime hic aut omnis magnam. gcs_bucket: type: string description: The GCS bucket name for this repository. Only applies when type = 'gcs'. @@ -5485,6 +5493,336 @@ definitions: type: s3 required: - type + ServiceInstanceStatus: + title: ServiceInstanceStatus + type: object + properties: + container_id: + type: string + description: The Docker container ID. + example: a1b2c3d4e5f6 + health_check: + $ref: '#/definitions/HealthCheckResult' + hostname: + type: string + description: The hostname of the service instance. + example: mcp-server-host-1.internal + image_version: + type: string + description: The container image version currently running. + example: 1.0.0 + ipv4_address: + type: string + description: The IPv4 address of the service instance. + example: 10.0.1.5 + format: ipv4 + last_health_at: + type: string + description: The time of the last health check attempt. + example: "2025-01-28T10:00:00Z" + format: date-time + ports: + type: array + items: + $ref: '#/definitions/PortMapping' + description: Port mappings for this service instance. + example: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: + type: boolean + description: Whether the service is ready to accept requests. + example: true + description: Runtime status information for a service instance. + example: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + ServiceSpec: + title: ServiceSpec + type: object + properties: + config: + type: object + description: Service-specific configuration. For MCP services, this includes llm_provider, llm_model, and provider-specific API keys. + example: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + additionalProperties: true + cpus: + type: string + description: The number of CPUs to allocate for this service. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if unspecified. + example: 500m + pattern: ^[0-9]+(\.[0-9]{1,3}|m)?$ + host_ids: + type: array + items: + type: string + example: 76f9b8c0-4958-11f0-a489-3bb29577c696 + minLength: 1 + maxLength: 63 + description: The IDs of the hosts that should run this service. One service instance will be created per host. + example: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + minItems: 1 + memory: + type: string + description: The amount of memory in SI or IEC notation to allocate for this service. Defaults to container defaults if unspecified. + example: 512M + maxLength: 16 + port: + type: integer + description: The port to publish the service on the host. If 0, Docker assigns a random port. If unspecified, no port is published and the service is not accessible from outside the Docker network. + example: 0 + format: int64 + minimum: 0 + maximum: 65535 + service_id: + type: string + description: The unique identifier for this service. + example: 76f9b8c0-4958-11f0-a489-3bb29577c696 + minLength: 1 + maxLength: 63 + service_type: + type: string + description: The type of service to run. + example: mcp + enum: + - mcp + version: + type: string + description: The version of the service in semver format (e.g., '1.0.0') or the literal 'latest'. + example: latest + pattern: ^(\d+\.\d+\.\d+|latest)$ + example: + config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + required: + - service_id + - service_type + - version + - host_ids + - config + ServiceinstanceResponseBody: + title: 'Mediatype identifier: serviceinstance; view=default' + type: object + properties: + created_at: + type: string + description: The time that the service instance was created. + example: "2025-01-28T10:00:00Z" + format: date-time + database_id: + type: string + description: The ID of the database this service belongs to. + example: 76f9b8c0-4958-11f0-a489-3bb29577c696 + minLength: 1 + maxLength: 63 + error: + type: string + description: An error message if the service instance is in an error state. + example: 'failed to start container: image not found' + host_id: + type: string + description: The ID of the host this service instance is running on. + example: host-1 + service_id: + type: string + description: The service ID from the DatabaseSpec. + example: mcp-server + service_instance_id: + type: string + description: Unique identifier for the service instance. + example: mcp-server-host-1 + state: + type: string + description: Current state of the service instance. + example: running + enum: + - creating + - running + - failed + - deleting + status: + $ref: '#/definitions/ServiceInstanceStatus' + updated_at: + type: string + description: The time that the service instance was last updated. + example: "2025-01-28T10:05:00Z" + format: date-time + description: A service instance running on a host alongside the database. (default view) + example: + created_at: "2025-01-28T10:00:00Z" + database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + required: + - service_instance_id + - service_id + - database_id + - host_id + - state + - created_at + - updated_at + ServiceinstanceResponseBodyCollection: + title: 'Mediatype identifier: serviceinstance; type=collection; view=default' + type: array + items: + $ref: '#/definitions/ServiceinstanceResponseBody' + description: ServiceinstanceCollectionResponseBody is the result type for an array of ServiceinstanceResponseBody (default view) + example: + - created_at: "2025-01-28T10:00:00Z" + database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" StartInstanceResponse: title: StartInstanceResponse type: object @@ -5531,7 +5869,7 @@ definitions: traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) additionalProperties: type: string - example: Omnis magnam aspernatur occaecati. + example: Ut dolorem. extra_networks: type: array items: diff --git a/api/apiv1/gen/http/openapi3.json b/api/apiv1/gen/http/openapi3.json index 4dd5c48f..a2c4b5e2 100644 --- a/api/apiv1/gen/http/openapi3.json +++ b/api/apiv1/gen/http/openapi3.json @@ -2402,7 +2402,7 @@ }, "example": { "candidate_instance_id": "68f50878-44d2-4524-a823-e31bd478706d-n1-689qacsi", - "skip_validation": true + "skip_validation": false } } } @@ -3878,16 +3878,6 @@ "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", "type": "create" }, - { - "completed_at": "2025-06-18T16:52:35Z", - "created_at": "2025-06-18T16:52:05Z", - "database_id": "storefront", - "entity_id": "storefront", - "scope": "database", - "status": "completed", - "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", - "type": "create" - }, { "completed_at": "2025-06-18T16:52:35Z", "created_at": "2025-06-18T16:52:05Z", @@ -4012,7 +4002,16 @@ "orchestrator": "swarm", "status": { "components": { - "Praesentium repellendus et et harum cum.": { + "Cum possimus minima.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Et et.": { "details": { "alarms": [ "3: NOSPACE" @@ -4021,7 +4020,7 @@ "error": "failed to connect to etcd", "healthy": false }, - "Ullam autem praesentium est.": { + "Pariatur praesentium.": { "details": { "alarms": [ "3: NOSPACE" @@ -4892,6 +4891,29 @@ }, "description": "The repositories for this backup configuration.", "example": [ + { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -5069,7 +5091,7 @@ }, "additionalProperties": { "type": "string", - "example": "Possimus error eligendi recusandae." + "example": "Qui et eius reiciendis accusamus." } }, "backup_options": { @@ -5080,7 +5102,7 @@ }, "additionalProperties": { "type": "string", - "example": "Sed neque eos rerum quia." + "example": "Quo recusandae quibusdam fuga molestiae." } }, "type": { @@ -5152,7 +5174,7 @@ }, "additionalProperties": { "type": "string", - "example": "Quam sint iure eum ducimus quia." + "example": "Impedit laudantium et quia commodi consequatur." } }, "gcs_bucket": { @@ -5849,13 +5871,16 @@ "instances": { "$ref": "#/components/schemas/InstanceResponseBodyCollection" }, + "service_instances": { + "$ref": "#/components/schemas/ServiceinstanceResponseBodyCollection" + }, "spec": { "$ref": "#/components/schemas/DatabaseSpec3" }, "state": { "type": "string", "description": "Current state of the database.", - "example": "failed", + "example": "unknown", "enum": [ "creating", "modifying", @@ -6036,7 +6061,9 @@ "id", "created_at", "updated_at", - "state" + "state", + "instances", + "service_instances" ] }, "CreateDatabaseRequest": { @@ -6256,13 +6283,16 @@ "instances": { "$ref": "#/components/schemas/InstanceCollection" }, + "service_instances": { + "$ref": "#/components/schemas/ServiceinstanceCollection" + }, "spec": { "$ref": "#/components/schemas/DatabaseSpec" }, "state": { "type": "string", "description": "Current state of the database.", - "example": "deleting", + "example": "modifying", "enum": [ "creating", "modifying", @@ -6446,6 +6476,113 @@ "updated_at": "2006-10-18T16:07:16Z" } ], + "service_instances": [ + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + } + ], "spec": { "backup_config": { "repositories": [ @@ -6500,7 +6637,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -6513,7 +6650,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -6526,7 +6663,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -6677,148 +6814,6 @@ "source_node_name": "n1" }, "source_node": "n1" - }, - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "de3b1388-1f0c-42f1-a86c-59ab72f255ec" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" - }, - "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" } ], "orchestrator_opts": { @@ -6910,9 +6905,59 @@ "source_database_name": "northwind", "source_node_name": "n1" }, + "services": [ + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + } + ], "spock_version": "5" }, - "state": "backing_up", + "state": "failed", "tenant_id": "8210ec10-2dca-406c-ac4a-0661d2189954", "updated_at": "2025-01-01T02:30:00Z" }, @@ -6920,7 +6965,9 @@ "id", "created_at", "updated_at", - "state" + "state", + "instances", + "service_instances" ] }, "DatabaseCollection": { @@ -7086,12 +7133,119 @@ "updated_at": "2006-10-18T16:07:16Z" } ], - "spec": { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "service_instances": [ + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + } + ], + "spec": { + "backup_config": { + "repositories": [ + { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "azure_endpoint": "blob.core.usgovcloudapi.net", "azure_key": "YXpLZXk=", "base_path": "/backups", @@ -7140,7 +7294,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -7153,7 +7307,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -7166,7 +7320,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -7317,148 +7471,6 @@ "source_node_name": "n1" }, "source_node": "n1" - }, - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "de3b1388-1f0c-42f1-a86c-59ab72f255ec" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" - }, - "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" } ], "orchestrator_opts": { @@ -7550,6 +7562,56 @@ "source_database_name": "northwind", "source_node_name": "n1" }, + "services": [ + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + } + ], "spock_version": "5" }, "state": "creating", @@ -7713,6 +7775,113 @@ "updated_at": "2006-10-18T16:07:16Z" } ], + "service_instances": [ + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + } + ], "spec": { "backup_config": { "repositories": [ @@ -7767,7 +7936,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -7780,7 +7949,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -7793,7 +7962,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -7944,148 +8113,6 @@ "source_node_name": "n1" }, "source_node": "n1" - }, - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "de3b1388-1f0c-42f1-a86c-59ab72f255ec" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" - }, - "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" } ], "orchestrator_opts": { @@ -8177,746 +8204,707 @@ "source_database_name": "northwind", "source_node_name": "n1" }, + "services": [ + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + } + ], "spock_version": "5" }, "state": "creating", "tenant_id": "8210ec10-2dca-406c-ac4a-0661d2189954", "updated_at": "2025-01-01T02:30:00Z" - } - ] - }, - "DatabaseNodeSpec": { - "type": "object", - "properties": { - "backup_config": { - "$ref": "#/components/schemas/BackupConfigSpec" - }, - "cpus": { - "type": "string", - "description": "The number of CPUs to allocate for the database on this node and to use for tuning Postgres. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Cannot allocate units smaller than 1m. Defaults to the number of available CPUs on the host if 0 or unspecified. Cannot allocate more CPUs than are available on the host. Whether this limit is enforced depends on the orchestrator.", - "example": "500m", - "pattern": "^[0-9]+(\\.[0-9]{1,3}|m)?$" - }, - "host_ids": { - "type": "array", - "items": { - "type": "string", - "description": "A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens.", - "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "minLength": 1, - "maxLength": 63 - }, - "description": "The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas.", - "example": [ - "de3b1388-1f0c-42f1-a86c-59ab72f255ec", - "de3b1388-1f0c-42f1-a86c-59ab72f255ec", - "de3b1388-1f0c-42f1-a86c-59ab72f255ec" - ], - "minItems": 1 - }, - "memory": { - "type": "string", - "description": "The amount of memory in SI or IEC notation to allocate for the database on this node and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator.", - "example": "500M", - "maxLength": 16 - }, - "name": { - "type": "string", - "description": "The name of the database node.", - "example": "n1", - "pattern": "n[0-9]+" - }, - "orchestrator_opts": { - "$ref": "#/components/schemas/OrchestratorOpts" - }, - "port": { - "type": "integer", - "description": "The port used by the Postgres database for this node. Overrides the Postgres port set in the DatabaseSpec.", - "example": 5432, - "format": "int64", - "minimum": 0, - "maximum": 65535 - }, - "postgres_version": { - "type": "string", - "description": "The Postgres version for this node in 'major.minor' format. Overrides the Postgres version set in the DatabaseSpec.", - "example": "17.6", - "pattern": "^\\d{2}\\.\\d{1,2}$" - }, - "postgresql_conf": { - "type": "object", - "description": "Additional postgresql.conf settings for this particular node. Will be merged with the settings provided by control-plane.", - "example": { - "max_connections": 1000 - }, - "additionalProperties": true }, - "restore_config": { - "$ref": "#/components/schemas/RestoreConfigSpec" - }, - "source_node": { - "type": "string", - "description": "The name of the source node to use for sync. This is typically the node (like 'n1') from which the data will be copied to initialize this new node.", - "example": "n1" - } - }, - "example": { - "backup_config": { - "repositories": [ + { + "created_at": "2025-01-01T01:30:00Z", + "id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", + "instances": [ { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" + "connection_info": { + "hostname": "i-0123456789abcdef.ec2.internal", + "ipv4_address": "10.24.34.2", + "port": 5432 }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" + "created_at": "1987-03-24T21:22:02Z", + "error": "failed to get patroni status: connection refused", + "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", + "node_name": "n1", + "postgres": { + "patroni_paused": true, + "patroni_state": "unknown", + "pending_restart": false, + "role": "primary", + "version": "18.1" + }, + "spock": { + "read_only": "off", + "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + } + ], + "version": "4.10.0" + }, + "state": "creating", + "status_updated_at": "1974-12-13T04:15:04Z", + "updated_at": "2006-10-18T16:07:16Z" }, { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" + "connection_info": { + "hostname": "i-0123456789abcdef.ec2.internal", + "ipv4_address": "10.24.34.2", + "port": 5432 + }, + "created_at": "1987-03-24T21:22:02Z", + "error": "failed to get patroni status: connection refused", + "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", + "node_name": "n1", + "postgres": { + "patroni_paused": true, + "patroni_state": "unknown", + "pending_restart": false, + "role": "primary", + "version": "18.1" + }, + "spock": { + "read_only": "off", + "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + } + ], + "version": "4.10.0" + }, + "state": "creating", + "status_updated_at": "1974-12-13T04:15:04Z", + "updated_at": "2006-10-18T16:07:16Z" }, { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "de3b1388-1f0c-42f1-a86c-59ab72f255ec" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" + "connection_info": { + "hostname": "i-0123456789abcdef.ec2.internal", + "ipv4_address": "10.24.34.2", + "port": 5432 }, - { - "aliases": [ - "pg-db", - "db-alias" + "created_at": "1987-03-24T21:22:02Z", + "error": "failed to get patroni status: connection refused", + "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", + "node_name": "n1", + "postgres": { + "patroni_paused": true, + "patroni_state": "unknown", + "pending_restart": false, + "role": "primary", + "version": "18.1" + }, + "spock": { + "read_only": "off", + "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + } ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" + "version": "4.10.0" }, - { - "aliases": [ - "pg-db", - "db-alias" + "state": "creating", + "status_updated_at": "1974-12-13T04:15:04Z", + "updated_at": "2006-10-18T16:07:16Z" + }, + { + "connection_info": { + "hostname": "i-0123456789abcdef.ec2.internal", + "ipv4_address": "10.24.34.2", + "port": 5432 + }, + "created_at": "1987-03-24T21:22:02Z", + "error": "failed to get patroni status: connection refused", + "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", + "node_name": "n1", + "postgres": { + "patroni_paused": true, + "patroni_state": "unknown", + "pending_restart": false, + "role": "primary", + "version": "18.1" + }, + "spock": { + "read_only": "off", + "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + } ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" + "version": "4.10.0" + }, + "state": "creating", + "status_updated_at": "1974-12-13T04:15:04Z", + "updated_at": "2006-10-18T16:07:16Z" + } + ], + "service_instances": [ + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" - }, - "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" - }, - "required": [ - "name", - "host_ids" - ] - }, - "DatabaseNodeSpec2": { - "type": "object", - "properties": { - "backup_config": { - "$ref": "#/components/schemas/BackupConfigSpec" - }, - "cpus": { - "type": "string", - "description": "The number of CPUs to allocate for the database on this node and to use for tuning Postgres. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Cannot allocate units smaller than 1m. Defaults to the number of available CPUs on the host if 0 or unspecified. Cannot allocate more CPUs than are available on the host. Whether this limit is enforced depends on the orchestrator.", - "example": "500m", - "pattern": "^[0-9]+(\\.[0-9]{1,3}|m)?$" - }, - "host_ids": { - "type": "array", - "items": { - "type": "string", - "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "minLength": 1, - "maxLength": 63 - }, - "description": "The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas.", - "example": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696" - ], - "minItems": 1 - }, - "memory": { - "type": "string", - "description": "The amount of memory in SI or IEC notation to allocate for the database on this node and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator.", - "example": "500M", - "maxLength": 16 - }, - "name": { - "type": "string", - "description": "The name of the database node.", - "example": "n1", - "pattern": "n[0-9]+" - }, - "orchestrator_opts": { - "$ref": "#/components/schemas/OrchestratorOpts" - }, - "port": { - "type": "integer", - "description": "The port used by the Postgres database for this node. Overrides the Postgres port set in the DatabaseSpec.", - "example": 5432, - "format": "int64", - "minimum": 0, - "maximum": 65535 - }, - "postgres_version": { - "type": "string", - "description": "The Postgres version for this node in 'major.minor' format. Overrides the Postgres version set in the DatabaseSpec.", - "example": "17.6", - "pattern": "^\\d{2}\\.\\d{1,2}$" - }, - "postgresql_conf": { - "type": "object", - "description": "Additional postgresql.conf settings for this particular node. Will be merged with the settings provided by control-plane.", - "example": { - "max_connections": 1000 - }, - "additionalProperties": true - }, - "restore_config": { - "$ref": "#/components/schemas/RestoreConfigSpec" - }, - "source_node": { - "type": "string", - "description": "The name of the source node to use for sync. This is typically the node (like 'n1') from which the data will be copied to initialize this new node.", - "example": "n1" - } - }, - "example": { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" + "updated_at": "2025-01-28T10:05:00Z" }, { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" + "updated_at": "2025-01-28T10:05:00Z" } ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", - "76f9b8c0-4958-11f0-a489-3bb29577c696" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + "spec": { + "backup_config": { + "repositories": [ + { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + } + ], + "schedules": [ + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + } + ] }, - "extra_networks": [ + "cpus": "500m", + "database_name": "northwind", + "database_users": [ { - "aliases": [ - "pg-db", - "db-alias" + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" + "username": "admin" }, { - "aliases": [ - "pg-db", - "db-alias" + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" - }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" - }, - "required": [ - "name", - "host_ids" - ] - }, - "DatabaseNodeSpec3": { - "type": "object", - "properties": { - "backup_config": { - "$ref": "#/components/schemas/BackupConfigSpec" - }, - "cpus": { - "type": "string", - "description": "The number of CPUs to allocate for the database on this node and to use for tuning Postgres. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Cannot allocate units smaller than 1m. Defaults to the number of available CPUs on the host if 0 or unspecified. Cannot allocate more CPUs than are available on the host. Whether this limit is enforced depends on the orchestrator.", - "example": "500m", - "pattern": "^[0-9]+(\\.[0-9]{1,3}|m)?$" - }, - "host_ids": { - "type": "array", - "items": { - "type": "string", - "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "minLength": 1, - "maxLength": 63 - }, - "description": "The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas.", - "example": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", - "76f9b8c0-4958-11f0-a489-3bb29577c696" - ], - "minItems": 1 - }, - "memory": { - "type": "string", - "description": "The amount of memory in SI or IEC notation to allocate for the database on this node and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator.", - "example": "500M", - "maxLength": 16 - }, - "name": { - "type": "string", - "description": "The name of the database node.", - "example": "n1", - "pattern": "n[0-9]+" - }, - "orchestrator_opts": { - "$ref": "#/components/schemas/OrchestratorOpts" - }, - "port": { - "type": "integer", - "description": "The port used by the Postgres database for this node. Overrides the Postgres port set in the DatabaseSpec.", - "example": 5432, - "format": "int64", - "minimum": 0, - "maximum": 65535 - }, - "postgres_version": { - "type": "string", - "description": "The Postgres version for this node in 'major.minor' format. Overrides the Postgres version set in the DatabaseSpec.", - "example": "17.6", - "pattern": "^\\d{2}\\.\\d{1,2}$" - }, - "postgresql_conf": { - "type": "object", - "description": "Additional postgresql.conf settings for this particular node. Will be merged with the settings provided by control-plane.", - "example": { - "max_connections": 1000 - }, - "additionalProperties": true - }, - "restore_config": { - "$ref": "#/components/schemas/RestoreConfigSpec" - }, - "source_node": { - "type": "string", - "description": "The name of the source node to use for sync. This is typically the node (like 'n1') from which the data will be copied to initialize this new node.", - "example": "n1" - } - }, - "example": { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" + "username": "admin" }, { - "aliases": [ - "pg-db", - "db-alias" + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" + } + ], + "memory": "500M", + "nodes": [ { - "aliases": [ - "pg-db", - "db-alias" + "backup_config": { + "repositories": [ + { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + } + ], + "schedules": [ + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + } + ] + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" + "memory": "500M", + "name": "n1", + "orchestrator_opts": { + "swarm": { + "extra_labels": { + "traefik.enable": "true", + "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + }, + "extra_networks": [ + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + } + ], + "extra_volumes": [ + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + } + ] + } }, - "id": "traefik-public" + "port": 5432, + "postgres_version": "17.6", + "postgresql_conf": { + "max_connections": 1000 + }, + "restore_config": { + "repository": { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" + }, + "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "source_node": "n1" } ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" + "orchestrator_opts": { + "swarm": { + "extra_labels": { + "traefik.enable": "true", + "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + }, + "extra_networks": [ + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + } + ], + "extra_volumes": [ + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + } + ] + } + }, + "port": 5432, + "postgres_version": "17.6", + "postgresql_conf": { + "max_connections": 1000 + }, + "restore_config": { + "repository": { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" }, + "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "services": [ { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + } + ], + "spock_version": "5" }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" - }, - "required": [ - "name", - "host_ids" + "state": "creating", + "tenant_id": "8210ec10-2dca-406c-ac4a-0661d2189954", + "updated_at": "2025-01-01T02:30:00Z" + } ] }, - "DatabaseNodeSpec4": { + "DatabaseNodeSpec": { "type": "object", "properties": { "backup_config": { @@ -8932,14 +8920,16 @@ "type": "array", "items": { "type": "string", + "description": "A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens.", "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", "minLength": 1, "maxLength": 63 }, "description": "The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas.", "example": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", - "76f9b8c0-4958-11f0-a489-3bb29577c696" + "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" ], "minItems": 1 }, @@ -9005,30 +8995,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", "retention_full": 2, "retention_full_type": "count", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -9059,8 +9026,7 @@ }, "cpus": "500m", "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", - "76f9b8c0-4958-11f0-a489-3bb29577c696" + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" ], "memory": "500M", "name": "n1", @@ -9136,7 +9102,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "s3_endpoint": "s3.us-east-1.amazonaws.com", "s3_key": "AKIAIOSFODNN7EXAMPLE", @@ -9149,7 +9115,7 @@ "target": "123456", "type": "xid" }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", "source_database_name": "northwind", "source_node_name": "n1" }, @@ -9160,7 +9126,7 @@ "host_ids" ] }, - "DatabaseSpec": { + "DatabaseNodeSpec2": { "type": "object", "properties": { "backup_config": { @@ -9168,264 +9134,73 @@ }, "cpus": { "type": "string", - "description": "The number of CPUs to allocate for the database and to use for tuning Postgres. Defaults to the number of available CPUs on the host. Can include an SI suffix, e.g. '500m' for 500 millicpus. Whether this limit is enforced depends on the orchestrator.", + "description": "The number of CPUs to allocate for the database on this node and to use for tuning Postgres. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Cannot allocate units smaller than 1m. Defaults to the number of available CPUs on the host if 0 or unspecified. Cannot allocate more CPUs than are available on the host. Whether this limit is enforced depends on the orchestrator.", "example": "500m", "pattern": "^[0-9]+(\\.[0-9]{1,3}|m)?$" }, - "database_name": { - "type": "string", - "description": "The name of the Postgres database.", - "example": "northwind", - "minLength": 1, - "maxLength": 31 - }, - "database_users": { + "host_ids": { "type": "array", "items": { - "$ref": "#/components/schemas/DatabaseUserSpec" + "type": "string", + "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "minLength": 1, + "maxLength": 63 }, - "description": "The users to create for this database.", + "description": "The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas.", "example": [ - { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": false, - "password": "secret", - "roles": [ - "pgedge_superuser" - ], - "username": "admin" - }, - { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": false, - "password": "secret", - "roles": [ - "pgedge_superuser" - ], - "username": "admin" - }, - { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": false, - "password": "secret", - "roles": [ - "pgedge_superuser" - ], - "username": "admin" - } + "76f9b8c0-4958-11f0-a489-3bb29577c696" ], - "maxItems": 16 + "minItems": 1 }, "memory": { "type": "string", - "description": "The amount of memory in SI or IEC notation to allocate for the database and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator.", + "description": "The amount of memory in SI or IEC notation to allocate for the database on this node and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator.", "example": "500M", "maxLength": 16 }, - "nodes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DatabaseNodeSpec" + "name": { + "type": "string", + "description": "The name of the database node.", + "example": "n1", + "pattern": "n[0-9]+" + }, + "orchestrator_opts": { + "$ref": "#/components/schemas/OrchestratorOpts" + }, + "port": { + "type": "integer", + "description": "The port used by the Postgres database for this node. Overrides the Postgres port set in the DatabaseSpec.", + "example": 5432, + "format": "int64", + "minimum": 0, + "maximum": 65535 + }, + "postgres_version": { + "type": "string", + "description": "The Postgres version for this node in 'major.minor' format. Overrides the Postgres version set in the DatabaseSpec.", + "example": "17.6", + "pattern": "^\\d{2}\\.\\d{1,2}$" + }, + "postgresql_conf": { + "type": "object", + "description": "Additional postgresql.conf settings for this particular node. Will be merged with the settings provided by control-plane.", + "example": { + "max_connections": 1000 }, - "description": "The Spock nodes for this database.", - "example": [ - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "de3b1388-1f0c-42f1-a86c-59ab72f255ec" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" - }, - "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" - } - ], - "minItems": 1, - "maxItems": 9 - }, - "orchestrator_opts": { - "$ref": "#/components/schemas/OrchestratorOpts" - }, - "port": { - "type": "integer", - "description": "The port used by the Postgres database. If the port is 0, each instance will be assigned a random port. If the port is unspecified, the database will not be exposed on any port, dependent on orchestrator support for that feature.", - "example": 5432, - "format": "int64", - "minimum": 0, - "maximum": 65535 - }, - "postgres_version": { - "type": "string", - "description": "The Postgres version in 'major.minor' format.", - "example": "17.6", - "pattern": "^\\d{2}\\.\\d{1,2}$" - }, - "postgresql_conf": { - "type": "object", - "description": "Additional postgresql.conf settings. Will be merged with the settings provided by control-plane.", - "example": { - "max_connections": 1000 - }, - "maxLength": 64, - "additionalProperties": true - }, - "restore_config": { - "$ref": "#/components/schemas/RestoreConfigSpec" - }, - "spock_version": { - "type": "string", - "description": "The major version of the Spock extension.", - "example": "5", - "pattern": "^\\d{1}$" - } - }, - "example": { - "backup_config": { - "repositories": [ + "additionalProperties": true + }, + "restore_config": { + "$ref": "#/components/schemas/RestoreConfigSpec" + }, + "source_node": { + "type": "string", + "description": "The name of the source node to use for sync. This is typically the node (like 'n1') from which the data will be copied to initialize this new node.", + "example": "n1" + } + }, + "example": { + "backup_config": { + "repositories": [ { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -9439,7 +9214,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", "retention_full": 2, "retention_full_type": "count", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -9469,477 +9244,233 @@ ] }, "cpus": "500m", - "database_name": "northwind", - "database_users": [ - { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": false, - "password": "secret", - "roles": [ - "pgedge_superuser" - ], - "username": "admin" - }, - { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": false, - "password": "secret", - "roles": [ - "pgedge_superuser" - ], - "username": "admin" - }, - { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": false, - "password": "secret", - "roles": [ - "pgedge_superuser" - ], - "username": "admin" - } + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" ], "memory": "500M", - "nodes": [ - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "de3b1388-1f0c-42f1-a86c-59ab72f255ec" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" - }, - "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" - }, - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "de3b1388-1f0c-42f1-a86c-59ab72f255ec" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 + "name": "n1", + "orchestrator_opts": { + "swarm": { + "extra_labels": { + "traefik.enable": "true", + "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" + "extra_networks": [ + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" + "id": "traefik-public" }, - "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" - }, - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + "id": "traefik-public" + } ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] + "extra_volumes": [ + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" } + ] + } + }, + "port": 5432, + "postgres_version": "17.6", + "postgresql_conf": { + "max_connections": 1000 + }, + "restore_config": { + "repository": { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" + }, + "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "source_node": "n1" + }, + "required": [ + "name", + "host_ids" + ] + }, + "DatabaseNodeSpec3": { + "type": "object", + "properties": { + "backup_config": { + "$ref": "#/components/schemas/BackupConfigSpec" + }, + "cpus": { + "type": "string", + "description": "The number of CPUs to allocate for the database on this node and to use for tuning Postgres. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Cannot allocate units smaller than 1m. Defaults to the number of available CPUs on the host if 0 or unspecified. Cannot allocate more CPUs than are available on the host. Whether this limit is enforced depends on the orchestrator.", + "example": "500m", + "pattern": "^[0-9]+(\\.[0-9]{1,3}|m)?$" + }, + "host_ids": { + "type": "array", + "items": { + "type": "string", + "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "minLength": 1, + "maxLength": 63 + }, + "description": "The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas.", + "example": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "minItems": 1 + }, + "memory": { + "type": "string", + "description": "The amount of memory in SI or IEC notation to allocate for the database on this node and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator.", + "example": "500M", + "maxLength": 16 + }, + "name": { + "type": "string", + "description": "The name of the database node.", + "example": "n1", + "pattern": "n[0-9]+" + }, + "orchestrator_opts": { + "$ref": "#/components/schemas/OrchestratorOpts" + }, + "port": { + "type": "integer", + "description": "The port used by the Postgres database for this node. Overrides the Postgres port set in the DatabaseSpec.", + "example": 5432, + "format": "int64", + "minimum": 0, + "maximum": 65535 + }, + "postgres_version": { + "type": "string", + "description": "The Postgres version for this node in 'major.minor' format. Overrides the Postgres version set in the DatabaseSpec.", + "example": "17.6", + "pattern": "^\\d{2}\\.\\d{1,2}$" + }, + "postgresql_conf": { + "type": "object", + "description": "Additional postgresql.conf settings for this particular node. Will be merged with the settings provided by control-plane.", + "example": { + "max_connections": 1000 + }, + "additionalProperties": true + }, + "restore_config": { + "$ref": "#/components/schemas/RestoreConfigSpec" + }, + "source_node": { + "type": "string", + "description": "The name of the source node to use for sync. This is typically the node (like 'n1') from which the data will be copied to initialize this new node.", + "example": "n1" + } + }, + "example": { + "backup_config": { + "repositories": [ + { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" }, - "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", - "source_database_name": "northwind", - "source_node_name": "n1" + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + } + ], + "schedules": [ + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" }, - "source_node": "n1" - } + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + } + ] + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" ], + "memory": "500M", + "name": "n1", "orchestrator_opts": { "swarm": { "extra_labels": { @@ -10012,7 +9543,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "s3_endpoint": "s3.us-east-1.amazonaws.com", "s3_key": "AKIAIOSFODNN7EXAMPLE", @@ -10025,18 +9556,18 @@ "target": "123456", "type": "xid" }, - "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", + "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", "source_database_name": "northwind", "source_node_name": "n1" }, - "spock_version": "5" + "source_node": "n1" }, "required": [ - "database_name", - "nodes" + "name", + "host_ids" ] }, - "DatabaseSpec2": { + "DatabaseNodeSpec4": { "type": "object", "properties": { "backup_config": { @@ -10044,267 +9575,321 @@ }, "cpus": { "type": "string", - "description": "The number of CPUs to allocate for the database and to use for tuning Postgres. Defaults to the number of available CPUs on the host. Can include an SI suffix, e.g. '500m' for 500 millicpus. Whether this limit is enforced depends on the orchestrator.", + "description": "The number of CPUs to allocate for the database on this node and to use for tuning Postgres. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Cannot allocate units smaller than 1m. Defaults to the number of available CPUs on the host if 0 or unspecified. Cannot allocate more CPUs than are available on the host. Whether this limit is enforced depends on the orchestrator.", "example": "500m", "pattern": "^[0-9]+(\\.[0-9]{1,3}|m)?$" }, - "database_name": { - "type": "string", - "description": "The name of the Postgres database.", - "example": "northwind", - "minLength": 1, - "maxLength": 31 - }, - "database_users": { + "host_ids": { "type": "array", "items": { - "$ref": "#/components/schemas/DatabaseUserSpec" + "type": "string", + "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "minLength": 1, + "maxLength": 63 }, - "description": "The users to create for this database.", + "description": "The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas.", "example": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "minItems": 1 + }, + "memory": { + "type": "string", + "description": "The amount of memory in SI or IEC notation to allocate for the database on this node and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator.", + "example": "500M", + "maxLength": 16 + }, + "name": { + "type": "string", + "description": "The name of the database node.", + "example": "n1", + "pattern": "n[0-9]+" + }, + "orchestrator_opts": { + "$ref": "#/components/schemas/OrchestratorOpts" + }, + "port": { + "type": "integer", + "description": "The port used by the Postgres database for this node. Overrides the Postgres port set in the DatabaseSpec.", + "example": 5432, + "format": "int64", + "minimum": 0, + "maximum": 65535 + }, + "postgres_version": { + "type": "string", + "description": "The Postgres version for this node in 'major.minor' format. Overrides the Postgres version set in the DatabaseSpec.", + "example": "17.6", + "pattern": "^\\d{2}\\.\\d{1,2}$" + }, + "postgresql_conf": { + "type": "object", + "description": "Additional postgresql.conf settings for this particular node. Will be merged with the settings provided by control-plane.", + "example": { + "max_connections": 1000 + }, + "additionalProperties": true + }, + "restore_config": { + "$ref": "#/components/schemas/RestoreConfigSpec" + }, + "source_node": { + "type": "string", + "description": "The name of the source node to use for sync. This is typically the node (like 'n1') from which the data will be copied to initialize this new node.", + "example": "n1" + } + }, + "example": { + "backup_config": { + "repositories": [ { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": true, - "password": "secret", - "roles": [ - "pgedge_superuser" - ], - "username": "admin" + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" }, { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": true, - "password": "secret", - "roles": [ - "pgedge_superuser" - ], - "username": "admin" + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + } + ], + "schedules": [ + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" }, { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": true, - "password": "secret", - "roles": [ - "pgedge_superuser" - ], - "username": "admin" + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" } - ], - "maxItems": 16 - }, - "memory": { - "type": "string", - "description": "The amount of memory in SI or IEC notation to allocate for the database and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator.", - "example": "500M", - "maxLength": 16 + ] }, - "nodes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DatabaseNodeSpec2" - }, - "description": "The Spock nodes for this database.", - "example": [ - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "500M", + "name": "n1", + "orchestrator_opts": { + "swarm": { + "extra_labels": { + "traefik.enable": "true", + "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + }, + "extra_networks": [ + { + "aliases": [ + "pg-db", + "db-alias" ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "source_database_name": "northwind", - "source_node_name": "n1" + "id": "traefik-public" + } + ], + "extra_volumes": [ + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" }, - "source_node": "n1" + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + } + ] + } + }, + "port": 5432, + "postgres_version": "17.6", + "postgresql_conf": { + "max_connections": 1000 + }, + "restore_config": { + "repository": { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" + }, + "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "source_node": "n1" + }, + "required": [ + "name", + "host_ids" + ] + }, + "DatabaseSpec": { + "type": "object", + "properties": { + "backup_config": { + "$ref": "#/components/schemas/BackupConfigSpec" + }, + "cpus": { + "type": "string", + "description": "The number of CPUs to allocate for the database and to use for tuning Postgres. Defaults to the number of available CPUs on the host. Can include an SI suffix, e.g. '500m' for 500 millicpus. Whether this limit is enforced depends on the orchestrator.", + "example": "500m", + "pattern": "^[0-9]+(\\.[0-9]{1,3}|m)?$" + }, + "database_name": { + "type": "string", + "description": "The name of the Postgres database.", + "example": "northwind", + "minLength": 1, + "maxLength": 31 + }, + "database_users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DatabaseUserSpec" + }, + "description": "The users to create for this database.", + "example": [ + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" + }, + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" }, + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" + } + ], + "maxItems": 16 + }, + "memory": { + "type": "string", + "description": "The amount of memory in SI or IEC notation to allocate for the database and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator.", + "example": "500M", + "maxLength": 16 + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DatabaseNodeSpec" + }, + "description": "The Spock nodes for this database.", + "example": [ { "backup_config": { "repositories": [ @@ -10321,53 +9906,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", "retention_full": 2, "retention_full_type": "count", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -10398,7 +9937,7 @@ }, "cpus": "500m", "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696" + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" ], "memory": "500M", "name": "n1", @@ -10474,7 +10013,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "s3_endpoint": "s3.us-east-1.amazonaws.com", "s3_key": "AKIAIOSFODNN7EXAMPLE", @@ -10487,7 +10026,7 @@ "target": "123456", "type": "xid" }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", "source_database_name": "northwind", "source_node_name": "n1" }, @@ -10509,53 +10048,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", "retention_full": 2, "retention_full_type": "count", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -10586,7 +10079,7 @@ }, "cpus": "500m", "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696" + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" ], "memory": "500M", "name": "n1", @@ -10662,7 +10155,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "s3_endpoint": "s3.us-east-1.amazonaws.com", "s3_key": "AKIAIOSFODNN7EXAMPLE", @@ -10675,7 +10168,7 @@ "target": "123456", "type": "xid" }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", "source_database_name": "northwind", "source_node_name": "n1" }, @@ -10714,6 +10207,47 @@ "restore_config": { "$ref": "#/components/schemas/RestoreConfigSpec" }, + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceSpec" + }, + "description": "Service instances to run alongside the database (e.g., MCP servers).", + "example": [ + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + } + ] + }, "spock_version": { "type": "string", "description": "The major version of the Spock extension.", @@ -10737,53 +10271,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", "retention_full": 2, "retention_full_type": "count", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -10873,53 +10361,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", "retention_full": 2, "retention_full_type": "count", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -10950,7 +10392,7 @@ }, "cpus": "500m", "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696" + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" ], "memory": "500M", "name": "n1", @@ -11026,7 +10468,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "s3_endpoint": "s3.us-east-1.amazonaws.com", "s3_key": "AKIAIOSFODNN7EXAMPLE", @@ -11039,7 +10481,7 @@ "target": "123456", "type": "xid" }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", "source_database_name": "northwind", "source_node_name": "n1" }, @@ -11118,7 +10560,7 @@ "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "s3_endpoint": "s3.us-east-1.amazonaws.com", "s3_key": "AKIAIOSFODNN7EXAMPLE", @@ -11131,10 +10573,44 @@ "target": "123456", "type": "xid" }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_id": "02f1a7db-fca8-4521-b57a-2a375c1ced51", "source_database_name": "northwind", "source_node_name": "n1" }, + "services": [ + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + } + ], "spock_version": "5" }, "required": [ @@ -11142,7 +10618,7 @@ "nodes" ] }, - "DatabaseSpec3": { + "DatabaseSpec2": { "type": "object", "properties": { "backup_config": { @@ -11174,7 +10650,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -11187,7 +10663,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -11200,7 +10676,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -11219,7 +10695,7 @@ "nodes": { "type": "array", "items": { - "$ref": "#/components/schemas/DatabaseNodeSpec3" + "$ref": "#/components/schemas/DatabaseNodeSpec2" }, "description": "The Spock nodes for this database.", "example": [ @@ -11248,7 +10724,126 @@ "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "s3_region": "us-east-1", "type": "s3" + } + ], + "schedules": [ + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + } + ] + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "500M", + "name": "n1", + "orchestrator_opts": { + "swarm": { + "extra_labels": { + "traefik.enable": "true", + "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + }, + "extra_networks": [ + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + } + ], + "extra_volumes": [ + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + } + ] + } + }, + "port": 5432, + "postgres_version": "17.6", + "postgresql_conf": { + "max_connections": 1000 + }, + "restore_config": { + "repository": { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" + }, + "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "source_node": "n1" + }, + { + "backup_config": { + "repositories": [ { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -11293,8 +10888,6 @@ }, "cpus": "500m", "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", - "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696" ], "memory": "500M", @@ -11423,6 +11016,51 @@ "restore_config": { "$ref": "#/components/schemas/RestoreConfigSpec" }, + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceSpec" + }, + "description": "Service instances to run alongside the database (e.g., MCP servers).", + "example": [ + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + } + ] + }, "spock_version": { "type": "string", "description": "The major version of the Spock extension.", @@ -11433,29 +11071,6 @@ "example": { "backup_config": { "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -11507,7 +11122,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -11520,7 +11135,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -11533,7 +11148,7 @@ "CREATEDB", "CREATEROLE" ], - "db_owner": false, + "db_owner": true, "password": "secret", "roles": [ "pgedge_superuser" @@ -11568,7 +11183,126 @@ "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "s3_region": "us-east-1", "type": "s3" + } + ], + "schedules": [ + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + } + ] + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "500M", + "name": "n1", + "orchestrator_opts": { + "swarm": { + "extra_labels": { + "traefik.enable": "true", + "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + }, + "extra_networks": [ + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + } + ], + "extra_volumes": [ + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + } + ] + } + }, + "port": 5432, + "postgres_version": "17.6", + "postgresql_conf": { + "max_connections": 1000 + }, + "restore_config": { + "repository": { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" + }, + "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "source_node": "n1" + }, + { + "backup_config": { + "repositories": [ { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -11613,8 +11347,6 @@ }, "cpus": "500m", "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", - "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696" ], "memory": "500M", @@ -11800,6 +11532,80 @@ "source_database_name": "northwind", "source_node_name": "n1" }, + "services": [ + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + } + ], "spock_version": "5" }, "required": [ @@ -11807,7 +11613,7 @@ "nodes" ] }, - "DatabaseSpec4": { + "DatabaseSpec3": { "type": "object", "properties": { "backup_config": { @@ -11878,42 +11684,19 @@ "memory": { "type": "string", "description": "The amount of memory in SI or IEC notation to allocate for the database and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator.", - "example": "500M", - "maxLength": 16 - }, - "nodes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DatabaseNodeSpec4" - }, - "description": "The Spock nodes for this database.", - "example": [ - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, + "example": "500M", + "maxLength": 16 + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DatabaseNodeSpec3" + }, + "description": "The Spock nodes for this database.", + "example": [ + { + "backup_config": { + "repositories": [ { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -12079,7 +11862,127 @@ "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "s3_region": "us-east-1", "type": "s3" + } + ], + "schedules": [ + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + } + ] + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "500M", + "name": "n1", + "orchestrator_opts": { + "swarm": { + "extra_labels": { + "traefik.enable": "true", + "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + }, + "extra_networks": [ + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + } + ], + "extra_volumes": [ + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + } + ] + } + }, + "port": 5432, + "postgres_version": "17.6", + "postgresql_conf": { + "max_connections": 1000 + }, + "restore_config": { + "repository": { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" + }, + "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "source_node": "n1" + }, + { + "backup_config": { + "repositories": [ { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", @@ -12188,689 +12091,1130 @@ "postgresql_conf": { "max_connections": 1000 }, - "restore_config": { - "repository": { + "restore_config": { + "repository": { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" + }, + "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "source_node": "n1" + } + ], + "minItems": 1, + "maxItems": 9 + }, + "orchestrator_opts": { + "$ref": "#/components/schemas/OrchestratorOpts" + }, + "port": { + "type": "integer", + "description": "The port used by the Postgres database. If the port is 0, each instance will be assigned a random port. If the port is unspecified, the database will not be exposed on any port, dependent on orchestrator support for that feature.", + "example": 5432, + "format": "int64", + "minimum": 0, + "maximum": 65535 + }, + "postgres_version": { + "type": "string", + "description": "The Postgres version in 'major.minor' format.", + "example": "17.6", + "pattern": "^\\d{2}\\.\\d{1,2}$" + }, + "postgresql_conf": { + "type": "object", + "description": "Additional postgresql.conf settings. Will be merged with the settings provided by control-plane.", + "example": { + "max_connections": 1000 + }, + "maxLength": 64, + "additionalProperties": true + }, + "restore_config": { + "$ref": "#/components/schemas/RestoreConfigSpec" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceSpec" + }, + "description": "Service instances to run alongside the database (e.g., MCP servers).", + "example": [ + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + } + ] + }, + "spock_version": { + "type": "string", + "description": "The major version of the Spock extension.", + "example": "5", + "pattern": "^\\d{1}$" + } + }, + "example": { + "backup_config": { + "repositories": [ + { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + } + ], + "schedules": [ + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + } + ] + }, + "cpus": "500m", + "database_name": "northwind", + "database_users": [ + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": false, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" + }, + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": false, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" + }, + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": false, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" + } + ], + "memory": "500M", + "nodes": [ + { + "backup_config": { + "repositories": [ + { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "azure_endpoint": "blob.core.usgovcloudapi.net", "azure_key": "YXpLZXk=", "base_path": "/backups", "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" }, "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "s3_endpoint": "s3.us-east-1.amazonaws.com", "s3_key": "AKIAIOSFODNN7EXAMPLE", "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "s3_region": "us-east-1", "type": "s3" + } + ], + "schedules": [ + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + } + ] }, - { - "backup_config": { - "repositories": [ + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "500M", + "name": "n1", + "orchestrator_opts": { + "swarm": { + "extra_labels": { + "traefik.enable": "true", + "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + }, + "extra_networks": [ { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" + "id": "traefik-public" }, { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" + "id": "traefik-public" }, { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", - "76f9b8c0-4958-11f0-a489-3bb29577c696" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } + "id": "traefik-public" + } + ], + "extra_volumes": [ + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + } + ] + } + }, + "port": 5432, + "postgres_version": "17.6", + "postgresql_conf": { + "max_connections": 1000 + }, + "restore_config": { + "repository": { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" + "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "source_node": "n1" + } + ], + "orchestrator_opts": { + "swarm": { + "extra_labels": { + "traefik.enable": "true", + "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + }, + "extra_networks": [ + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "source_database_name": "northwind", - "source_node_name": "n1" + "id": "traefik-public" }, - "source_node": "n1" - } - ], - "minItems": 1, - "maxItems": 9 - }, - "orchestrator_opts": { - "$ref": "#/components/schemas/OrchestratorOpts" - }, - "port": { - "type": "integer", - "description": "The port used by the Postgres database. If the port is 0, each instance will be assigned a random port. If the port is unspecified, the database will not be exposed on any port, dependent on orchestrator support for that feature.", - "example": 5432, - "format": "int64", - "minimum": 0, - "maximum": 65535 - }, - "postgres_version": { - "type": "string", - "description": "The Postgres version in 'major.minor' format.", - "example": "17.6", - "pattern": "^\\d{2}\\.\\d{1,2}$" + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + } + ], + "extra_volumes": [ + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + } + ] + } }, + "port": 5432, + "postgres_version": "17.6", "postgresql_conf": { - "type": "object", - "description": "Additional postgresql.conf settings. Will be merged with the settings provided by control-plane.", - "example": { - "max_connections": 1000 - }, - "maxLength": 64, - "additionalProperties": true + "max_connections": 1000 }, "restore_config": { - "$ref": "#/components/schemas/RestoreConfigSpec" - }, - "spock_version": { - "type": "string", - "description": "The major version of the Spock extension.", - "example": "5", - "pattern": "^\\d{1}$" - } - }, - "example": { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" + "repository": { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "database_name": "northwind", - "database_users": [ - { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": false, - "password": "secret", - "roles": [ - "pgedge_superuser" - ], - "username": "admin" + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" }, + "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "services": [ { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": false, - "password": "secret", - "roles": [ - "pgedge_superuser" + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" ], - "username": "admin" + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" }, { - "attributes": [ - "LOGIN", - "CREATEDB", - "CREATEROLE" - ], - "db_owner": false, - "password": "secret", - "roles": [ - "pgedge_superuser" + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" ], - "username": "admin" + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" } ], - "memory": "500M", - "nodes": [ - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" + "spock_version": "5" + }, + "required": [ + "database_name", + "nodes" + ] + }, + "DatabaseSpec4": { + "type": "object", + "properties": { + "backup_config": { + "$ref": "#/components/schemas/BackupConfigSpec" + }, + "cpus": { + "type": "string", + "description": "The number of CPUs to allocate for the database and to use for tuning Postgres. Defaults to the number of available CPUs on the host. Can include an SI suffix, e.g. '500m' for 500 millicpus. Whether this limit is enforced depends on the orchestrator.", + "example": "500m", + "pattern": "^[0-9]+(\\.[0-9]{1,3}|m)?$" + }, + "database_name": { + "type": "string", + "description": "The name of the Postgres database.", + "example": "northwind", + "minLength": 1, + "maxLength": 31 + }, + "database_users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DatabaseUserSpec" + }, + "description": "The users to create for this database.", + "example": [ + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" + }, + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" + }, + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" + } + ], + "maxItems": 16 + }, + "memory": { + "type": "string", + "description": "The amount of memory in SI or IEC notation to allocate for the database and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator.", + "example": "500M", + "maxLength": 16 + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DatabaseNodeSpec4" + }, + "description": "The Spock nodes for this database.", + "example": [ + { + "backup_config": { + "repositories": [ + { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { + { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + } + ], + "schedules": [ + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + } + ] + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "500M", + "name": "n1", + "orchestrator_opts": { + "swarm": { + "extra_labels": { + "traefik.enable": "true", + "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + }, + "extra_networks": [ + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + } + ], + "extra_volumes": [ + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + } + ] + } + }, + "port": 5432, + "postgres_version": "17.6", + "postgresql_conf": { + "max_connections": 1000 + }, + "restore_config": { + "repository": { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "azure_endpoint": "blob.core.usgovcloudapi.net", "azure_key": "YXpLZXk=", "base_path": "/backups", "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" }, "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "s3_endpoint": "s3.us-east-1.amazonaws.com", "s3_key": "AKIAIOSFODNN7EXAMPLE", "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "s3_region": "us-east-1", "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] + "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "source_node": "n1" }, - "cpus": "500m", - "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", - "76f9b8c0-4958-11f0-a489-3bb29577c696" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" - }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, + { + "backup_config": { + "repositories": [ { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" }, - "id": "traefik-public" + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" }, { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" }, - "id": "traefik-public" + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" } ], - "extra_volumes": [ + "schedules": [ { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" }, { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" }, { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" } ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 - }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696", + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "500M", + "name": "n1", + "orchestrator_opts": { + "swarm": { + "extra_labels": { + "traefik.enable": "true", + "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + }, + "extra_networks": [ + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + }, + { + "aliases": [ + "pg-db", + "db-alias" + ], + "driver_opts": { + "com.docker.network.endpoint.expose": "true" + }, + "id": "traefik-public" + } + ], + "extra_volumes": [ + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + }, + { + "destination_path": "/backups/container", + "host_path": "/Users/user/backups/host" + } + ] + } + }, + "port": 5432, + "postgres_version": "17.6", + "postgresql_conf": { + "max_connections": 1000 }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "source_database_name": "northwind", - "source_node_name": "n1" - }, - "source_node": "n1" - }, - { - "backup_config": { - "repositories": [ - { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" - }, - { + "restore_config": { + "repository": { "azure_account": "pgedge-backups", "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "azure_endpoint": "blob.core.usgovcloudapi.net", "azure_key": "YXpLZXk=", "base_path": "/backups", "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", - "storage-upload-chunk-size": "5MiB" + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" }, "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "gcs_endpoint": "localhost", "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "retention_full": 2, - "retention_full_type": "count", "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", "s3_endpoint": "s3.us-east-1.amazonaws.com", "s3_key": "AKIAIOSFODNN7EXAMPLE", "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "s3_region": "us-east-1", "type": "s3" - } - ], - "schedules": [ - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" }, - { - "cron_expression": "0 6 * * ?", - "id": "daily-full-backup", - "type": "full" - } - ] - }, - "cpus": "500m", - "host_ids": [ - "76f9b8c0-4958-11f0-a489-3bb29577c696", - "76f9b8c0-4958-11f0-a489-3bb29577c696" - ], - "memory": "500M", - "name": "n1", - "orchestrator_opts": { - "swarm": { - "extra_labels": { - "traefik.enable": "true", - "traefik.tcp.routers.mydb.rule": "HostSNI(`mydb.example.com`)" + "restore_options": { + "set": "20250505-153628F", + "target": "123456", + "type": "xid" }, - "extra_networks": [ - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - }, - { - "aliases": [ - "pg-db", - "db-alias" - ], - "driver_opts": { - "com.docker.network.endpoint.expose": "true" - }, - "id": "traefik-public" - } - ], - "extra_volumes": [ - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - }, - { - "destination_path": "/backups/container", - "host_path": "/Users/user/backups/host" - } - ] - } - }, - "port": 5432, - "postgres_version": "17.6", - "postgresql_conf": { - "max_connections": 1000 + "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "source_database_name": "northwind", + "source_node_name": "n1" + }, + "source_node": "n1" + } + ], + "minItems": 1, + "maxItems": 9 + }, + "orchestrator_opts": { + "$ref": "#/components/schemas/OrchestratorOpts" + }, + "port": { + "type": "integer", + "description": "The port used by the Postgres database. If the port is 0, each instance will be assigned a random port. If the port is unspecified, the database will not be exposed on any port, dependent on orchestrator support for that feature.", + "example": 5432, + "format": "int64", + "minimum": 0, + "maximum": 65535 + }, + "postgres_version": { + "type": "string", + "description": "The Postgres version in 'major.minor' format.", + "example": "17.6", + "pattern": "^\\d{2}\\.\\d{1,2}$" + }, + "postgresql_conf": { + "type": "object", + "description": "Additional postgresql.conf settings. Will be merged with the settings provided by control-plane.", + "example": { + "max_connections": 1000 + }, + "maxLength": 64, + "additionalProperties": true + }, + "restore_config": { + "$ref": "#/components/schemas/RestoreConfigSpec" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceSpec" + }, + "description": "Service instances to run alongside the database (e.g., MCP servers).", + "example": [ + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" }, - "restore_config": { - "repository": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + } + ] + }, + "spock_version": { + "type": "string", + "description": "The major version of the Spock extension.", + "example": "5", + "pattern": "^\\d{1}$" + } + }, + "example": { + "backup_config": { + "repositories": [ + { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" }, - "restore_options": { - "set": "20250505-153628F", - "target": "123456", - "type": "xid" + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, + { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab", + "storage-upload-chunk-size": "5MiB" }, - "source_database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", - "source_database_name": "northwind", - "source_node_name": "n1" + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "retention_full": 2, + "retention_full_type": "count", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + } + ], + "schedules": [ + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" }, - "source_node": "n1" + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + }, + { + "cron_expression": "0 6 * * ?", + "id": "daily-full-backup", + "type": "full" + } + ] + }, + "cpus": "500m", + "database_name": "northwind", + "database_users": [ + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" + }, + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" }, + { + "attributes": [ + "LOGIN", + "CREATEDB", + "CREATEROLE" + ], + "db_owner": true, + "password": "secret", + "roles": [ + "pgedge_superuser" + ], + "username": "admin" + } + ], + "memory": "500M", + "nodes": [ { "backup_config": { "repositories": [ @@ -12941,6 +13285,7 @@ }, "cpus": "500m", "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696", "76f9b8c0-4958-11f0-a489-3bb29577c696" ], @@ -13127,6 +13472,72 @@ "source_database_name": "northwind", "source_node_name": "n1" }, + "services": [ + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + }, + { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "76f9b8c0-4958-11f0-a489-3bb29577c696" + ], + "memory": "512M", + "port": 0, + "service_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "service_type": "mcp", + "version": "latest" + } + ], "spock_version": "5" }, "required": [ @@ -13141,7 +13552,7 @@ "type": "array", "items": { "type": "string", - "example": "Fuga molestiae." + "example": "Sint iure eum ducimus quia deserunt animi." }, "description": "The attributes to assign to this database user.", "example": [ @@ -13154,7 +13565,7 @@ "db_owner": { "type": "boolean", "description": "If true, this user will be granted database ownership.", - "example": true + "example": false }, "password": { "type": "string", @@ -13166,7 +13577,7 @@ "type": "array", "items": { "type": "string", - "example": "Quas nostrum eos reprehenderit harum sapiente qui." + "example": "Nihil qui non quae sint ea." }, "description": "The roles to assign to this database user.", "example": [ @@ -13225,7 +13636,7 @@ "type": "array", "items": { "type": "string", - "example": "Eius reiciendis accusamus veritatis quo." + "example": "Sapiente qui ullam." }, "description": "The Etcd client endpoint for this cluster member.", "example": [ @@ -13241,7 +13652,7 @@ "type": "array", "items": { "type": "string", - "example": "Non modi explicabo illum alias qui." + "example": "Eos reprehenderit." }, "description": "The Etcd peer endpoint for this cluster member.", "example": [ @@ -13271,7 +13682,7 @@ "type": "array", "items": { "type": "string", - "example": "Quam id aut." + "example": "Harum voluptatum nulla commodi quo." }, "description": "Optional network-scoped aliases for the container.", "example": [ @@ -13288,7 +13699,7 @@ }, "additionalProperties": { "type": "string", - "example": "Et labore in dolor quisquam placeat." + "example": "Facere eius totam." } }, "id": { @@ -13389,7 +13800,7 @@ "type": "boolean", "description": "If true, skip the health validations that prevent running failover on a healthy cluster.", "default": false, - "example": true + "example": false } }, "example": { @@ -13417,6 +13828,42 @@ "task" ] }, + "HealthCheckResult": { + "type": "object", + "properties": { + "checked_at": { + "type": "string", + "description": "The time this health check was performed.", + "example": "2025-01-28T10:00:00Z", + "format": "date-time" + }, + "message": { + "type": "string", + "description": "Optional message about the health status.", + "example": "Connection refused" + }, + "status": { + "type": "string", + "description": "The health status.", + "example": "healthy", + "enum": [ + "healthy", + "unhealthy", + "unknown" + ] + } + }, + "description": "Health check result for a service instance.", + "example": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "required": [ + "status", + "checked_at" + ] + }, "Host": { "type": "object", "properties": { @@ -13488,10 +13935,6 @@ "postgres_version": "17.6", "spock_version": "5" }, - { - "postgres_version": "17.6", - "spock_version": "5" - }, { "postgres_version": "17.6", "spock_version": "5" @@ -13546,10 +13989,6 @@ "postgres_version": "17.6", "spock_version": "5" }, - { - "postgres_version": "17.6", - "spock_version": "5" - }, { "postgres_version": "17.6", "spock_version": "5" @@ -13602,7 +14041,16 @@ "type": "object", "description": "The status of each component of the host.", "example": { - "Dolorem itaque aut aut cupiditate sunt quibusdam.": { + "Eaque eos quos autem perspiciatis.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Labore et qui quod veniam.": { "details": { "alarms": [ "3: NOSPACE" @@ -13611,7 +14059,7 @@ "error": "failed to connect to etcd", "healthy": false }, - "Ipsa nihil facere ad.": { + "Omnis non nesciunt consequuntur reprehenderit esse.": { "details": { "alarms": [ "3: NOSPACE" @@ -13644,7 +14092,25 @@ }, "example": { "components": { - "Quisquam dignissimos veritatis et omnis.": { + "Quae in cumque rerum ipsam.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Quis ut.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Quisquam dolores veritatis odio voluptatem dicta.": { "details": { "alarms": [ "3: NOSPACE" @@ -13688,7 +14154,7 @@ "created_at": { "type": "string", "description": "The time that the instance was created.", - "example": "1971-02-20T15:20:44Z", + "example": "1975-10-10T05:27:56Z", "format": "date-time" }, "error": { @@ -13734,13 +14200,13 @@ "status_updated_at": { "type": "string", "description": "The time that the instance status information was last updated.", - "example": "1995-04-11T11:49:24Z", + "example": "1987-12-22T17:07:25Z", "format": "date-time" }, "updated_at": { "type": "string", "description": "The time that the instance was last modified.", - "example": "1983-10-23T08:18:23Z", + "example": "1984-06-21T18:43:23Z", "format": "date-time" } }, @@ -13751,7 +14217,7 @@ "ipv4_address": "10.24.34.2", "port": 5432 }, - "created_at": "2014-04-20T04:34:11Z", + "created_at": "2008-02-03T11:21:30Z", "error": "failed to get patroni status: connection refused", "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", @@ -13779,9 +14245,9 @@ ], "version": "4.10.0" }, - "state": "degraded", - "status_updated_at": "2011-10-12T20:33:23Z", - "updated_at": "1994-09-09T06:25:01Z" + "state": "available", + "status_updated_at": "2010-07-06T06:39:04Z", + "updated_at": "1976-11-29T11:38:34Z" }, "required": [ "id", @@ -13836,44 +14302,6 @@ "status_updated_at": "1974-12-13T04:15:04Z", "updated_at": "2006-10-18T16:07:16Z" }, - { - "connection_info": { - "hostname": "i-0123456789abcdef.ec2.internal", - "ipv4_address": "10.24.34.2", - "port": 5432 - }, - "created_at": "1987-03-24T21:22:02Z", - "error": "failed to get patroni status: connection refused", - "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", - "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", - "node_name": "n1", - "postgres": { - "patroni_paused": true, - "patroni_state": "unknown", - "pending_restart": false, - "role": "primary", - "version": "18.1" - }, - "spock": { - "read_only": "off", - "subscriptions": [ - { - "name": "sub_n1n2", - "provider_node": "n2", - "status": "down" - }, - { - "name": "sub_n1n2", - "provider_node": "n2", - "status": "down" - } - ], - "version": "4.10.0" - }, - "state": "creating", - "status_updated_at": "1974-12-13T04:15:04Z", - "updated_at": "2006-10-18T16:07:16Z" - }, { "connection_info": { "hostname": "i-0123456789abcdef.ec2.internal", @@ -13971,7 +14399,7 @@ }, "description": "Postgres status information for a pgEdge instance.", "example": { - "patroni_paused": true, + "patroni_paused": false, "patroni_state": "unknown", "pending_restart": false, "role": "primary", @@ -13987,7 +14415,7 @@ "created_at": { "type": "string", "description": "The time that the instance was created.", - "example": "2006-12-23T09:53:03Z", + "example": "1970-08-02T22:23:02Z", "format": "date-time" }, "error": { @@ -14033,13 +14461,13 @@ "status_updated_at": { "type": "string", "description": "The time that the instance status information was last updated.", - "example": "1976-03-01T20:13:25Z", + "example": "1986-12-16T22:41:27Z", "format": "date-time" }, "updated_at": { "type": "string", "description": "The time that the instance was last modified.", - "example": "2012-08-16T06:20:51Z", + "example": "1991-12-26T07:39:17Z", "format": "date-time" } }, @@ -14050,7 +14478,7 @@ "ipv4_address": "10.24.34.2", "port": 5432 }, - "created_at": "1977-03-14T09:54:12Z", + "created_at": "2008-07-30T07:06:38Z", "error": "failed to get patroni status: connection refused", "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", @@ -14058,13 +14486,23 @@ "postgres": { "patroni_paused": false, "patroni_state": "unknown", - "pending_restart": false, + "pending_restart": true, "role": "primary", "version": "18.1" }, "spock": { "read_only": "off", "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, { "name": "sub_n1n2", "provider_node": "n2", @@ -14079,8 +14517,8 @@ "version": "4.10.0" }, "state": "failed", - "status_updated_at": "1988-02-08T05:38:12Z", - "updated_at": "2002-03-20T15:42:21Z" + "status_updated_at": "1987-08-24T17:24:39Z", + "updated_at": "2015-05-08T21:09:57Z" }, "required": [ "id", @@ -14104,7 +14542,7 @@ "ipv4_address": "10.24.34.2", "port": 5432 }, - "created_at": "1972-02-12T09:45:07Z", + "created_at": "1995-05-15T08:45:56Z", "error": "failed to get patroni status: connection refused", "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", @@ -14112,13 +14550,23 @@ "postgres": { "patroni_paused": false, "patroni_state": "unknown", - "pending_restart": false, + "pending_restart": true, "role": "primary", "version": "18.1" }, "spock": { "read_only": "off", "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, { "name": "sub_n1n2", "provider_node": "n2", @@ -14132,9 +14580,9 @@ ], "version": "4.10.0" }, - "state": "stopped", - "status_updated_at": "1989-09-01T22:57:29Z", - "updated_at": "1978-08-28T00:21:42Z" + "state": "modifying", + "status_updated_at": "2010-12-24T23:39:10Z", + "updated_at": "2014-01-25T23:48:24Z" }, { "connection_info": { @@ -14142,7 +14590,7 @@ "ipv4_address": "10.24.34.2", "port": 5432 }, - "created_at": "1972-02-12T09:45:07Z", + "created_at": "1995-05-15T08:45:56Z", "error": "failed to get patroni status: connection refused", "host_id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", "id": "a67cbb36-c3c3-49c9-8aac-f4a0438a883d", @@ -14150,13 +14598,23 @@ "postgres": { "patroni_paused": false, "patroni_state": "unknown", - "pending_restart": false, + "pending_restart": true, "role": "primary", "version": "18.1" }, "spock": { "read_only": "off", "subscriptions": [ + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "down" + }, { "name": "sub_n1n2", "provider_node": "n2", @@ -14170,9 +14628,9 @@ ], "version": "4.10.0" }, - "state": "stopped", - "status_updated_at": "1989-09-01T22:57:29Z", - "updated_at": "1978-08-28T00:21:42Z" + "state": "modifying", + "status_updated_at": "2010-12-24T23:39:10Z", + "updated_at": "2014-01-25T23:48:24Z" } ] }, @@ -14361,6 +14819,26 @@ "$ref": "#/components/schemas/Task" }, "example": [ + { + "completed_at": "2025-06-18T16:52:35Z", + "created_at": "2025-06-18T16:52:05Z", + "database_id": "storefront", + "entity_id": "storefront", + "scope": "database", + "status": "completed", + "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", + "type": "create" + }, + { + "completed_at": "2025-06-18T16:52:35Z", + "created_at": "2025-06-18T16:52:05Z", + "database_id": "storefront", + "entity_id": "storefront", + "scope": "database", + "status": "completed", + "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", + "type": "create" + }, { "completed_at": "2025-06-18T16:52:35Z", "created_at": "2025-06-18T16:52:05Z", @@ -14466,6 +14944,63 @@ } ] }, + { + "cohort": { + "control_available": true, + "member_id": "lah4bsznw6kc0hp7biylmmmll", + "type": "swarm" + }, + "cpus": 4, + "data_dir": "/data", + "default_pgedge_version": { + "postgres_version": "17.6", + "spock_version": "5" + }, + "etcd_mode": "server", + "hostname": "i-0123456789abcdef.ec2.internal", + "id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "ipv4_address": "10.24.34.2", + "memory": "16GiB", + "orchestrator": "swarm", + "status": { + "components": { + "Enim et voluptatum ex ea dolore.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Tenetur nostrum repellendus sint qui.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + } + }, + "state": "available", + "updated_at": "2021-07-01T12:34:56Z" + }, + "supported_pgedge_versions": [ + { + "postgres_version": "17.6", + "spock_version": "5" + }, + { + "postgres_version": "17.6", + "spock_version": "5" + }, + { + "postgres_version": "17.6", + "spock_version": "5" + } + ] + }, { "cohort": { "control_available": true, @@ -14643,6 +15178,63 @@ } ] }, + { + "cohort": { + "control_available": true, + "member_id": "lah4bsznw6kc0hp7biylmmmll", + "type": "swarm" + }, + "cpus": 4, + "data_dir": "/data", + "default_pgedge_version": { + "postgres_version": "17.6", + "spock_version": "5" + }, + "etcd_mode": "server", + "hostname": "i-0123456789abcdef.ec2.internal", + "id": "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "ipv4_address": "10.24.34.2", + "memory": "16GiB", + "orchestrator": "swarm", + "status": { + "components": { + "Enim et voluptatum ex ea dolore.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + }, + "Tenetur nostrum repellendus sint qui.": { + "details": { + "alarms": [ + "3: NOSPACE" + ] + }, + "error": "failed to connect to etcd", + "healthy": false + } + }, + "state": "available", + "updated_at": "2021-07-01T12:34:56Z" + }, + "supported_pgedge_versions": [ + { + "postgres_version": "17.6", + "spock_version": "5" + }, + { + "postgres_version": "17.6", + "spock_version": "5" + }, + { + "postgres_version": "17.6", + "spock_version": "5" + } + ] + }, { "cohort": { "control_available": true, @@ -14715,26 +15307,6 @@ "$ref": "#/components/schemas/Task" }, "example": [ - { - "completed_at": "2025-06-18T16:52:35Z", - "created_at": "2025-06-18T16:52:05Z", - "database_id": "storefront", - "entity_id": "storefront", - "scope": "database", - "status": "completed", - "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", - "type": "create" - }, - { - "completed_at": "2025-06-18T16:52:35Z", - "created_at": "2025-06-18T16:52:05Z", - "database_id": "storefront", - "entity_id": "storefront", - "scope": "database", - "status": "completed", - "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", - "type": "create" - }, { "completed_at": "2025-06-18T16:52:35Z", "created_at": "2025-06-18T16:52:05Z", @@ -14934,6 +15506,42 @@ "spock_version" ] }, + "PortMapping": { + "type": "object", + "properties": { + "container_port": { + "type": "integer", + "description": "The port number inside the container.", + "example": 8080, + "format": "int64", + "minimum": 1, + "maximum": 65535 + }, + "host_port": { + "type": "integer", + "description": "The port number on the host (if port-forwarded).", + "example": 8080, + "format": "int64", + "minimum": 1, + "maximum": 65535 + }, + "name": { + "type": "string", + "description": "The name of the port (e.g., 'http', 'web-client').", + "example": "web-client" + } + }, + "description": "Port mapping information for a service instance.", + "example": { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + "required": [ + "name", + "container_port" + ] + }, "RemoveHostResponse": { "type": "object", "properties": { @@ -14957,16 +15565,6 @@ "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", "type": "create" }, - { - "completed_at": "2025-06-18T16:52:35Z", - "created_at": "2025-06-18T16:52:05Z", - "database_id": "storefront", - "entity_id": "storefront", - "scope": "database", - "status": "completed", - "task_id": "019783f4-75f4-71e7-85a3-c9b96b345d77", - "type": "create" - }, { "completed_at": "2025-06-18T16:52:35Z", "created_at": "2025-06-18T16:52:05Z", @@ -15070,7 +15668,7 @@ "maxLength": 32, "additionalProperties": { "type": "string", - "example": "Ea omnis ut dolor dolorem impedit laudantium." + "example": "Et labore in dolor quisquam placeat." } }, "source_database_id": { @@ -15141,7 +15739,7 @@ "type": "array", "items": { "type": "string", - "example": "Consequatur ex possimus magni quaerat." + "example": "Voluptates laborum earum illo aut et." }, "description": "The nodes to restore. Defaults to all nodes if empty or unspecified.", "example": [ @@ -15234,14 +15832,84 @@ "instances": [ { "connection_info": { - "hostname": "i-0123456789abcdef.ec2.internal", - "ipv4_address": "10.24.34.2", + "hostname": "i-0123456789abcdef.ec2.internal", + "ipv4_address": "10.24.34.2", + "port": 5432 + }, + "created_at": "2025-06-18T16:52:22Z", + "host_id": "us-east-1", + "id": "storefront-n1-689qacsi", + "node_name": "n1", + "postgres": { + "patroni_state": "running", + "role": "primary", + "version": "18.1" + }, + "spock": { + "read_only": "off", + "subscriptions": [ + { + "name": "sub_n1n3", + "provider_node": "n3", + "status": "replicating" + }, + { + "name": "sub_n1n2", + "provider_node": "n2", + "status": "replicating" + } + ], + "version": "4.0.10" + }, + "state": "available", + "status_updated_at": "2025-06-18T17:58:56Z", + "updated_at": "2025-06-18T17:54:36Z" + }, + { + "connection_info": { + "hostname": "i-058731542fee493f.ec2.internal", + "ipv4_address": "10.24.35.2", + "port": 5432 + }, + "created_at": "2025-06-18T16:52:22Z", + "host_id": "ap-south-1", + "id": "storefront-n2-9ptayhma", + "node_name": "n2", + "postgres": { + "patroni_state": "running", + "role": "primary", + "version": "18.1" + }, + "spock": { + "read_only": "off", + "subscriptions": [ + { + "name": "sub_n2n1", + "provider_node": "n1", + "status": "replicating" + }, + { + "name": "sub_n2n3", + "provider_node": "n3", + "status": "replicating" + } + ], + "version": "4.0.10" + }, + "state": "available", + "status_updated_at": "2025-06-18T17:58:56Z", + "updated_at": "2025-06-18T17:54:01Z" + }, + { + "connection_info": { + "hostname": "i-494027b7b53f6a23.ec2.internal", + "ipv4_address": "10.24.36.2", "port": 5432 }, "created_at": "2025-06-18T16:52:22Z", - "host_id": "us-east-1", - "id": "storefront-n1-689qacsi", - "node_name": "n1", + "host_id": "eu-central-1", + "id": "storefront-n3-ant97dj4", + "node_name": "n3", "postgres": { "patroni_state": "running", "role": "primary", @@ -15251,12 +15919,12 @@ "read_only": "off", "subscriptions": [ { - "name": "sub_n1n3", - "provider_node": "n3", + "name": "sub_n3n1", + "provider_node": "n1", "status": "replicating" }, { - "name": "sub_n1n2", + "name": "sub_n3n2", "provider_node": "n2", "status": "replicating" } @@ -15265,303 +15933,948 @@ }, "state": "available", "status_updated_at": "2025-06-18T17:58:56Z", - "updated_at": "2025-06-18T17:54:36Z" + "updated_at": "2025-06-18T17:54:01Z" + } + ], + "spec": { + "database_name": "storefront", + "database_users": [ + { + "attributes": [ + "SUPERUSER", + "LOGIN" + ], + "db_owner": true, + "username": "admin" + } + ], + "nodes": [ + { + "host_ids": [ + "us-east-1" + ], + "name": "n1" + }, + { + "host_ids": [ + "ap-south-1" + ], + "name": "n2" + }, + { + "host_ids": [ + "eu-central-1" + ], + "name": "n3" + } + ], + "port": 5432, + "postgres_version": "17.6", + "spock_version": "5" + }, + "state": "restoring", + "updated_at": "2025-06-18T17:58:59Z" + }, + "node_tasks": [ + { + "created_at": "2025-06-18T17:58:59Z", + "database_id": "storefront", + "node_name": "n1", + "parent_id": "01978431-b628-758a-aec6-03b331fa1a17", + "status": "pending", + "task_id": "01978431-b62b-723b-a09c-e4072cd64bdb", + "type": "node_restore" + }, + { + "created_at": "2025-06-18T17:58:59Z", + "database_id": "storefront", + "node_name": "n2", + "parent_id": "01978431-b628-758a-aec6-03b331fa1a17", + "status": "pending", + "task_id": "01978431-b62c-7593-aad8-43b03df2031b", + "type": "node_restore" + }, + { + "created_at": "2025-06-18T17:58:59Z", + "database_id": "storefront", + "node_name": "n3", + "parent_id": "01978431-b628-758a-aec6-03b331fa1a17", + "status": "pending", + "task_id": "01978431-b62d-7b65-ab09-272d0b2fea91", + "type": "node_restore" + } + ], + "task": { + "created_at": "2025-06-18T17:58:59Z", + "database_id": "storefront", + "status": "pending", + "task_id": "01978431-b628-758a-aec6-03b331fa1a17", + "type": "restore" + } + }, + "required": [ + "task", + "node_tasks", + "database" + ] + }, + "RestoreRepositorySpec": { + "type": "object", + "properties": { + "azure_account": { + "type": "string", + "description": "The Azure account name for this repository. Only applies when type = 'azure'.", + "example": "pgedge-backups", + "minLength": 3, + "maxLength": 24 + }, + "azure_container": { + "type": "string", + "description": "The Azure container name for this repository. Only applies when type = 'azure'.", + "example": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "minLength": 3, + "maxLength": 63 + }, + "azure_endpoint": { + "type": "string", + "description": "The optional Azure endpoint for this repository. Only applies when type = 'azure'.", + "example": "blob.core.usgovcloudapi.net", + "minLength": 3, + "maxLength": 128 + }, + "azure_key": { + "type": "string", + "description": "An optional Azure storage account access key to use for this repository. If not provided, pgbackrest will use the VM's managed identity.", + "example": "YXpLZXk=", + "maxLength": 128 + }, + "base_path": { + "type": "string", + "description": "The base path within the repository to store backups. Required for type = 'posix' and 'cifs'.", + "example": "/backups", + "maxLength": 256 + }, + "custom_options": { + "type": "object", + "description": "Additional options to apply to this repository.", + "example": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" + }, + "additionalProperties": { + "type": "string", + "example": "Quam id aut." + } + }, + "gcs_bucket": { + "type": "string", + "description": "The GCS bucket name for this repository. Only applies when type = 'gcs'.", + "example": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "minLength": 3, + "maxLength": 63 + }, + "gcs_endpoint": { + "type": "string", + "description": "The optional GCS endpoint for this repository. Only applies when type = 'gcs'.", + "example": "localhost", + "minLength": 3, + "maxLength": 128 + }, + "gcs_key": { + "type": "string", + "description": "Optional base64-encoded private key data. If omitted, pgbackrest will use the service account attached to the instance profile.", + "example": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "maxLength": 1024 + }, + "id": { + "type": "string", + "description": "A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens.", + "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "minLength": 1, + "maxLength": 63 + }, + "s3_bucket": { + "type": "string", + "description": "The S3 bucket name for this repository. Only applies when type = 's3'.", + "example": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "minLength": 3, + "maxLength": 63 + }, + "s3_endpoint": { + "type": "string", + "description": "The optional S3 endpoint for this repository. Only applies when type = 's3'.", + "example": "s3.us-east-1.amazonaws.com", + "minLength": 3, + "maxLength": 128 + }, + "s3_key": { + "type": "string", + "description": "An optional AWS access key ID to use for this repository. If not provided, pgbackrest will use the default credential provider chain.", + "example": "AKIAIOSFODNN7EXAMPLE", + "maxLength": 128 + }, + "s3_key_secret": { + "type": "string", + "description": "The corresponding secret for the AWS access key ID in s3_key.", + "example": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "maxLength": 128 + }, + "s3_region": { + "type": "string", + "description": "The region of the S3 bucket for this repository. Only applies when type = 's3'.", + "example": "us-east-1", + "minLength": 1, + "maxLength": 32 + }, + "type": { + "type": "string", + "description": "The type of this repository.", + "example": "s3", + "enum": [ + "s3", + "gcs", + "azure", + "posix", + "cifs" + ] + } + }, + "example": { + "azure_account": "pgedge-backups", + "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "azure_endpoint": "blob.core.usgovcloudapi.net", + "azure_key": "YXpLZXk=", + "base_path": "/backups", + "custom_options": { + "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" + }, + "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "gcs_endpoint": "localhost", + "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", + "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", + "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", + "s3_endpoint": "s3.us-east-1.amazonaws.com", + "s3_key": "AKIAIOSFODNN7EXAMPLE", + "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3_region": "us-east-1", + "type": "s3" + }, + "required": [ + "type" + ] + }, + "ServiceInstanceStatus": { + "type": "object", + "properties": { + "container_id": { + "type": "string", + "description": "The Docker container ID.", + "example": "a1b2c3d4e5f6" + }, + "health_check": { + "$ref": "#/components/schemas/HealthCheckResult" + }, + "hostname": { + "type": "string", + "description": "The hostname of the service instance.", + "example": "mcp-server-host-1.internal" + }, + "image_version": { + "type": "string", + "description": "The container image version currently running.", + "example": "1.0.0" + }, + "ipv4_address": { + "type": "string", + "description": "The IPv4 address of the service instance.", + "example": "10.0.1.5", + "format": "ipv4" + }, + "last_health_at": { + "type": "string", + "description": "The time of the last health check attempt.", + "example": "2025-01-28T10:00:00Z", + "format": "date-time" + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PortMapping" + }, + "description": "Port mappings for this service instance.", + "example": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" }, { - "connection_info": { - "hostname": "i-058731542fee493f.ec2.internal", - "ipv4_address": "10.24.35.2", - "port": 5432 - }, - "created_at": "2025-06-18T16:52:22Z", - "host_id": "ap-south-1", - "id": "storefront-n2-9ptayhma", - "node_name": "n2", - "postgres": { - "patroni_state": "running", - "role": "primary", - "version": "18.1" - }, - "spock": { - "read_only": "off", - "subscriptions": [ - { - "name": "sub_n2n1", - "provider_node": "n1", - "status": "replicating" - }, - { - "name": "sub_n2n3", - "provider_node": "n3", - "status": "replicating" - } - ], - "version": "4.0.10" - }, - "state": "available", - "status_updated_at": "2025-06-18T17:58:56Z", - "updated_at": "2025-06-18T17:54:01Z" + "container_port": 8080, + "host_port": 8080, + "name": "web-client" }, { - "connection_info": { - "hostname": "i-494027b7b53f6a23.ec2.internal", - "ipv4_address": "10.24.36.2", - "port": 5432 - }, - "created_at": "2025-06-18T16:52:22Z", - "host_id": "eu-central-1", - "id": "storefront-n3-ant97dj4", - "node_name": "n3", - "postgres": { - "patroni_state": "running", - "role": "primary", - "version": "18.1" - }, - "spock": { - "read_only": "off", - "subscriptions": [ - { - "name": "sub_n3n1", - "provider_node": "n1", - "status": "replicating" - }, - { - "name": "sub_n3n2", - "provider_node": "n2", - "status": "replicating" - } - ], - "version": "4.0.10" - }, - "state": "available", - "status_updated_at": "2025-06-18T17:58:56Z", - "updated_at": "2025-06-18T17:54:01Z" + "container_port": 8080, + "host_port": 8080, + "name": "web-client" } - ], - "spec": { - "database_name": "storefront", - "database_users": [ - { - "attributes": [ - "SUPERUSER", - "LOGIN" - ], - "db_owner": true, - "username": "admin" - } - ], - "nodes": [ - { - "host_ids": [ - "us-east-1" - ], - "name": "n1" - }, - { - "host_ids": [ - "ap-south-1" - ], - "name": "n2" - }, - { - "host_ids": [ - "eu-central-1" - ], - "name": "n3" - } - ], - "port": 5432, - "postgres_version": "17.6", - "spock_version": "5" - }, - "state": "restoring", - "updated_at": "2025-06-18T17:58:59Z" + ] }, - "node_tasks": [ - { - "created_at": "2025-06-18T17:58:59Z", - "database_id": "storefront", - "node_name": "n1", - "parent_id": "01978431-b628-758a-aec6-03b331fa1a17", - "status": "pending", - "task_id": "01978431-b62b-723b-a09c-e4072cd64bdb", - "type": "node_restore" - }, + "service_ready": { + "type": "boolean", + "description": "Whether the service is ready to accept requests.", + "example": true + } + }, + "description": "Runtime status information for a service instance.", + "example": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ { - "created_at": "2025-06-18T17:58:59Z", - "database_id": "storefront", - "node_name": "n2", - "parent_id": "01978431-b628-758a-aec6-03b331fa1a17", - "status": "pending", - "task_id": "01978431-b62c-7593-aad8-43b03df2031b", - "type": "node_restore" + "container_port": 8080, + "host_port": 8080, + "name": "web-client" }, { - "created_at": "2025-06-18T17:58:59Z", - "database_id": "storefront", - "node_name": "n3", - "parent_id": "01978431-b628-758a-aec6-03b331fa1a17", - "status": "pending", - "task_id": "01978431-b62d-7b65-ab09-272d0b2fea91", - "type": "node_restore" + "container_port": 8080, + "host_port": 8080, + "name": "web-client" } ], - "task": { - "created_at": "2025-06-18T17:58:59Z", - "database_id": "storefront", - "status": "pending", - "task_id": "01978431-b628-758a-aec6-03b331fa1a17", - "type": "restore" + "service_ready": true + } + }, + "ServiceSpec": { + "type": "object", + "properties": { + "config": { + "type": "object", + "description": "Service-specific configuration. For MCP services, this includes llm_provider, llm_model, and provider-specific API keys.", + "example": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "additionalProperties": true + }, + "cpus": { + "type": "string", + "description": "The number of CPUs to allocate for this service. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if unspecified.", + "example": "500m", + "pattern": "^[0-9]+(\\.[0-9]{1,3}|m)?$" + }, + "host_ids": { + "type": "array", + "items": { + "type": "string", + "description": "A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens.", + "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "minLength": 1, + "maxLength": 63 + }, + "description": "The IDs of the hosts that should run this service. One service instance will be created per host.", + "example": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "minItems": 1 + }, + "memory": { + "type": "string", + "description": "The amount of memory in SI or IEC notation to allocate for this service. Defaults to container defaults if unspecified.", + "example": "512M", + "maxLength": 16 + }, + "port": { + "type": "integer", + "description": "The port to publish the service on the host. If 0, Docker assigns a random port. If unspecified, no port is published and the service is not accessible from outside the Docker network.", + "example": 0, + "format": "int64", + "minimum": 0, + "maximum": 65535 + }, + "service_id": { + "type": "string", + "description": "A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens.", + "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "minLength": 1, + "maxLength": 63 + }, + "service_type": { + "type": "string", + "description": "The type of service to run.", + "example": "mcp", + "enum": [ + "mcp" + ] + }, + "version": { + "type": "string", + "description": "The version of the service in semver format (e.g., '1.0.0') or the literal 'latest'.", + "example": "latest", + "pattern": "^(\\d+\\.\\d+\\.\\d+|latest)$" } }, + "example": { + "config": { + "llm_model": "gpt-4", + "llm_provider": "openai", + "openai_api_key": "sk-..." + }, + "cpus": "500m", + "host_ids": [ + "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "de3b1388-1f0c-42f1-a86c-59ab72f255ec", + "de3b1388-1f0c-42f1-a86c-59ab72f255ec" + ], + "memory": "512M", + "port": 0, + "service_id": "analytics-service", + "service_type": "mcp", + "version": "latest" + }, "required": [ - "task", - "node_tasks", - "database" + "service_id", + "service_type", + "version", + "host_ids", + "config" ] }, - "RestoreRepositorySpec": { + "Serviceinstance": { "type": "object", "properties": { - "azure_account": { + "created_at": { "type": "string", - "description": "The Azure account name for this repository. Only applies when type = 'azure'.", - "example": "pgedge-backups", - "minLength": 3, - "maxLength": 24 + "description": "The time that the service instance was created.", + "example": "2025-01-28T10:00:00Z", + "format": "date-time" + }, + "database_id": { + "type": "string", + "description": "A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens.", + "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "minLength": 1, + "maxLength": 63 + }, + "error": { + "type": "string", + "description": "An error message if the service instance is in an error state.", + "example": "failed to start container: image not found" + }, + "host_id": { + "type": "string", + "description": "The ID of the host this service instance is running on.", + "example": "host-1" }, - "azure_container": { + "service_id": { "type": "string", - "description": "The Azure container name for this repository. Only applies when type = 'azure'.", - "example": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "minLength": 3, - "maxLength": 63 + "description": "The service ID from the DatabaseSpec.", + "example": "mcp-server" }, - "azure_endpoint": { + "service_instance_id": { "type": "string", - "description": "The optional Azure endpoint for this repository. Only applies when type = 'azure'.", - "example": "blob.core.usgovcloudapi.net", - "minLength": 3, - "maxLength": 128 + "description": "Unique identifier for the service instance.", + "example": "mcp-server-host-1" }, - "azure_key": { + "state": { "type": "string", - "description": "An optional Azure storage account access key to use for this repository. If not provided, pgbackrest will use the VM's managed identity.", - "example": "YXpLZXk=", - "maxLength": 128 + "description": "Current state of the service instance.", + "example": "running", + "enum": [ + "creating", + "running", + "failed", + "deleting" + ] }, - "base_path": { + "status": { + "$ref": "#/components/schemas/ServiceInstanceStatus" + }, + "updated_at": { "type": "string", - "description": "The base path within the repository to store backups. Required for type = 'posix' and 'cifs'.", - "example": "/backups", - "maxLength": 256 + "description": "The time that the service instance was last updated.", + "example": "2025-01-28T10:05:00Z", + "format": "date-time" + } + }, + "description": "A service instance running on a host alongside the database.", + "example": { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true }, - "custom_options": { - "type": "object", - "description": "Additional options to apply to this repository.", - "example": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" + "updated_at": "2025-01-28T10:05:00Z" + }, + "required": [ + "service_instance_id", + "service_id", + "database_id", + "host_id", + "state", + "created_at", + "updated_at" + ] + }, + "ServiceinstanceCollection": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Serviceinstance" + }, + "example": [ + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true }, - "additionalProperties": { - "type": "string", - "example": "Non quae." - } + "updated_at": "2025-01-28T10:05:00Z" }, - "gcs_bucket": { - "type": "string", - "description": "The GCS bucket name for this repository. Only applies when type = 'gcs'.", - "example": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "minLength": 3, - "maxLength": 63 + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" }, - "gcs_endpoint": { - "type": "string", - "description": "The optional GCS endpoint for this repository. Only applies when type = 'gcs'.", - "example": "localhost", - "minLength": 3, - "maxLength": 128 + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" }, - "gcs_key": { + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "production", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + } + ] + }, + "ServiceinstanceResponseBody": { + "type": "object", + "properties": { + "created_at": { "type": "string", - "description": "Optional base64-encoded private key data. If omitted, pgbackrest will use the service account attached to the instance profile.", - "example": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "maxLength": 1024 + "description": "The time that the service instance was created.", + "example": "2025-01-28T10:00:00Z", + "format": "date-time" }, - "id": { + "database_id": { "type": "string", - "description": "A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens.", + "description": "Unique identifier for the database.", "example": "76f9b8c0-4958-11f0-a489-3bb29577c696", "minLength": 1, "maxLength": 63 }, - "s3_bucket": { - "type": "string", - "description": "The S3 bucket name for this repository. Only applies when type = 's3'.", - "example": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "minLength": 3, - "maxLength": 63 - }, - "s3_endpoint": { + "error": { "type": "string", - "description": "The optional S3 endpoint for this repository. Only applies when type = 's3'.", - "example": "s3.us-east-1.amazonaws.com", - "minLength": 3, - "maxLength": 128 + "description": "An error message if the service instance is in an error state.", + "example": "failed to start container: image not found" }, - "s3_key": { + "host_id": { "type": "string", - "description": "An optional AWS access key ID to use for this repository. If not provided, pgbackrest will use the default credential provider chain.", - "example": "AKIAIOSFODNN7EXAMPLE", - "maxLength": 128 + "description": "The ID of the host this service instance is running on.", + "example": "host-1" }, - "s3_key_secret": { + "service_id": { "type": "string", - "description": "The corresponding secret for the AWS access key ID in s3_key.", - "example": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "maxLength": 128 + "description": "The service ID from the DatabaseSpec.", + "example": "mcp-server" }, - "s3_region": { + "service_instance_id": { "type": "string", - "description": "The region of the S3 bucket for this repository. Only applies when type = 's3'.", - "example": "us-east-1", - "minLength": 1, - "maxLength": 32 + "description": "Unique identifier for the service instance.", + "example": "mcp-server-host-1" }, - "type": { + "state": { "type": "string", - "description": "The type of this repository.", - "example": "s3", + "description": "Current state of the service instance.", + "example": "running", "enum": [ - "s3", - "gcs", - "azure", - "posix", - "cifs" + "creating", + "running", + "failed", + "deleting" ] + }, + "status": { + "$ref": "#/components/schemas/ServiceInstanceStatus" + }, + "updated_at": { + "type": "string", + "description": "The time that the service instance was last updated.", + "example": "2025-01-28T10:05:00Z", + "format": "date-time" } }, + "description": "A service instance running on a host alongside the database. (default view)", "example": { - "azure_account": "pgedge-backups", - "azure_container": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "azure_endpoint": "blob.core.usgovcloudapi.net", - "azure_key": "YXpLZXk=", - "base_path": "/backups", - "custom_options": { - "s3-kms-key-id": "1234abcd-12ab-34cd-56ef-1234567890ab" + "created_at": "2025-01-28T10:00:00Z", + "database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true }, - "gcs_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "gcs_endpoint": "localhost", - "gcs_key": "ZXhhbXBsZSBnY3Mga2V5Cg==", - "id": "f6b84a99-5e91-4203-be1e-131fe82e5984", - "s3_bucket": "pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1", - "s3_endpoint": "s3.us-east-1.amazonaws.com", - "s3_key": "AKIAIOSFODNN7EXAMPLE", - "s3_key_secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "s3_region": "us-east-1", - "type": "s3" + "updated_at": "2025-01-28T10:05:00Z" }, "required": [ - "type" + "service_instance_id", + "service_id", + "database_id", + "host_id", + "state", + "created_at", + "updated_at" + ] + }, + "ServiceinstanceResponseBodyCollection": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceinstanceResponseBody" + }, + "description": "ServiceinstanceCollectionResponseBody is the result type for an array of ServiceinstanceResponseBody (default view)", + "example": [ + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + }, + { + "created_at": "2025-01-28T10:00:00Z", + "database_id": "76f9b8c0-4958-11f0-a489-3bb29577c696", + "error": "failed to start container: image not found", + "host_id": "host-1", + "service_id": "mcp-server", + "service_instance_id": "mcp-server-host-1", + "state": "running", + "status": { + "container_id": "a1b2c3d4e5f6", + "health_check": { + "checked_at": "2025-01-28T10:00:00Z", + "message": "Connection refused", + "status": "healthy" + }, + "hostname": "mcp-server-host-1.internal", + "image_version": "1.0.0", + "ipv4_address": "10.0.1.5", + "last_health_at": "2025-01-28T10:00:00Z", + "ports": [ + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + }, + { + "container_port": 8080, + "host_port": 8080, + "name": "web-client" + } + ], + "service_ready": true + }, + "updated_at": "2025-01-28T10:05:00Z" + } ] }, "StartInstanceResponse": { @@ -15624,7 +16937,7 @@ }, "additionalProperties": { "type": "string", - "example": "Voluptates laborum earum illo aut et." + "example": "Aut cupiditate sunt." } }, "extra_networks": { @@ -15956,6 +17269,14 @@ "message": "task started", "timestamp": "2025-05-29T15:43:13Z" }, + { + "fields": { + "option.enabled": true, + "status": "creating" + }, + "message": "task started", + "timestamp": "2025-05-29T15:43:13Z" + }, { "fields": { "option.enabled": true, diff --git a/api/apiv1/gen/http/openapi3.yaml b/api/apiv1/gen/http/openapi3.yaml index f61f7c9b..65918e4b 100644 --- a/api/apiv1/gen/http/openapi3.yaml +++ b/api/apiv1/gen/http/openapi3.yaml @@ -1578,7 +1578,7 @@ paths: $ref: '#/components/schemas/FailoverDatabaseNodeRequest2' example: candidate_instance_id: 68f50878-44d2-4524-a823-e31bd478706d-n1-689qacsi - skip_validation: true + skip_validation: false responses: "200": description: OK response. @@ -2600,14 +2600,6 @@ paths: status: completed task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 type: create - - completed_at: "2025-06-18T16:52:35Z" - created_at: "2025-06-18T16:52:05Z" - database_id: storefront - entity_id: storefront - scope: database - status: completed - task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 - type: create "400": description: 'invalid_input: Bad Request response.' content: @@ -2687,13 +2679,19 @@ paths: orchestrator: swarm status: components: - Praesentium repellendus et et harum cum.: + Cum possimus minima.: details: alarms: - '3: NOSPACE' error: failed to connect to etcd healthy: false - Ullam autem praesentium est.: + Et et.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Pariatur praesentium.: details: alarms: - '3: NOSPACE' @@ -3303,6 +3301,26 @@ components: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 + - azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + storage-upload-chunk-size: 5MiB + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: f6b84a99-5e91-4203-be1e-131fe82e5984 + retention_full: 2 + retention_full_type: count + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 minItems: 1 schedules: type: array @@ -3418,7 +3436,7 @@ components: key: value additionalProperties: type: string - example: Possimus error eligendi recusandae. + example: Qui et eius reiciendis accusamus. backup_options: type: object description: Options for the backup. @@ -3426,7 +3444,7 @@ components: archive-check: "n" additionalProperties: type: string - example: Sed neque eos rerum quia. + example: Quo recusandae quibusdam fuga molestiae. type: type: string description: The type of backup. @@ -3482,7 +3500,7 @@ components: storage-upload-chunk-size: 5MiB additionalProperties: type: string - example: Quam sint iure eum ducimus quia. + example: Impedit laudantium et quia commodi consequatur. gcs_bucket: type: string description: The GCS bucket name for this repository. Only applies when type = 'gcs'. @@ -4005,12 +4023,14 @@ components: maxLength: 63 instances: $ref: '#/components/schemas/InstanceResponseBodyCollection' + service_instances: + $ref: '#/components/schemas/ServiceinstanceResponseBodyCollection' spec: $ref: '#/components/schemas/DatabaseSpec3' state: type: string description: Current state of the database. - example: failed + example: unknown enum: - creating - modifying @@ -4140,6 +4160,8 @@ components: - created_at - updated_at - state + - instances + - service_instances CreateDatabaseRequest: type: object properties: @@ -4285,12 +4307,14 @@ components: maxLength: 63 instances: $ref: '#/components/schemas/InstanceCollection' + service_instances: + $ref: '#/components/schemas/ServiceinstanceCollection' spec: $ref: '#/components/schemas/DatabaseSpec' state: type: string description: Current state of the database. - example: deleting + example: modifying enum: - creating - modifying @@ -4428,6 +4452,85 @@ components: state: creating status_updated_at: "1974-12-13T04:15:04Z" updated_at: "2006-10-18T16:07:16Z" + service_instances: + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" spec: backup_config: repositories: @@ -4468,7 +4571,7 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - pgedge_superuser @@ -4477,7 +4580,7 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - pgedge_superuser @@ -4486,7 +4589,7 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - pgedge_superuser @@ -4592,105 +4695,6 @@ components: source_database_name: northwind source_node_name: n1 source_node: n1 - - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - de3b1388-1f0c-42f1-a86c-59ab72f255ec - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 - source_database_name: northwind - source_node_name: n1 - source_node: n1 orchestrator_opts: swarm: extra_labels: @@ -4752,8 +4756,45 @@ components: source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 source_database_name: northwind source_node_name: n1 + services: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest spock_version: "5" - state: backing_up + state: failed tenant_id: 8210ec10-2dca-406c-ac4a-0661d2189954 updated_at: "2025-01-01T02:30:00Z" required: @@ -4761,6 +4802,8 @@ components: - created_at - updated_at - state + - instances + - service_instances DatabaseCollection: type: array items: @@ -4881,6 +4924,85 @@ components: state: creating status_updated_at: "1974-12-13T04:15:04Z" updated_at: "2006-10-18T16:07:16Z" + service_instances: + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" spec: backup_config: repositories: @@ -4921,7 +5043,7 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - pgedge_superuser @@ -4930,7 +5052,7 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - pgedge_superuser @@ -4939,7 +5061,7 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - pgedge_superuser @@ -5045,105 +5167,6 @@ components: source_database_name: northwind source_node_name: n1 source_node: n1 - - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - de3b1388-1f0c-42f1-a86c-59ab72f255ec - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 - source_database_name: northwind - source_node_name: n1 - source_node: n1 orchestrator_opts: swarm: extra_labels: @@ -5205,6 +5228,43 @@ components: source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 source_database_name: northwind source_node_name: n1 + services: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest spock_version: "5" state: creating tenant_id: 8210ec10-2dca-406c-ac4a-0661d2189954 @@ -5324,6 +5384,85 @@ components: state: creating status_updated_at: "1974-12-13T04:15:04Z" updated_at: "2006-10-18T16:07:16Z" + service_instances: + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" spec: backup_config: repositories: @@ -5364,7 +5503,7 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - pgedge_superuser @@ -5373,7 +5512,7 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - pgedge_superuser @@ -5382,7 +5521,7 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - pgedge_superuser @@ -5488,105 +5627,6 @@ components: source_database_name: northwind source_node_name: n1 source_node: n1 - - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - de3b1388-1f0c-42f1-a86c-59ab72f255ec - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 - source_database_name: northwind - source_node_name: n1 - source_node: n1 orchestrator_opts: swarm: extra_labels: @@ -5648,132 +5688,629 @@ components: source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 source_database_name: northwind source_node_name: n1 + services: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest spock_version: "5" state: creating tenant_id: 8210ec10-2dca-406c-ac4a-0661d2189954 updated_at: "2025-01-01T02:30:00Z" - DatabaseNodeSpec: - type: object - properties: - backup_config: - $ref: '#/components/schemas/BackupConfigSpec' - cpus: - type: string - description: The number of CPUs to allocate for the database on this node and to use for tuning Postgres. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Cannot allocate units smaller than 1m. Defaults to the number of available CPUs on the host if 0 or unspecified. Cannot allocate more CPUs than are available on the host. Whether this limit is enforced depends on the orchestrator. - example: 500m - pattern: ^[0-9]+(\.[0-9]{1,3}|m)?$ - host_ids: - type: array - items: - type: string - description: A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens. - example: 76f9b8c0-4958-11f0-a489-3bb29577c696 - minLength: 1 - maxLength: 63 - description: The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas. - example: - - de3b1388-1f0c-42f1-a86c-59ab72f255ec - - de3b1388-1f0c-42f1-a86c-59ab72f255ec - - de3b1388-1f0c-42f1-a86c-59ab72f255ec - minItems: 1 - memory: - type: string - description: The amount of memory in SI or IEC notation to allocate for the database on this node and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator. - example: 500M - maxLength: 16 - name: - type: string - description: The name of the database node. - example: n1 - pattern: n[0-9]+ - orchestrator_opts: - $ref: '#/components/schemas/OrchestratorOpts' - port: - type: integer - description: The port used by the Postgres database for this node. Overrides the Postgres port set in the DatabaseSpec. - example: 5432 - format: int64 - minimum: 0 - maximum: 65535 - postgres_version: - type: string - description: The Postgres version for this node in 'major.minor' format. Overrides the Postgres version set in the DatabaseSpec. - example: "17.6" - pattern: ^\d{2}\.\d{1,2}$ - postgresql_conf: - type: object - description: Additional postgresql.conf settings for this particular node. Will be merged with the settings provided by control-plane. - example: - max_connections: 1000 - additionalProperties: true - restore_config: - $ref: '#/components/schemas/RestoreConfigSpec' - source_node: - type: string - description: The name of the source node to use for sync. This is typically the node (like 'n1') from which the data will be copied to initialize this new node. - example: n1 - example: - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - de3b1388-1f0c-42f1-a86c-59ab72f255ec - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public + - created_at: "2025-01-01T01:30:00Z" + id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 + instances: + - connection_info: + hostname: i-0123456789abcdef.ec2.internal + ipv4_address: 10.24.34.2 + port: 5432 + created_at: "1987-03-24T21:22:02Z" + error: 'failed to get patroni status: connection refused' + host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec + id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d + node_name: n1 + postgres: + patroni_paused: true + patroni_state: unknown + pending_restart: false + role: primary + version: "18.1" + spock: + read_only: "off" + subscriptions: + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down + version: 4.10.0 + state: creating + status_updated_at: "1974-12-13T04:15:04Z" + updated_at: "2006-10-18T16:07:16Z" + - connection_info: + hostname: i-0123456789abcdef.ec2.internal + ipv4_address: 10.24.34.2 + port: 5432 + created_at: "1987-03-24T21:22:02Z" + error: 'failed to get patroni status: connection refused' + host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec + id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d + node_name: n1 + postgres: + patroni_paused: true + patroni_state: unknown + pending_restart: false + role: primary + version: "18.1" + spock: + read_only: "off" + subscriptions: + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down + version: 4.10.0 + state: creating + status_updated_at: "1974-12-13T04:15:04Z" + updated_at: "2006-10-18T16:07:16Z" + - connection_info: + hostname: i-0123456789abcdef.ec2.internal + ipv4_address: 10.24.34.2 + port: 5432 + created_at: "1987-03-24T21:22:02Z" + error: 'failed to get patroni status: connection refused' + host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec + id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d + node_name: n1 + postgres: + patroni_paused: true + patroni_state: unknown + pending_restart: false + role: primary + version: "18.1" + spock: + read_only: "off" + subscriptions: + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down + version: 4.10.0 + state: creating + status_updated_at: "1974-12-13T04:15:04Z" + updated_at: "2006-10-18T16:07:16Z" + - connection_info: + hostname: i-0123456789abcdef.ec2.internal + ipv4_address: 10.24.34.2 + port: 5432 + created_at: "1987-03-24T21:22:02Z" + error: 'failed to get patroni status: connection refused' + host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec + id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d + node_name: n1 + postgres: + patroni_paused: true + patroni_state: unknown + pending_restart: false + role: primary + version: "18.1" + spock: + read_only: "off" + subscriptions: + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down + version: 4.10.0 + state: creating + status_updated_at: "1974-12-13T04:15:04Z" + updated_at: "2006-10-18T16:07:16Z" + service_instances: + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + spec: + backup_config: + repositories: + - azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + storage-upload-chunk-size: 5MiB + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: f6b84a99-5e91-4203-be1e-131fe82e5984 + retention_full: 2 + retention_full_type: count + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + schedules: + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + cpus: 500m + database_name: northwind + database_users: + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + memory: 500M + nodes: + - backup_config: + repositories: + - azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + storage-upload-chunk-size: 5MiB + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: f6b84a99-5e91-4203-be1e-131fe82e5984 + retention_full: 2 + retention_full_type: count + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + schedules: + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 500M + name: n1 + orchestrator_opts: + swarm: + extra_labels: + traefik.enable: "true" + traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) + extra_networks: + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + extra_volumes: + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + port: 5432 + postgres_version: "17.6" + postgresql_conf: + max_connections: 1000 + restore_config: + repository: + azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: f6b84a99-5e91-4203-be1e-131fe82e5984 + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + restore_options: + set: 20250505-153628F + target: "123456" + type: xid + source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 + source_database_name: northwind + source_node_name: n1 + source_node: n1 + orchestrator_opts: + swarm: + extra_labels: + traefik.enable: "true" + traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) + extra_networks: + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + extra_volumes: + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + port: 5432 + postgres_version: "17.6" + postgresql_conf: + max_connections: 1000 + restore_config: + repository: + azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: f6b84a99-5e91-4203-be1e-131fe82e5984 + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + restore_options: + set: 20250505-153628F + target: "123456" + type: xid + source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 + source_database_name: northwind + source_node_name: n1 + services: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + spock_version: "5" + state: creating + tenant_id: 8210ec10-2dca-406c-ac4a-0661d2189954 + updated_at: "2025-01-01T02:30:00Z" + DatabaseNodeSpec: + type: object + properties: + backup_config: + $ref: '#/components/schemas/BackupConfigSpec' + cpus: + type: string + description: The number of CPUs to allocate for the database on this node and to use for tuning Postgres. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Cannot allocate units smaller than 1m. Defaults to the number of available CPUs on the host if 0 or unspecified. Cannot allocate more CPUs than are available on the host. Whether this limit is enforced depends on the orchestrator. + example: 500m + pattern: ^[0-9]+(\.[0-9]{1,3}|m)?$ + host_ids: + type: array + items: + type: string + description: A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens. + example: 76f9b8c0-4958-11f0-a489-3bb29577c696 + minLength: 1 + maxLength: 63 + description: The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas. + example: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + minItems: 1 + memory: + type: string + description: The amount of memory in SI or IEC notation to allocate for the database on this node and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator. + example: 500M + maxLength: 16 + name: + type: string + description: The name of the database node. + example: n1 + pattern: n[0-9]+ + orchestrator_opts: + $ref: '#/components/schemas/OrchestratorOpts' + port: + type: integer + description: The port used by the Postgres database for this node. Overrides the Postgres port set in the DatabaseSpec. + example: 5432 + format: int64 + minimum: 0 + maximum: 65535 + postgres_version: + type: string + description: The Postgres version for this node in 'major.minor' format. Overrides the Postgres version set in the DatabaseSpec. + example: "17.6" + pattern: ^\d{2}\.\d{1,2}$ + postgresql_conf: + type: object + description: Additional postgresql.conf settings for this particular node. Will be merged with the settings provided by control-plane. + example: + max_connections: 1000 + additionalProperties: true + restore_config: + $ref: '#/components/schemas/RestoreConfigSpec' + source_node: + type: string + description: The name of the source node to use for sync. This is typically the node (like 'n1') from which the data will be copied to initialize this new node. + example: n1 + example: + backup_config: + repositories: + - azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + storage-upload-chunk-size: 5MiB + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: f6b84a99-5e91-4203-be1e-131fe82e5984 + retention_full: 2 + retention_full_type: count + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + schedules: + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 500M + name: n1 + orchestrator_opts: + swarm: + extra_labels: + traefik.enable: "true" + traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) + extra_networks: + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public extra_volumes: - destination_path: /backups/container host_path: /Users/user/backups/host @@ -5895,46 +6432,6 @@ components: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 schedules: - cron_expression: 0 6 * * ? id: daily-full-backup @@ -6097,26 +6594,6 @@ components: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 schedules: - cron_expression: 0 6 * * ? id: daily-full-backup @@ -6130,6 +6607,7 @@ components: cpus: 500m host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 orchestrator_opts: @@ -6217,7 +6695,6 @@ components: description: The IDs of the hosts that should run this node. When multiple hosts are specified, one host will chosen as a primary, and the others will be read replicas. example: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 minItems: 1 memory: type: string @@ -6311,7 +6788,6 @@ components: cpus: 500m host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 orchestrator_opts: @@ -6377,461 +6853,392 @@ components: source_node_name: n1 source_node: n1 required: - - name - - host_ids - DatabaseSpec: - type: object - properties: - backup_config: - $ref: '#/components/schemas/BackupConfigSpec' - cpus: - type: string - description: The number of CPUs to allocate for the database and to use for tuning Postgres. Defaults to the number of available CPUs on the host. Can include an SI suffix, e.g. '500m' for 500 millicpus. Whether this limit is enforced depends on the orchestrator. - example: 500m - pattern: ^[0-9]+(\.[0-9]{1,3}|m)?$ - database_name: - type: string - description: The name of the Postgres database. - example: northwind - minLength: 1 - maxLength: 31 - database_users: - type: array - items: - $ref: '#/components/schemas/DatabaseUserSpec' - description: The users to create for this database. - example: - - attributes: - - LOGIN - - CREATEDB - - CREATEROLE - db_owner: false - password: secret - roles: - - pgedge_superuser - username: admin - - attributes: - - LOGIN - - CREATEDB - - CREATEROLE - db_owner: false - password: secret - roles: - - pgedge_superuser - username: admin - - attributes: - - LOGIN - - CREATEDB - - CREATEROLE - db_owner: false - password: secret - roles: - - pgedge_superuser - username: admin - maxItems: 16 - memory: - type: string - description: The amount of memory in SI or IEC notation to allocate for the database and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator. - example: 500M - maxLength: 16 - nodes: - type: array - items: - $ref: '#/components/schemas/DatabaseNodeSpec' - description: The Spock nodes for this database. - example: - - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - de3b1388-1f0c-42f1-a86c-59ab72f255ec - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 - source_database_name: northwind - source_node_name: n1 - source_node: n1 - minItems: 1 - maxItems: 9 - orchestrator_opts: - $ref: '#/components/schemas/OrchestratorOpts' - port: - type: integer - description: The port used by the Postgres database. If the port is 0, each instance will be assigned a random port. If the port is unspecified, the database will not be exposed on any port, dependent on orchestrator support for that feature. - example: 5432 - format: int64 - minimum: 0 - maximum: 65535 - postgres_version: - type: string - description: The Postgres version in 'major.minor' format. - example: "17.6" - pattern: ^\d{2}\.\d{1,2}$ - postgresql_conf: - type: object - description: Additional postgresql.conf settings. Will be merged with the settings provided by control-plane. - example: - max_connections: 1000 - maxLength: 64 - additionalProperties: true - restore_config: - $ref: '#/components/schemas/RestoreConfigSpec' - spock_version: - type: string - description: The major version of the Spock extension. - example: "5" - pattern: ^\d{1}$ - example: + - name + - host_ids + DatabaseSpec: + type: object + properties: backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - database_name: northwind + $ref: '#/components/schemas/BackupConfigSpec' + cpus: + type: string + description: The number of CPUs to allocate for the database and to use for tuning Postgres. Defaults to the number of available CPUs on the host. Can include an SI suffix, e.g. '500m' for 500 millicpus. Whether this limit is enforced depends on the orchestrator. + example: 500m + pattern: ^[0-9]+(\.[0-9]{1,3}|m)?$ + database_name: + type: string + description: The name of the Postgres database. + example: northwind + minLength: 1 + maxLength: 31 database_users: - - attributes: - - LOGIN - - CREATEDB - - CREATEROLE - db_owner: false - password: secret - roles: - - pgedge_superuser - username: admin - - attributes: - - LOGIN - - CREATEDB - - CREATEROLE - db_owner: false - password: secret - roles: - - pgedge_superuser - username: admin - - attributes: - - LOGIN - - CREATEDB - - CREATEROLE - db_owner: false - password: secret - roles: - - pgedge_superuser - username: admin - memory: 500M + type: array + items: + $ref: '#/components/schemas/DatabaseUserSpec' + description: The users to create for this database. + example: + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + maxItems: 16 + memory: + type: string + description: The amount of memory in SI or IEC notation to allocate for the database and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator. + example: 500M + maxLength: 16 nodes: - - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - de3b1388-1f0c-42f1-a86c-59ab72f255ec - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 - source_database_name: northwind - source_node_name: n1 - source_node: n1 - - backup_config: - repositories: - - azure_account: pgedge-backups + type: array + items: + $ref: '#/components/schemas/DatabaseNodeSpec' + description: The Spock nodes for this database. + example: + - backup_config: + repositories: + - azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + storage-upload-chunk-size: 5MiB + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: f6b84a99-5e91-4203-be1e-131fe82e5984 + retention_full: 2 + retention_full_type: count + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + schedules: + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 500M + name: n1 + orchestrator_opts: + swarm: + extra_labels: + traefik.enable: "true" + traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) + extra_networks: + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + extra_volumes: + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + port: 5432 + postgres_version: "17.6" + postgresql_conf: + max_connections: 1000 + restore_config: + repository: + azure_account: pgedge-backups azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 azure_endpoint: blob.core.usgovcloudapi.net azure_key: YXpLZXk= base_path: /backups custom_options: s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 gcs_endpoint: localhost gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== id: f6b84a99-5e91-4203-be1e-131fe82e5984 - retention_full: 2 - retention_full_type: count s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 s3_endpoint: s3.us-east-1.amazonaws.com s3_key: AKIAIOSFODNN7EXAMPLE s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - de3b1388-1f0c-42f1-a86c-59ab72f255ec - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: f6b84a99-5e91-4203-be1e-131fe82e5984 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 - source_database_name: northwind - source_node_name: n1 - source_node: n1 + restore_options: + set: 20250505-153628F + target: "123456" + type: xid + source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 + source_database_name: northwind + source_node_name: n1 + source_node: n1 + - backup_config: + repositories: + - azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + storage-upload-chunk-size: 5MiB + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: f6b84a99-5e91-4203-be1e-131fe82e5984 + retention_full: 2 + retention_full_type: count + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + schedules: + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 500M + name: n1 + orchestrator_opts: + swarm: + extra_labels: + traefik.enable: "true" + traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) + extra_networks: + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + extra_volumes: + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + port: 5432 + postgres_version: "17.6" + postgresql_conf: + max_connections: 1000 + restore_config: + repository: + azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: f6b84a99-5e91-4203-be1e-131fe82e5984 + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + restore_options: + set: 20250505-153628F + target: "123456" + type: xid + source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 + source_database_name: northwind + source_node_name: n1 + source_node: n1 + minItems: 1 + maxItems: 9 + orchestrator_opts: + $ref: '#/components/schemas/OrchestratorOpts' + port: + type: integer + description: The port used by the Postgres database. If the port is 0, each instance will be assigned a random port. If the port is unspecified, the database will not be exposed on any port, dependent on orchestrator support for that feature. + example: 5432 + format: int64 + minimum: 0 + maximum: 65535 + postgres_version: + type: string + description: The Postgres version in 'major.minor' format. + example: "17.6" + pattern: ^\d{2}\.\d{1,2}$ + postgresql_conf: + type: object + description: Additional postgresql.conf settings. Will be merged with the settings provided by control-plane. + example: + max_connections: 1000 + maxLength: 64 + additionalProperties: true + restore_config: + $ref: '#/components/schemas/RestoreConfigSpec' + services: + type: array + items: + $ref: '#/components/schemas/ServiceSpec' + description: Service instances to run alongside the database (e.g., MCP servers). + example: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + spock_version: + type: string + description: The major version of the Spock extension. + example: "5" + pattern: ^\d{1}$ + example: + backup_config: + repositories: + - azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + storage-upload-chunk-size: 5MiB + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: f6b84a99-5e91-4203-be1e-131fe82e5984 + retention_full: 2 + retention_full_type: count + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + schedules: + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + cpus: 500m + database_name: northwind + database_users: + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + memory: 500M + nodes: - backup_config: repositories: - azure_account: pgedge-backups @@ -6992,6 +7399,31 @@ components: source_database_id: 02f1a7db-fca8-4521-b57a-2a375c1ced51 source_database_name: northwind source_node_name: n1 + services: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest spock_version: "5" required: - database_name @@ -7023,179 +7455,40 @@ components: - CREATEDB - CREATEROLE db_owner: true - password: secret - roles: - - pgedge_superuser - username: admin - - attributes: - - LOGIN - - CREATEDB - - CREATEROLE - db_owner: true - password: secret - roles: - - pgedge_superuser - username: admin - - attributes: - - LOGIN - - CREATEDB - - CREATEROLE - db_owner: true - password: secret - roles: - - pgedge_superuser - username: admin - maxItems: 16 - memory: - type: string - description: The amount of memory in SI or IEC notation to allocate for the database and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator. - example: 500M - maxLength: 16 - nodes: - type: array - items: - $ref: '#/components/schemas/DatabaseNodeSpec2' - description: The Spock nodes for this database. - example: - - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - source_database_name: northwind - source_node_name: n1 - source_node: n1 + password: secret + roles: + - pgedge_superuser + username: admin + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + maxItems: 16 + memory: + type: string + description: The amount of memory in SI or IEC notation to allocate for the database and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator. + example: 500M + maxLength: 16 + nodes: + type: array + items: + $ref: '#/components/schemas/DatabaseNodeSpec2' + description: The Spock nodes for this database. + example: - backup_config: repositories: - azure_account: pgedge-backups @@ -7218,46 +7511,6 @@ components: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 schedules: - cron_expression: 0 6 * * ? id: daily-full-backup @@ -7357,46 +7610,6 @@ components: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 schedules: - cron_expression: 0 6 * * ? id: daily-full-backup @@ -7499,6 +7712,40 @@ components: additionalProperties: true restore_config: $ref: '#/components/schemas/RestoreConfigSpec' + services: + type: array + items: + $ref: '#/components/schemas/ServiceSpec' + description: Service instances to run alongside the database (e.g., MCP servers). + example: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest spock_version: type: string description: The major version of the Spock extension. @@ -7527,46 +7774,6 @@ components: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 schedules: - cron_expression: 0 6 * * ? id: daily-full-backup @@ -7631,26 +7838,85 @@ components: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 + schedules: + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 500M + name: n1 + orchestrator_opts: + swarm: + extra_labels: + traefik.enable: "true" + traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) + extra_networks: + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + extra_volumes: + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + port: 5432 + postgres_version: "17.6" + postgresql_conf: + max_connections: 1000 + restore_config: + repository: + azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + restore_options: + set: 20250505-153628F + target: "123456" + type: xid + source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + source_database_name: northwind + source_node_name: n1 + source_node: n1 + - backup_config: + repositories: - azure_account: pgedge-backups azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 azure_endpoint: blob.core.usgovcloudapi.net @@ -7809,6 +8075,63 @@ components: source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 source_database_name: northwind source_node_name: n1 + services: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest spock_version: "5" required: - database_name @@ -7896,6 +8219,86 @@ components: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 + schedules: + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 500M + name: n1 + orchestrator_opts: + swarm: + extra_labels: + traefik.enable: "true" + traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) + extra_networks: + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + extra_volumes: + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + port: 5432 + postgres_version: "17.6" + postgresql_conf: + max_connections: 1000 + restore_config: + repository: + azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + restore_options: + set: 20250505-153628F + target: "123456" + type: xid + source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + source_database_name: northwind + source_node_name: n1 + source_node: n1 + - backup_config: + repositories: - azure_account: pgedge-backups azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 azure_endpoint: blob.core.usgovcloudapi.net @@ -7930,6 +8333,105 @@ components: host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 500M + name: n1 + orchestrator_opts: + swarm: + extra_labels: + traefik.enable: "true" + traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) + extra_networks: + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + - aliases: + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + extra_volumes: + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + port: 5432 + postgres_version: "17.6" + postgresql_conf: + max_connections: 1000 + restore_config: + repository: + azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + restore_options: + set: 20250505-153628F + target: "123456" + type: xid + source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + source_database_name: northwind + source_node_name: n1 + source_node: n1 + - backup_config: + repositories: + - azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + storage-upload-chunk-size: 5MiB + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + retention_full: 2 + retention_full_type: count + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + schedules: + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + - cron_expression: 0 6 * * ? + id: daily-full-backup + type: full + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 @@ -8020,34 +8522,72 @@ components: additionalProperties: true restore_config: $ref: '#/components/schemas/RestoreConfigSpec' - spock_version: - type: string - description: The major version of the Spock extension. - example: "5" - pattern: ^\d{1}$ - example: - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 + services: + type: array + items: + $ref: '#/components/schemas/ServiceSpec' + description: Service instances to run alongside the database (e.g., MCP servers). + example: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + spock_version: + type: string + description: The major version of the Spock extension. + example: "5" + pattern: ^\d{1}$ + example: + backup_config: + repositories: - azure_account: pgedge-backups azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 azure_endpoint: blob.core.usgovcloudapi.net @@ -8132,26 +8672,6 @@ components: s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_region: us-east-1 type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 schedules: - cron_expression: 0 6 * * ? id: daily-full-backup @@ -8166,7 +8686,6 @@ components: host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 orchestrator_opts: @@ -8250,233 +8769,140 @@ components: com.docker.network.endpoint.expose: "true" id: traefik-public - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - source_database_name: northwind - source_node_name: n1 - spock_version: "5" - required: - - database_name - - nodes - DatabaseSpec4: - type: object - properties: - backup_config: - $ref: '#/components/schemas/BackupConfigSpec' - cpus: - type: string - description: The number of CPUs to allocate for the database and to use for tuning Postgres. Defaults to the number of available CPUs on the host. Can include an SI suffix, e.g. '500m' for 500 millicpus. Whether this limit is enforced depends on the orchestrator. - example: 500m - pattern: ^[0-9]+(\.[0-9]{1,3}|m)?$ - database_name: - type: string - description: The name of the Postgres database. - example: northwind - minLength: 1 - maxLength: 31 - database_users: - type: array - items: - $ref: '#/components/schemas/DatabaseUserSpec' - description: The users to create for this database. - example: - - attributes: - - LOGIN - - CREATEDB - - CREATEROLE - db_owner: false - password: secret - roles: - - pgedge_superuser - username: admin - - attributes: - - LOGIN - - CREATEDB - - CREATEROLE - db_owner: false - password: secret - roles: - - pgedge_superuser - username: admin - - attributes: - - LOGIN - - CREATEDB - - CREATEROLE - db_owner: false - password: secret - roles: - - pgedge_superuser - username: admin - maxItems: 16 - memory: - type: string - description: The amount of memory in SI or IEC notation to allocate for the database and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator. - example: 500M - maxLength: 16 - nodes: - type: array - items: - $ref: '#/components/schemas/DatabaseNodeSpec4' - description: The Spock nodes for this database. - example: - - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - source_database_name: northwind - source_node_name: n1 - source_node: n1 + - pg-db + - db-alias + driver_opts: + com.docker.network.endpoint.expose: "true" + id: traefik-public + extra_volumes: + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + - destination_path: /backups/container + host_path: /Users/user/backups/host + port: 5432 + postgres_version: "17.6" + postgresql_conf: + max_connections: 1000 + restore_config: + repository: + azure_account: pgedge-backups + azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + azure_endpoint: blob.core.usgovcloudapi.net + azure_key: YXpLZXk= + base_path: /backups + custom_options: + s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab + gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + gcs_endpoint: localhost + gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== + id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 + s3_endpoint: s3.us-east-1.amazonaws.com + s3_key: AKIAIOSFODNN7EXAMPLE + s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + s3_region: us-east-1 + type: s3 + restore_options: + set: 20250505-153628F + target: "123456" + type: xid + source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + source_database_name: northwind + source_node_name: n1 + services: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + spock_version: "5" + required: + - database_name + - nodes + DatabaseSpec4: + type: object + properties: + backup_config: + $ref: '#/components/schemas/BackupConfigSpec' + cpus: + type: string + description: The number of CPUs to allocate for the database and to use for tuning Postgres. Defaults to the number of available CPUs on the host. Can include an SI suffix, e.g. '500m' for 500 millicpus. Whether this limit is enforced depends on the orchestrator. + example: 500m + pattern: ^[0-9]+(\.[0-9]{1,3}|m)?$ + database_name: + type: string + description: The name of the Postgres database. + example: northwind + minLength: 1 + maxLength: 31 + database_users: + type: array + items: + $ref: '#/components/schemas/DatabaseUserSpec' + description: The users to create for this database. + example: + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + - attributes: + - LOGIN + - CREATEDB + - CREATEROLE + db_owner: true + password: secret + roles: + - pgedge_superuser + username: admin + maxItems: 16 + memory: + type: string + description: The amount of memory in SI or IEC notation to allocate for the database and to use for tuning Postgres. Defaults to the total available memory on the host. Whether this limit is enforced depends on the orchestrator. + example: 500M + maxLength: 16 + nodes: + type: array + items: + $ref: '#/components/schemas/DatabaseNodeSpec4' + description: The Spock nodes for this database. + example: - backup_config: repositories: - azure_account: pgedge-backups @@ -8533,6 +8959,7 @@ components: host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 orchestrator_opts: @@ -8653,6 +9080,7 @@ components: host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 + - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 orchestrator_opts: @@ -8742,6 +9170,36 @@ components: additionalProperties: true restore_config: $ref: '#/components/schemas/RestoreConfigSpec' + services: + type: array + items: + $ref: '#/components/schemas/ServiceSpec' + description: Service instances to run alongside the database (e.g., MCP servers). + example: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest spock_version: type: string description: The major version of the Spock extension. @@ -8807,7 +9265,7 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - pgedge_superuser @@ -8816,7 +9274,7 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - pgedge_superuser @@ -8825,133 +9283,13 @@ components: - LOGIN - CREATEDB - CREATEROLE - db_owner: false + db_owner: true password: secret roles: - - pgedge_superuser - username: admin - memory: 500M - nodes: - - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - source_database_name: northwind - source_node_name: n1 - source_node: n1 + - pgedge_superuser + username: admin + memory: 500M + nodes: - backup_config: repositories: - azure_account: pgedge-backups @@ -9008,125 +9346,6 @@ components: host_ids: - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 - memory: 500M - name: n1 - orchestrator_opts: - swarm: - extra_labels: - traefik.enable: "true" - traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) - extra_networks: - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - - aliases: - - pg-db - - db-alias - driver_opts: - com.docker.network.endpoint.expose: "true" - id: traefik-public - extra_volumes: - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - - destination_path: /backups/container - host_path: /Users/user/backups/host - port: 5432 - postgres_version: "17.6" - postgresql_conf: - max_connections: 1000 - restore_config: - repository: - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - restore_options: - set: 20250505-153628F - target: "123456" - type: xid - source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - source_database_name: northwind - source_node_name: n1 - source_node: n1 - - backup_config: - repositories: - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - - azure_account: pgedge-backups - azure_container: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - azure_endpoint: blob.core.usgovcloudapi.net - azure_key: YXpLZXk= - base_path: /backups - custom_options: - s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab - storage-upload-chunk-size: 5MiB - gcs_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - gcs_endpoint: localhost - gcs_key: ZXhhbXBsZSBnY3Mga2V5Cg== - id: 76f9b8c0-4958-11f0-a489-3bb29577c696 - retention_full: 2 - retention_full_type: count - s3_bucket: pgedge-backups-9f81786f-373b-4ff2-afee-e054a06a96f1 - s3_endpoint: s3.us-east-1.amazonaws.com - s3_key: AKIAIOSFODNN7EXAMPLE - s3_key_secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - s3_region: us-east-1 - type: s3 - schedules: - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - - cron_expression: 0 6 * * ? - id: daily-full-backup - type: full - cpus: 500m - host_ids: - - 76f9b8c0-4958-11f0-a489-3bb29577c696 - 76f9b8c0-4958-11f0-a489-3bb29577c696 memory: 500M name: n1 @@ -9253,6 +9472,55 @@ components: source_database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 source_database_name: northwind source_node_name: n1 + services: + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest + - config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - 76f9b8c0-4958-11f0-a489-3bb29577c696 + memory: 512M + port: 0 + service_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + service_type: mcp + version: latest spock_version: "5" required: - database_name @@ -9264,7 +9532,7 @@ components: type: array items: type: string - example: Fuga molestiae. + example: Sint iure eum ducimus quia deserunt animi. description: The attributes to assign to this database user. example: - LOGIN @@ -9274,7 +9542,7 @@ components: db_owner: type: boolean description: If true, this user will be granted database ownership. - example: true + example: false password: type: string description: The password for this database user. This field will be excluded from the response of all endpoints. It can also be omitted from update requests to keep the current value. @@ -9284,7 +9552,7 @@ components: type: array items: type: string - example: Quas nostrum eos reprehenderit harum sapiente qui. + example: Nihil qui non quae sint ea. description: The roles to assign to this database user. example: - pgedge_superuser @@ -9327,7 +9595,7 @@ components: type: array items: type: string - example: Eius reiciendis accusamus veritatis quo. + example: Sapiente qui ullam. description: The Etcd client endpoint for this cluster member. example: - http://192.168.1.1:2379 @@ -9339,7 +9607,7 @@ components: type: array items: type: string - example: Non modi explicabo illum alias qui. + example: Eos reprehenderit. description: The Etcd peer endpoint for this cluster member. example: - http://192.168.1.1:2380 @@ -9360,7 +9628,7 @@ components: type: array items: type: string - example: Quam id aut. + example: Harum voluptatum nulla commodi quo. description: Optional network-scoped aliases for the container. example: - pg-db @@ -9373,7 +9641,7 @@ components: com.docker.network.endpoint.expose: "true" additionalProperties: type: string - example: Et labore in dolor quisquam placeat. + example: Facere eius totam. id: type: string description: The name or ID of the network to connect to. @@ -9450,7 +9718,7 @@ components: type: boolean description: If true, skip the health validations that prevent running failover on a healthy cluster. default: false - example: true + example: false example: candidate_instance_id: 68f50878-44d2-4524-a823-e31bd478706d-n1-689qacsi skip_validation: false @@ -9468,6 +9736,34 @@ components: type: failover required: - task + HealthCheckResult: + type: object + properties: + checked_at: + type: string + description: The time this health check was performed. + example: "2025-01-28T10:00:00Z" + format: date-time + message: + type: string + description: Optional message about the health status. + example: Connection refused + status: + type: string + description: The health status. + example: healthy + enum: + - healthy + - unhealthy + - unknown + description: Health check result for a service instance. + example: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + required: + - status + - checked_at Host: type: object properties: @@ -9526,8 +9822,6 @@ components: spock_version: "5" - postgres_version: "17.6" spock_version: "5" - - postgres_version: "17.6" - spock_version: "5" example: cohort: control_available: true @@ -9565,8 +9859,6 @@ components: spock_version: "5" - postgres_version: "17.6" spock_version: "5" - - postgres_version: "17.6" - spock_version: "5" required: - id - orchestrator @@ -9604,13 +9896,19 @@ components: type: object description: The status of each component of the host. example: - Dolorem itaque aut aut cupiditate sunt quibusdam.: + Eaque eos quos autem perspiciatis.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Labore et qui quod veniam.: details: alarms: - '3: NOSPACE' error: failed to connect to etcd healthy: false - Ipsa nihil facere ad.: + Omnis non nesciunt consequuntur reprehenderit esse.: details: alarms: - '3: NOSPACE' @@ -9633,7 +9931,19 @@ components: format: date-time example: components: - Quisquam dignissimos veritatis et omnis.: + Quae in cumque rerum ipsam.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Quis ut.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Quisquam dolores veritatis odio voluptatem dicta.: details: alarms: - '3: NOSPACE' @@ -9665,7 +9975,7 @@ components: created_at: type: string description: The time that the instance was created. - example: "1971-02-20T15:20:44Z" + example: "1975-10-10T05:27:56Z" format: date-time error: type: string @@ -9702,12 +10012,12 @@ components: status_updated_at: type: string description: The time that the instance status information was last updated. - example: "1995-04-11T11:49:24Z" + example: "1987-12-22T17:07:25Z" format: date-time updated_at: type: string description: The time that the instance was last modified. - example: "1983-10-23T08:18:23Z" + example: "1984-06-21T18:43:23Z" format: date-time description: An instance of pgEdge Postgres running on a host. example: @@ -9715,7 +10025,7 @@ components: hostname: i-0123456789abcdef.ec2.internal ipv4_address: 10.24.34.2 port: 5432 - created_at: "2014-04-20T04:34:11Z" + created_at: "2008-02-03T11:21:30Z" error: 'failed to get patroni status: connection refused' host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d @@ -9736,9 +10046,9 @@ components: provider_node: n2 status: down version: 4.10.0 - state: degraded - status_updated_at: "2011-10-12T20:33:23Z" - updated_at: "1994-09-09T06:25:01Z" + state: available + status_updated_at: "2010-07-06T06:39:04Z" + updated_at: "1976-11-29T11:38:34Z" required: - id - host_id @@ -9807,34 +10117,6 @@ components: state: creating status_updated_at: "1974-12-13T04:15:04Z" updated_at: "2006-10-18T16:07:16Z" - - connection_info: - hostname: i-0123456789abcdef.ec2.internal - ipv4_address: 10.24.34.2 - port: 5432 - created_at: "1987-03-24T21:22:02Z" - error: 'failed to get patroni status: connection refused' - host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec - id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d - node_name: n1 - postgres: - patroni_paused: true - patroni_state: unknown - pending_restart: false - role: primary - version: "18.1" - spock: - read_only: "off" - subscriptions: - - name: sub_n1n2 - provider_node: n2 - status: down - - name: sub_n1n2 - provider_node: n2 - status: down - version: 4.10.0 - state: creating - status_updated_at: "1974-12-13T04:15:04Z" - updated_at: "2006-10-18T16:07:16Z" InstanceConnectionInfo: type: object properties: @@ -9880,7 +10162,7 @@ components: example: "18.1" description: Postgres status information for a pgEdge instance. example: - patroni_paused: true + patroni_paused: false patroni_state: unknown pending_restart: false role: primary @@ -9893,7 +10175,7 @@ components: created_at: type: string description: The time that the instance was created. - example: "2006-12-23T09:53:03Z" + example: "1970-08-02T22:23:02Z" format: date-time error: type: string @@ -9930,12 +10212,12 @@ components: status_updated_at: type: string description: The time that the instance status information was last updated. - example: "1976-03-01T20:13:25Z" + example: "1986-12-16T22:41:27Z" format: date-time updated_at: type: string description: The time that the instance was last modified. - example: "2012-08-16T06:20:51Z" + example: "1991-12-26T07:39:17Z" format: date-time description: An instance of pgEdge Postgres running on a host. (default view) example: @@ -9943,7 +10225,7 @@ components: hostname: i-0123456789abcdef.ec2.internal ipv4_address: 10.24.34.2 port: 5432 - created_at: "1977-03-14T09:54:12Z" + created_at: "2008-07-30T07:06:38Z" error: 'failed to get patroni status: connection refused' host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d @@ -9951,7 +10233,7 @@ components: postgres: patroni_paused: false patroni_state: unknown - pending_restart: false + pending_restart: true role: primary version: "18.1" spock: @@ -9963,10 +10245,16 @@ components: - name: sub_n1n2 provider_node: n2 status: down + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down version: 4.10.0 state: failed - status_updated_at: "1988-02-08T05:38:12Z" - updated_at: "2002-03-20T15:42:21Z" + status_updated_at: "1987-08-24T17:24:39Z" + updated_at: "2015-05-08T21:09:57Z" required: - id - host_id @@ -9984,7 +10272,7 @@ components: hostname: i-0123456789abcdef.ec2.internal ipv4_address: 10.24.34.2 port: 5432 - created_at: "1972-02-12T09:45:07Z" + created_at: "1995-05-15T08:45:56Z" error: 'failed to get patroni status: connection refused' host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d @@ -9992,7 +10280,7 @@ components: postgres: patroni_paused: false patroni_state: unknown - pending_restart: false + pending_restart: true role: primary version: "18.1" spock: @@ -10004,15 +10292,21 @@ components: - name: sub_n1n2 provider_node: n2 status: down + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down version: 4.10.0 - state: stopped - status_updated_at: "1989-09-01T22:57:29Z" - updated_at: "1978-08-28T00:21:42Z" + state: modifying + status_updated_at: "2010-12-24T23:39:10Z" + updated_at: "2014-01-25T23:48:24Z" - connection_info: hostname: i-0123456789abcdef.ec2.internal ipv4_address: 10.24.34.2 port: 5432 - created_at: "1972-02-12T09:45:07Z" + created_at: "1995-05-15T08:45:56Z" error: 'failed to get patroni status: connection refused' host_id: de3b1388-1f0c-42f1-a86c-59ab72f255ec id: a67cbb36-c3c3-49c9-8aac-f4a0438a883d @@ -10020,7 +10314,7 @@ components: postgres: patroni_paused: false patroni_state: unknown - pending_restart: false + pending_restart: true role: primary version: "18.1" spock: @@ -10032,10 +10326,16 @@ components: - name: sub_n1n2 provider_node: n2 status: down + - name: sub_n1n2 + provider_node: n2 + status: down + - name: sub_n1n2 + provider_node: n2 + status: down version: 4.10.0 - state: stopped - status_updated_at: "1989-09-01T22:57:29Z" - updated_at: "1978-08-28T00:21:42Z" + state: modifying + status_updated_at: "2010-12-24T23:39:10Z" + updated_at: "2014-01-25T23:48:24Z" InstanceSpockStatus: type: object properties: @@ -10187,6 +10487,22 @@ components: status: completed task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 type: create + - completed_at: "2025-06-18T16:52:35Z" + created_at: "2025-06-18T16:52:05Z" + database_id: storefront + entity_id: storefront + scope: database + status: completed + task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 + type: create + - completed_at: "2025-06-18T16:52:35Z" + created_at: "2025-06-18T16:52:05Z" + database_id: storefront + entity_id: storefront + scope: database + status: completed + task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 + type: create example: tasks: - completed_at: "2025-06-18T17:54:36Z" @@ -10282,6 +10598,44 @@ components: spock_version: "5" - postgres_version: "17.6" spock_version: "5" + - cohort: + control_available: true + member_id: lah4bsznw6kc0hp7biylmmmll + type: swarm + cpus: 4 + data_dir: /data + default_pgedge_version: + postgres_version: "17.6" + spock_version: "5" + etcd_mode: server + hostname: i-0123456789abcdef.ec2.internal + id: de3b1388-1f0c-42f1-a86c-59ab72f255ec + ipv4_address: 10.24.34.2 + memory: 16GiB + orchestrator: swarm + status: + components: + Enim et voluptatum ex ea dolore.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Tenetur nostrum repellendus sint qui.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + state: available + updated_at: "2021-07-01T12:34:56Z" + supported_pgedge_versions: + - postgres_version: "17.6" + spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" description: Response containing the list of hosts example: hosts: @@ -10399,6 +10753,44 @@ components: spock_version: "5" - postgres_version: "17.6" spock_version: "5" + - cohort: + control_available: true + member_id: lah4bsznw6kc0hp7biylmmmll + type: swarm + cpus: 4 + data_dir: /data + default_pgedge_version: + postgres_version: "17.6" + spock_version: "5" + etcd_mode: server + hostname: i-0123456789abcdef.ec2.internal + id: de3b1388-1f0c-42f1-a86c-59ab72f255ec + ipv4_address: 10.24.34.2 + memory: 16GiB + orchestrator: swarm + status: + components: + Enim et voluptatum ex ea dolore.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + Tenetur nostrum repellendus sint qui.: + details: + alarms: + - '3: NOSPACE' + error: failed to connect to etcd + healthy: false + state: available + updated_at: "2021-07-01T12:34:56Z" + supported_pgedge_versions: + - postgres_version: "17.6" + spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" + - postgres_version: "17.6" + spock_version: "5" required: - hosts ListTasksResponse: @@ -10425,22 +10817,6 @@ components: status: completed task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 type: create - - completed_at: "2025-06-18T16:52:35Z" - created_at: "2025-06-18T16:52:05Z" - database_id: storefront - entity_id: storefront - scope: database - status: completed - task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 - type: create - - completed_at: "2025-06-18T16:52:35Z" - created_at: "2025-06-18T16:52:05Z" - database_id: storefront - entity_id: storefront - scope: database - status: completed - task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 - type: create example: tasks: - completed_at: "2025-06-18T17:54:36Z" @@ -10555,6 +10931,35 @@ components: required: - postgres_version - spock_version + PortMapping: + type: object + properties: + container_port: + type: integer + description: The port number inside the container. + example: 8080 + format: int64 + minimum: 1 + maximum: 65535 + host_port: + type: integer + description: The port number on the host (if port-forwarded). + example: 8080 + format: int64 + minimum: 1 + maximum: 65535 + name: + type: string + description: The name of the port (e.g., 'http', 'web-client'). + example: web-client + description: Port mapping information for a service instance. + example: + container_port: 8080 + host_port: 8080 + name: web-client + required: + - name + - container_port RemoveHostResponse: type: object properties: @@ -10582,14 +10987,6 @@ components: status: completed task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 type: create - - completed_at: "2025-06-18T16:52:35Z" - created_at: "2025-06-18T16:52:05Z" - database_id: storefront - entity_id: storefront - scope: database - status: completed - task_id: 019783f4-75f4-71e7-85a3-c9b96b345d77 - type: create example: task: completed_at: "2025-06-18T16:52:35Z" @@ -10661,7 +11058,7 @@ components: maxLength: 32 additionalProperties: type: string - example: Ea omnis ut dolor dolorem impedit laudantium. + example: Et labore in dolor quisquam placeat. source_database_id: type: string description: A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens. @@ -10719,7 +11116,7 @@ components: type: array items: type: string - example: Consequatur ex possimus magni quaerat. + example: Voluptates laborum earum illo aut et. description: The nodes to restore. Defaults to all nodes if empty or unspecified. example: - n1 @@ -10954,7 +11351,7 @@ components: s3-kms-key-id: 1234abcd-12ab-34cd-56ef-1234567890ab additionalProperties: type: string - example: Non quae. + example: Quam id aut. gcs_bucket: type: string description: The GCS bucket name for this repository. Only applies when type = 'gcs'. @@ -11036,6 +11433,548 @@ components: type: s3 required: - type + ServiceInstanceStatus: + type: object + properties: + container_id: + type: string + description: The Docker container ID. + example: a1b2c3d4e5f6 + health_check: + $ref: '#/components/schemas/HealthCheckResult' + hostname: + type: string + description: The hostname of the service instance. + example: mcp-server-host-1.internal + image_version: + type: string + description: The container image version currently running. + example: 1.0.0 + ipv4_address: + type: string + description: The IPv4 address of the service instance. + example: 10.0.1.5 + format: ipv4 + last_health_at: + type: string + description: The time of the last health check attempt. + example: "2025-01-28T10:00:00Z" + format: date-time + ports: + type: array + items: + $ref: '#/components/schemas/PortMapping' + description: Port mappings for this service instance. + example: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: + type: boolean + description: Whether the service is ready to accept requests. + example: true + description: Runtime status information for a service instance. + example: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + ServiceSpec: + type: object + properties: + config: + type: object + description: Service-specific configuration. For MCP services, this includes llm_provider, llm_model, and provider-specific API keys. + example: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + additionalProperties: true + cpus: + type: string + description: The number of CPUs to allocate for this service. It can include the SI suffix 'm', e.g. '500m' for 500 millicpus. Defaults to container defaults if unspecified. + example: 500m + pattern: ^[0-9]+(\.[0-9]{1,3}|m)?$ + host_ids: + type: array + items: + type: string + description: A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens. + example: 76f9b8c0-4958-11f0-a489-3bb29577c696 + minLength: 1 + maxLength: 63 + description: The IDs of the hosts that should run this service. One service instance will be created per host. + example: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + minItems: 1 + memory: + type: string + description: The amount of memory in SI or IEC notation to allocate for this service. Defaults to container defaults if unspecified. + example: 512M + maxLength: 16 + port: + type: integer + description: The port to publish the service on the host. If 0, Docker assigns a random port. If unspecified, no port is published and the service is not accessible from outside the Docker network. + example: 0 + format: int64 + minimum: 0 + maximum: 65535 + service_id: + type: string + description: A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens. + example: 76f9b8c0-4958-11f0-a489-3bb29577c696 + minLength: 1 + maxLength: 63 + service_type: + type: string + description: The type of service to run. + example: mcp + enum: + - mcp + version: + type: string + description: The version of the service in semver format (e.g., '1.0.0') or the literal 'latest'. + example: latest + pattern: ^(\d+\.\d+\.\d+|latest)$ + example: + config: + llm_model: gpt-4 + llm_provider: openai + openai_api_key: sk-... + cpus: 500m + host_ids: + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + - de3b1388-1f0c-42f1-a86c-59ab72f255ec + memory: 512M + port: 0 + service_id: analytics-service + service_type: mcp + version: latest + required: + - service_id + - service_type + - version + - host_ids + - config + Serviceinstance: + type: object + properties: + created_at: + type: string + description: The time that the service instance was created. + example: "2025-01-28T10:00:00Z" + format: date-time + database_id: + type: string + description: A user-specified identifier. Must be 1-63 characters, contain only lower-cased letters and hyphens, start and end with a letter or number, and not contain consecutive hyphens. + example: 76f9b8c0-4958-11f0-a489-3bb29577c696 + minLength: 1 + maxLength: 63 + error: + type: string + description: An error message if the service instance is in an error state. + example: 'failed to start container: image not found' + host_id: + type: string + description: The ID of the host this service instance is running on. + example: host-1 + service_id: + type: string + description: The service ID from the DatabaseSpec. + example: mcp-server + service_instance_id: + type: string + description: Unique identifier for the service instance. + example: mcp-server-host-1 + state: + type: string + description: Current state of the service instance. + example: running + enum: + - creating + - running + - failed + - deleting + status: + $ref: '#/components/schemas/ServiceInstanceStatus' + updated_at: + type: string + description: The time that the service instance was last updated. + example: "2025-01-28T10:05:00Z" + format: date-time + description: A service instance running on a host alongside the database. + example: + created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + required: + - service_instance_id + - service_id + - database_id + - host_id + - state + - created_at + - updated_at + ServiceinstanceCollection: + type: array + items: + $ref: '#/components/schemas/Serviceinstance' + example: + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: production + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + ServiceinstanceResponseBody: + type: object + properties: + created_at: + type: string + description: The time that the service instance was created. + example: "2025-01-28T10:00:00Z" + format: date-time + database_id: + type: string + description: Unique identifier for the database. + example: 76f9b8c0-4958-11f0-a489-3bb29577c696 + minLength: 1 + maxLength: 63 + error: + type: string + description: An error message if the service instance is in an error state. + example: 'failed to start container: image not found' + host_id: + type: string + description: The ID of the host this service instance is running on. + example: host-1 + service_id: + type: string + description: The service ID from the DatabaseSpec. + example: mcp-server + service_instance_id: + type: string + description: Unique identifier for the service instance. + example: mcp-server-host-1 + state: + type: string + description: Current state of the service instance. + example: running + enum: + - creating + - running + - failed + - deleting + status: + $ref: '#/components/schemas/ServiceInstanceStatus' + updated_at: + type: string + description: The time that the service instance was last updated. + example: "2025-01-28T10:05:00Z" + format: date-time + description: A service instance running on a host alongside the database. (default view) + example: + created_at: "2025-01-28T10:00:00Z" + database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + required: + - service_instance_id + - service_id + - database_id + - host_id + - state + - created_at + - updated_at + ServiceinstanceResponseBodyCollection: + type: array + items: + $ref: '#/components/schemas/ServiceinstanceResponseBody' + description: ServiceinstanceCollectionResponseBody is the result type for an array of ServiceinstanceResponseBody (default view) + example: + - created_at: "2025-01-28T10:00:00Z" + database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" + - created_at: "2025-01-28T10:00:00Z" + database_id: 76f9b8c0-4958-11f0-a489-3bb29577c696 + error: 'failed to start container: image not found' + host_id: host-1 + service_id: mcp-server + service_instance_id: mcp-server-host-1 + state: running + status: + container_id: a1b2c3d4e5f6 + health_check: + checked_at: "2025-01-28T10:00:00Z" + message: Connection refused + status: healthy + hostname: mcp-server-host-1.internal + image_version: 1.0.0 + ipv4_address: 10.0.1.5 + last_health_at: "2025-01-28T10:00:00Z" + ports: + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + - container_port: 8080 + host_port: 8080 + name: web-client + service_ready: true + updated_at: "2025-01-28T10:05:00Z" StartInstanceResponse: type: object properties: @@ -11083,7 +12022,7 @@ components: traefik.tcp.routers.mydb.rule: HostSNI(`mydb.example.com`) additionalProperties: type: string - example: Voluptates laborum earum illo aut et. + example: Aut cupiditate sunt. extra_networks: type: array items: @@ -11326,6 +12265,11 @@ components: status: creating message: task started timestamp: "2025-05-29T15:43:13Z" + - fields: + option.enabled: true + status: creating + message: task started + timestamp: "2025-05-29T15:43:13Z" last_entry_id: type: string description: The ID of the last entry in the task log. diff --git a/docs/development/service-credentials.md b/docs/development/service-credentials.md new file mode 100644 index 00000000..4480f5c4 --- /dev/null +++ b/docs/development/service-credentials.md @@ -0,0 +1,267 @@ +# Service Instance Database Credentials + +The Control Plane generates and manages database credentials for each service instance. + +## Overview + +Each service instance receives dedicated database credentials with read-only access. +The credentials provide security isolation between service instances; services cannot modify database data. + +## Credential Generation Workflow + +The `CreateServiceUser` workflow activity generates credentials during service instance provisioning. + +The credential generation workflow follows these steps: + +1. The activity connects to the primary database instance using admin credentials. +2. The activity generates a deterministic username from the service ID and host ID. +3. The activity generates a 44-character base64url password from 32 random bytes. +4. The activity executes SQL statements to create a user with a read-only role. +5. The activity stores the credentials in etcd and injects them into the service container. + +The following source files implement credential generation: + +- The `CreateServiceUser` activity resides in `server/internal/workflows/activities/create_service_user.go`. +- The `GenerateServiceUsername()` function resides in `server/internal/database/service_instance.go`. +- The `RandomString(32)` function in the `server/internal/utils` package generates passwords. +- The `CreateUserRole()` function in the `server/internal/postgres` package creates database users. + +## Username Format + +Service usernames follow a deterministic pattern based on the service ID and host ID. + +In the following example, the username combines the `svc_` prefix with the service and host identifiers: + +```text +Format: svc_{service_id}_{host_id} + +Example: + Service ID: "mcp-server" + Host ID: "host1" + Generated Username: "svc_mcp-server_host1" +``` + +The username format provides the following benefits: + +- The `svc_` prefix distinguishes service accounts from application users. +- The same service ID and host ID combination always produces the same username. +- The service ID and host ID combination is unique within each database. + +### PostgreSQL Compatibility + +PostgreSQL limits identifier length to 63 characters. +The system truncates the username to 63 characters when the combined values exceed that limit. + +## Password Generation + +The `utils.RandomString(32)` function reads 32 bytes from `crypto/rand` and base64url-encodes the result. + +The generated passwords have the following properties: + +- The password contains 256 bits of entropy from 32 random bytes. +- The character set includes base64url characters: `A-Z`, `a-z`, `0-9`, `-`, and `_`. +- The encoded password is 44 characters long. +- The `crypto/rand` package provides cryptographic randomness. + +The password strength protects against brute-force attacks; the format is compatible with PostgreSQL. + +## Database Permissions + +The system grants each service user the `pgedge_application_read_only` role. + +The role provides the following permissions: + +- The user can execute `SELECT` queries on all tables. +- The user can execute read-only functions. +- The user cannot execute `INSERT`, `UPDATE`, `DELETE`, or DDL statements. + +This approach follows the principle of least privilege; services can query data but cannot modify the data. + +### Permission Rationale + +Read-only access prevents several categories of risk: + +- A compromised service cannot corrupt the database data. +- A buggy service cannot accidentally modify application data. +- A service cannot execute schema changes that could break the application. + +All data modifications must go through the application layer for business logic enforcement. + +## Credential Storage and Injection + +The system stores credentials in etcd and injects them into service containers at startup. + +### Storage in etcd + +The system stores credentials in etcd as part of the `ServiceInstance` metadata. +Credentials are stored as plaintext JSON; etcd access control is the primary protection layer. + +In the following example, the credentials appear within the service instance record: + +```json +{ + "service_instance_id": "...", + "credentials": { + "username": "svc_mcp-server_host1", + "password": "", + "role": "pgedge_application_read_only" + } +} +``` + +The etcd key follows the pattern `/service_instances/{database_id}/{service_instance_id}`. + +### Injection into Containers + +The system injects credentials as environment variables into service containers at startup. + +In the following example, the container receives standard PostgreSQL connection variables: + +```bash +PGUSER=svc_mcp-server_host1 +PGPASSWORD=<44-char-base64url-password> +PGHOST=postgres-instance-hostname +PGPORT=5432 +PGDATABASE=database_name +PGSSLMODE=prefer +``` + +PostgreSQL client libraries automatically recognize these standard environment variables. + +## Security Considerations + +The credential system addresses isolation, rotation, and revocation. + +### Isolation + +The following measures enforce credential isolation: + +- Each service instance receives unique credentials that are not shared. +- One compromised service cannot access the credentials of another service. +- Read-only access limits the damage from a compromised service. +- The system never logs or prints passwords to `stdout` or `stderr`. + +### Storage + +The system stores credentials as plaintext JSON in etcd. +etcd access control restricts which clients can read credential data. +Docker Swarm transmits credentials within the overlay network. + +A future enhancement will integrate a secrets manager (Vault or AWS Secrets Manager) for encrypted storage at rest. + +### Rotation + +The system does not currently support credential rotation. +A future enhancement will add automatic rotation with zero downtime. + +The planned rotation workflow follows these steps: + +1. The system generates new credentials for the service instance. +2. The system restarts service containers with the new credentials. +3. The system revokes the old credentials after a grace period. + +### Revocation + +The system automatically revokes credentials under the following conditions: + +- A service instance deletion triggers credential revocation. +- A database deletion triggers credential revocation for all associated services. +- Removing a service from the database spec triggers declarative credential revocation. + +The revocation is immediate; the system drops the database user and terminates active connections. + +## Credential Lifecycle + +The credential lifecycle spans five stages from provisioning through deletion. + +1. The `ProvisionServices` workflow creates credentials via the `CreateServiceUser` activity. + The username is deterministic; the password is cryptographically random. + +2. The system stores the credentials in etcd as plaintext JSON. + The storage path follows `/service_instances/{database_id}/{service_instance_id}`. + +3. The Docker Swarm service spec injects credentials as environment variables. + The service connects to the database using standard `libpq` environment variables. + +4. The service connects to the database with read-only access. + The user can execute `SELECT` queries and read-only functions. + +5. The system revokes credentials when the service instance is deleted. + The system drops the database user and removes the etcd metadata. + +## Troubleshooting + +The following sections describe common credential-related issues and their solutions. + +### Service Cannot Connect + +Verify the following items when a service cannot connect to the database: + +1. Confirm the service instance state is "running" via `GET /v1/databases/{id}`. +2. Confirm the database credentials exist in etcd. +3. Confirm the database user exists by running `'SELECT * FROM pg_user WHERE usename LIKE 'svc_%';'`. +4. Confirm network connectivity from the service container to the database. +5. Check the service logs for connection error messages. + +### Permission Denied Errors + +Service users have read-only access; write operations fail by design. + +The following operations produce expected permission errors: + +- `INSERT`, `UPDATE`, and `DELETE` statements fail because the service role is read-only. +- `CREATE`, `ALTER`, and `DROP` statements fail because the service cannot modify the schema. + +Consider the following solutions: + +- Modify the service to use read-only queries for data access. +- Route data modifications through the application API. + +### Username Collision + +Username collisions are rare because the service instance ID is unique within each database. + +Verify the following items when a collision is suspected: + +- Check for duplicate service instance IDs in etcd. +- Run `'SELECT * FROM pg_user WHERE usename = 'svc_';'` to confirm the user exists. + +## Future Enhancements + +The following features will be considered for future releases. + +- Read/Write users based on use-case requirements. +- Automatic credential rotation will provide periodic rotation with zero downtime. +- Secret manager integration will store passwords in Vault or AWS Secrets Manager. +- Custom role support will allow users to specify database roles per service. +- Certificate-based authentication will replace passwords with TLS client certificates. + +## References + +The following source files implement the credential system: + +- The `CreateServiceUser` activity resides in `server/internal/workflows/activities/create_service_user.go`. +- The `ServiceUser` type resides in `server/internal/database/service_instance.go`. +- The `GenerateServiceUsername()` function generates deterministic usernames. +- The `server/internal/postgres` package creates database user roles. + +See the [PostgreSQL Roles Documentation](https://www.postgresql.org/docs/current/user-manag.html) for details on role management. + +### Workflow Sequence + +The following diagram shows the credential creation workflow: + +```text +UpdateDatabase Workflow + └─> ProvisionServices Sub-Workflow + └─> For each service instance: + ├─> CreateServiceUser Activity + │ ├─> Connect to database primary + │ ├─> Generate username (deterministic) + │ ├─> Generate password (random) + │ ├─> Execute CREATE USER + │ ├─> Grant pgedge_application_read_only role + │ └─> Return credentials + ├─> GenerateServiceInstanceResources Activity + └─> StoreServiceInstance Activity (saves credentials to etcd) +``` diff --git a/e2e/service_provisioning_test.go b/e2e/service_provisioning_test.go new file mode 100644 index 00000000..92a3d5c0 --- /dev/null +++ b/e2e/service_provisioning_test.go @@ -0,0 +1,605 @@ +//go:build e2e_test + +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5" + controlplane "github.com/pgEdge/control-plane/api/apiv1/gen/control_plane" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestProvisionMCPService tests provisioning an MCP server service with a database. +func TestProvisionMCPService(t *testing.T) { + t.Parallel() + + host1 := fixture.HostIDs()[0] + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + t.Log("Creating database with MCP service") + + // Create database with MCP service in spec + db := fixture.NewDatabaseFixture(ctx, t, &controlplane.CreateDatabaseRequest{ + Spec: &controlplane.DatabaseSpec{ + DatabaseName: "test_mcp_service", + DatabaseUsers: []*controlplane.DatabaseUserSpec{ + { + Username: "admin", + Password: pointerTo("testpassword"), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + }, + Port: pointerTo(0), + Nodes: []*controlplane.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + }, + }, + Services: []*controlplane.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + //Version: "1.0.0", + Version: "latest", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-test-key-12345", + }, + }, + }, + }, + }) + + t.Log("Database created, verifying service instances") + + // Verify service instances exist + require.NotNil(t, db.ServiceInstances, "ServiceInstances should not be nil") + require.Len(t, db.ServiceInstances, 1, "Expected 1 service instance") + + serviceInstance := db.ServiceInstances[0] + + // Verify service instance properties + assert.Equal(t, "mcp-server", serviceInstance.ServiceID, "Service ID should match") + assert.Equal(t, string(host1), serviceInstance.HostID, "Host ID should match") + assert.NotEmpty(t, serviceInstance.ServiceInstanceID, "Service instance ID should not be empty") + + // Verify service instance state + // Note: State might be "creating" or "running" depending on timing + validStates := []string{"creating", "running"} + assert.Contains(t, validStates, serviceInstance.State, "Service instance should be in a valid state") + + t.Logf("Service instance created: %s (state: %s)", serviceInstance.ServiceInstanceID, serviceInstance.State) + + // Wait for service to be running if it's still creating + if serviceInstance.State == "creating" { + t.Log("Service is still creating, waiting for it to become running...") + + maxWait := 2 * time.Minute + pollInterval := 5 * time.Second + deadline := time.Now().Add(maxWait) + + for time.Now().Before(deadline) { + err := db.Refresh(ctx) + require.NoError(t, err, "Failed to refresh database") + + if len(db.ServiceInstances) > 0 && db.ServiceInstances[0].State == "running" { + t.Log("Service is now running") + break + } + + time.Sleep(pollInterval) + } + + // Verify final state + require.Len(t, db.ServiceInstances, 1, "Service instance should still exist") + assert.Equal(t, "running", db.ServiceInstances[0].State, "Service should be running after wait") + } + + // Verify service instance status/connection info exists + serviceInstance = db.ServiceInstances[0] + if serviceInstance.Status != nil { + t.Log("Verifying service instance connection info") + + // Verify basic connection info exists + assert.NotNil(t, serviceInstance.Status.Hostname, "Hostname should be set") + assert.NotNil(t, serviceInstance.Status.Ipv4Address, "IPv4 address should be set") + + if serviceInstance.Status.Hostname != nil { + t.Logf("Service hostname: %s", *serviceInstance.Status.Hostname) + } + if serviceInstance.Status.Ipv4Address != nil { + t.Logf("Service IPv4 address: %s", *serviceInstance.Status.Ipv4Address) + } + + // Verify ports are configured + if len(serviceInstance.Status.Ports) > 0 { + t.Logf("Service has %d ports configured", len(serviceInstance.Status.Ports)) + for _, port := range serviceInstance.Status.Ports { + t.Logf(" - %s: container_port=%d", port.Name, port.ContainerPort) + } + + // Verify HTTP port (8080) is exposed + foundHTTPPort := false + for _, port := range serviceInstance.Status.Ports { + if port.Name == "http" && port.ContainerPort == 8080 { + foundHTTPPort = true + break + } + } + assert.True(t, foundHTTPPort, "HTTP port (8080) should be configured") + } + } else { + t.Log("Service instance status not yet populated (this may be expected if container is still starting)") + } + + t.Log("MCP service provisioning test completed successfully") +} + +// TestProvisionMultiHostMCPService tests provisioning MCP service on multiple hosts. +func TestProvisionMultiHostMCPService(t *testing.T) { + t.Parallel() + + host1 := fixture.HostIDs()[0] + host2 := fixture.HostIDs()[1] + host3 := fixture.HostIDs()[2] + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + t.Log("Creating database with MCP service on multiple hosts") + + // Create database with MCP service on 3 hosts + db := fixture.NewDatabaseFixture(ctx, t, &controlplane.CreateDatabaseRequest{ + Spec: &controlplane.DatabaseSpec{ + DatabaseName: "test_mcp_multihost", + DatabaseUsers: []*controlplane.DatabaseUserSpec{ + { + Username: "admin", + Password: pointerTo("testpassword"), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + }, + Port: pointerTo(0), + Nodes: []*controlplane.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + }, + }, + Services: []*controlplane.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + //Version: "1.0.0", + Version: "latest", + HostIds: []controlplane.Identifier{ + controlplane.Identifier(host1), + controlplane.Identifier(host2), + controlplane.Identifier(host3), + }, + Config: map[string]any{ + "llm_provider": "openai", + "llm_model": "gpt-4", + "openai_api_key": "sk-test-key-67890", + }, + }, + }, + }, + }) + + t.Log("Database created, verifying service instances on all hosts") + + // Verify service instances exist for all hosts + require.NotNil(t, db.ServiceInstances, "ServiceInstances should not be nil") + require.Len(t, db.ServiceInstances, 3, "Expected 3 service instances (one per host)") + + // Track which hosts have service instances + hostsWithServices := make(map[string]bool) + for _, si := range db.ServiceInstances { + hostsWithServices[si.HostID] = true + + // Verify basic properties + assert.Equal(t, "mcp-server", si.ServiceID, "Service ID should match") + assert.NotEmpty(t, si.ServiceInstanceID, "Service instance ID should not be empty") + + t.Logf("Service instance on host %s: %s (state: %s)", si.HostID, si.ServiceInstanceID, si.State) + } + + // Verify all three hosts have service instances + assert.True(t, hostsWithServices[host1], "Host 1 should have a service instance") + assert.True(t, hostsWithServices[host2], "Host 2 should have a service instance") + assert.True(t, hostsWithServices[host3], "Host 3 should have a service instance") + + t.Log("Multi-host MCP service provisioning test completed successfully") +} + +// TestUpdateDatabaseAddService tests adding a service to an existing database. +func TestUpdateDatabaseAddService(t *testing.T) { + t.Parallel() + + host1 := fixture.HostIDs()[0] + host2 := fixture.HostIDs()[1] + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + t.Log("Creating database without services") + + // Create database without services + db := fixture.NewDatabaseFixture(ctx, t, &controlplane.CreateDatabaseRequest{ + Spec: &controlplane.DatabaseSpec{ + DatabaseName: "test_add_service", + DatabaseUsers: []*controlplane.DatabaseUserSpec{ + { + Username: "admin", + Password: pointerTo("testpassword"), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + }, + Port: pointerTo(0), + Nodes: []*controlplane.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + }, + }, + }, + }) + + // Verify no service instances initially + assert.Empty(t, db.ServiceInstances, "Should have no service instances initially") + + t.Log("Adding MCP service to existing database") + + // Update database to add service + err := db.Update(ctx, UpdateOptions{ + Spec: &controlplane.DatabaseSpec{ + DatabaseName: "test_add_service", + DatabaseUsers: []*controlplane.DatabaseUserSpec{ + { + Username: "admin", + Password: pointerTo("testpassword"), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + }, + Port: pointerTo(0), + Nodes: []*controlplane.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + }, + }, + Services: []*controlplane.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + //Version: "1.0.0", + Version: "latest", + HostIds: []controlplane.Identifier{controlplane.Identifier(host2)}, + Config: map[string]any{ + "llm_provider": "ollama", + "llm_model": "llama2", + "ollama_url": "http://localhost:11434", + }, + }, + }, + }, + }) + require.NoError(t, err, "Failed to update database") + + t.Log("Database updated, verifying service instance was added") + + // Verify service instance was created + require.NotNil(t, db.ServiceInstances, "ServiceInstances should not be nil") + require.Len(t, db.ServiceInstances, 1, "Expected 1 service instance after update") + + serviceInstance := db.ServiceInstances[0] + assert.Equal(t, "mcp-server", serviceInstance.ServiceID, "Service ID should match") + assert.Equal(t, string(host2), serviceInstance.HostID, "Host ID should match") + + t.Logf("Service instance added: %s (state: %s)", serviceInstance.ServiceInstanceID, serviceInstance.State) + + t.Log("Add service to existing database test completed successfully") +} + +// TestProvisionMCPServiceUnsupportedVersion tests that database creation succeeds +// even when service provisioning fails due to an unsupported image version. +// Version "99.99.99" passes API validation (semver pattern) but is not registered +// in ServiceVersions, so GenerateServiceInstanceResources fails. The database +// should still become available and Postgres should be accessible. +func TestProvisionMCPServiceUnsupportedVersion(t *testing.T) { + t.Parallel() + + host1 := fixture.HostIDs()[0] + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + t.Log("Creating database with MCP service using unsupported version") + + db := fixture.NewDatabaseFixture(ctx, t, &controlplane.CreateDatabaseRequest{ + Spec: &controlplane.DatabaseSpec{ + DatabaseName: "test_mcp_unsupported_ver", + DatabaseUsers: []*controlplane.DatabaseUserSpec{ + { + Username: "admin", + Password: pointerTo("testpassword"), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + }, + Port: pointerTo(0), + Nodes: []*controlplane.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + }, + }, + Services: []*controlplane.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "99.99.99", // Valid semver but not registered in ServiceVersions + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-test-key-12345", + }, + }, + }, + }, + }) + + t.Log("Database created, verifying database is available despite service failure") + + // Database should be available even though service provisioning failed + assert.Equal(t, "available", db.State, "Database should be available despite service provisioning failure") + + // Verify Postgres instances exist and are accessible + require.NotEmpty(t, db.Instances, "Database should have at least one Postgres instance") + + db.WithConnection(ctx, ConnectionOptions{ + Matcher: WithRole("primary"), + Username: "admin", + Password: "testpassword", + }, t, func(conn *pgx.Conn) { + var result int + row := conn.QueryRow(ctx, "SELECT 1") + require.NoError(t, row.Scan(&result)) + assert.Equal(t, 1, result) + t.Log("Postgres is accessible despite service provisioning failure") + }) + + t.Log("Unsupported version test completed successfully") +} + +// TestProvisionMCPServiceRecovery tests that a failed service can be recovered +// by updating the database with a corrected service version. The sequence is: +// 1. Create database with an unsupported service version (provisioning fails) +// 2. Verify database is available and Postgres is accessible +// 3. Update database with a corrected service version +// 4. Verify the service instance is created and transitions to running +func TestProvisionMCPServiceRecovery(t *testing.T) { + t.Parallel() + + host1 := fixture.HostIDs()[0] + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + t.Log("Creating database with MCP service using unsupported version") + + db := fixture.NewDatabaseFixture(ctx, t, &controlplane.CreateDatabaseRequest{ + Spec: &controlplane.DatabaseSpec{ + DatabaseName: "test_mcp_recovery", + DatabaseUsers: []*controlplane.DatabaseUserSpec{ + { + Username: "admin", + Password: pointerTo("testpassword"), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + }, + Port: pointerTo(0), + Nodes: []*controlplane.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + }, + }, + Services: []*controlplane.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "99.99.99", // Unsupported version - service provisioning will fail + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-test-key-12345", + }, + }, + }, + }, + }) + + // Database should be available despite service failure + assert.Equal(t, "available", db.State, "Database should be available despite service provisioning failure") + t.Log("Database available, now updating with corrected service version") + + // Update database with corrected service version + err := db.Update(ctx, UpdateOptions{ + Spec: &controlplane.DatabaseSpec{ + DatabaseName: "test_mcp_recovery", + DatabaseUsers: []*controlplane.DatabaseUserSpec{ + { + Username: "admin", + Password: pointerTo("testpassword"), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + }, + Port: pointerTo(0), + Nodes: []*controlplane.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + }, + }, + Services: []*controlplane.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "latest", // Corrected version + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-test-key-12345", + }, + }, + }, + }, + }) + require.NoError(t, err, "Failed to update database with corrected service version") + + t.Log("Database updated, verifying service instance recovered") + + // Database should still be available + assert.Equal(t, "available", db.State, "Database should remain available after update") + + // Service instance should now exist + require.NotNil(t, db.ServiceInstances, "ServiceInstances should not be nil after recovery") + require.Len(t, db.ServiceInstances, 1, "Expected 1 service instance after recovery") + + serviceInstance := db.ServiceInstances[0] + assert.Equal(t, "mcp-server", serviceInstance.ServiceID, "Service ID should match") + assert.Equal(t, host1, serviceInstance.HostID, "Host ID should match") + + t.Logf("Service instance created: %s (state: %s)", serviceInstance.ServiceInstanceID, serviceInstance.State) + + // Wait for service to become running if it's still creating + if serviceInstance.State != "running" { + t.Log("Service is not yet running, waiting...") + + maxWait := 2 * time.Minute + pollInterval := 5 * time.Second + deadline := time.Now().Add(maxWait) + + for time.Now().Before(deadline) { + err := db.Refresh(ctx) + require.NoError(t, err, "Failed to refresh database") + + if len(db.ServiceInstances) > 0 && db.ServiceInstances[0].State == "running" { + t.Log("Service has recovered and is now running") + break + } + + time.Sleep(pollInterval) + } + } + + require.Len(t, db.ServiceInstances, 1, "Service instance should still exist after wait") + assert.Equal(t, "running", db.ServiceInstances[0].State, "Service should be running after recovery") + + t.Logf("Service instance recovered: %s (state: %s)", db.ServiceInstances[0].ServiceInstanceID, db.ServiceInstances[0].State) + t.Log("Service recovery test completed successfully") +} + +// TestUpdateDatabaseRemoveService tests removing a service from a database. +func TestUpdateDatabaseRemoveService(t *testing.T) { + t.Parallel() + + host1 := fixture.HostIDs()[0] + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + t.Log("Creating database with MCP service") + + // Create database with service + db := fixture.NewDatabaseFixture(ctx, t, &controlplane.CreateDatabaseRequest{ + Spec: &controlplane.DatabaseSpec{ + DatabaseName: "test_remove_service", + DatabaseUsers: []*controlplane.DatabaseUserSpec{ + { + Username: "admin", + Password: pointerTo("testpassword"), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + }, + Port: pointerTo(0), + Nodes: []*controlplane.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + }, + }, + Services: []*controlplane.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + //Version: "1.0.0", + Version: "latest", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-test", + }, + }, + }, + }, + }) + + // Verify service instance exists + require.Len(t, db.ServiceInstances, 1, "Expected 1 service instance initially") + + t.Log("Removing service from database") + + // Update database to remove service (empty services array) + err := db.Update(ctx, UpdateOptions{ + Spec: &controlplane.DatabaseSpec{ + DatabaseName: "test_remove_service", + DatabaseUsers: []*controlplane.DatabaseUserSpec{ + { + Username: "admin", + Password: pointerTo("testpassword"), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + }, + Port: pointerTo(0), + Nodes: []*controlplane.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, + }, + }, + Services: []*controlplane.ServiceSpec{}, // Empty services array + }, + }) + require.NoError(t, err, "Failed to update database") + + t.Log("Database updated, verifying service instance was removed") + + // Verify service instance was removed (declarative deletion) + assert.Empty(t, db.ServiceInstances, "Service instances should be empty after removal") + + t.Log("Remove service test completed successfully") +} diff --git a/server/internal/api/apiv1/convert.go b/server/internal/api/apiv1/convert.go index 154848d3..f3b3867e 100644 --- a/server/internal/api/apiv1/convert.go +++ b/server/internal/api/apiv1/convert.go @@ -181,6 +181,53 @@ func restoreConfigToAPI(config *database.RestoreConfig) *api.RestoreConfigSpec { return out } +func serviceSpecToAPI(svc *database.ServiceSpec) *api.ServiceSpec { + if svc == nil { + return nil + } + + hostIDs := make([]api.Identifier, len(svc.HostIDs)) + for i, hostID := range svc.HostIDs { + hostIDs[i] = api.Identifier(hostID) + } + + // Strip sensitive keys from config before returning to API + var filteredConfig map[string]any + if svc.Config != nil { + filteredConfig = make(map[string]any, len(svc.Config)) + for k, v := range svc.Config { + kLower := strings.ToLower(k) + if strings.Contains(kLower, "api_key") || strings.Contains(kLower, "secret") || strings.Contains(kLower, "password") { + continue + } + filteredConfig[k] = v + } + } + + return &api.ServiceSpec{ + ServiceID: api.Identifier(svc.ServiceID), + ServiceType: svc.ServiceType, + Version: svc.Version, + HostIds: hostIDs, + Port: svc.Port, + Config: filteredConfig, + Cpus: utils.NillablePointerTo(humanizeCPUs(utils.FromPointer(svc.CPUs))), + Memory: utils.NillablePointerTo(humanizeBytes(utils.FromPointer(svc.MemoryBytes))), + } +} + +func serviceSpecsToAPI(services []*database.ServiceSpec) []*api.ServiceSpec { + if len(services) == 0 { + return nil + } + + apiServices := make([]*api.ServiceSpec, len(services)) + for i, svc := range services { + apiServices[i] = serviceSpecToAPI(svc) + } + return apiServices +} + func databaseSpecToAPI(d *database.Spec) *api.DatabaseSpec { return &api.DatabaseSpec{ DatabaseName: d.DatabaseName, @@ -191,6 +238,7 @@ func databaseSpecToAPI(d *database.Spec) *api.DatabaseSpec { Memory: utils.NillablePointerTo(humanizeBytes(d.MemoryBytes)), Nodes: databaseNodesToAPI(d.Nodes), DatabaseUsers: databaseUsersToAPI(d.DatabaseUsers), + Services: serviceSpecsToAPI(d.Services), BackupConfig: backupConfigToAPI(d.BackupConfig), RestoreConfig: restoreConfigToAPI(d.RestoreConfig), PostgresqlConf: d.PostgreSQLConf, @@ -198,6 +246,70 @@ func databaseSpecToAPI(d *database.Spec) *api.DatabaseSpec { } } +func portMappingToAPI(pm database.PortMapping) *api.PortMapping { + return &api.PortMapping{ + Name: pm.Name, + ContainerPort: pm.ContainerPort, + HostPort: pm.HostPort, + } +} + +func healthCheckResultToAPI(hc *database.HealthCheckResult) *api.HealthCheckResult { + if hc == nil { + return nil + } + return &api.HealthCheckResult{ + Status: hc.Status, + Message: utils.NillablePointerTo(hc.Message), + CheckedAt: hc.CheckedAt.Format(time.RFC3339), + } +} + +func serviceInstanceStatusToAPI(status *database.ServiceInstanceStatus) *api.ServiceInstanceStatus { + if status == nil { + return nil + } + + ports := make([]*api.PortMapping, len(status.Ports)) + for i, pm := range status.Ports { + ports[i] = portMappingToAPI(pm) + } + + var lastHealthAt *string + if status.LastHealthAt != nil { + lastHealthAt = utils.PointerTo(status.LastHealthAt.Format(time.RFC3339)) + } + + return &api.ServiceInstanceStatus{ + ContainerID: status.ContainerID, + ImageVersion: status.ImageVersion, + Hostname: status.Hostname, + Ipv4Address: status.IPv4Address, + Ports: ports, + HealthCheck: healthCheckResultToAPI(status.HealthCheck), + LastHealthAt: lastHealthAt, + ServiceReady: status.ServiceReady, + } +} + +func serviceInstanceToAPI(si *database.ServiceInstance) *api.Serviceinstance { + if si == nil { + return nil + } + + return &api.Serviceinstance{ + ServiceInstanceID: si.ServiceInstanceID, + ServiceID: si.ServiceID, + DatabaseID: api.Identifier(si.DatabaseID), + HostID: si.HostID, + State: string(si.State), + Status: serviceInstanceStatusToAPI(si.Status), + CreatedAt: si.CreatedAt.Format(time.RFC3339), + UpdatedAt: si.UpdatedAt.Format(time.RFC3339), + Error: utils.NillablePointerTo(si.Error), + } +} + func databaseToAPI(d *database.Database) *api.Database { if d == nil { return nil @@ -220,19 +332,32 @@ func databaseToAPI(d *database.Database) *api.Database { return strings.Compare(a.ID, b.ID) }) + serviceInstances := make([]*api.Serviceinstance, len(d.ServiceInstances)) + for i, si := range d.ServiceInstances { + serviceInstances[i] = serviceInstanceToAPI(si) + } + // Sort by service ID, host ID asc + slices.SortStableFunc(serviceInstances, func(a, b *api.Serviceinstance) int { + if svcEq := strings.Compare(a.ServiceID, b.ServiceID); svcEq != 0 { + return svcEq + } + return strings.Compare(a.HostID, b.HostID) + }) + var tenantID *api.Identifier if d.TenantID != nil { tenantID = utils.PointerTo(api.Identifier(*d.TenantID)) } return &api.Database{ - ID: api.Identifier(d.DatabaseID), - TenantID: tenantID, - CreatedAt: d.CreatedAt.Format(time.RFC3339), - UpdatedAt: d.UpdatedAt.Format(time.RFC3339), - State: string(d.State), - Spec: spec, - Instances: instances, + ID: api.Identifier(d.DatabaseID), + TenantID: tenantID, + CreatedAt: d.CreatedAt.Format(time.RFC3339), + UpdatedAt: d.UpdatedAt.Format(time.RFC3339), + State: string(d.State), + Spec: spec, + Instances: instances, + ServiceInstances: serviceInstances, } } @@ -452,6 +577,62 @@ func apiToRestoreConfig(apiConfig *api.RestoreConfigSpec) (*database.RestoreConf }, nil } +func apiToServiceSpec(apiSvc *api.ServiceSpec) (*database.ServiceSpec, error) { + if apiSvc == nil { + return nil, nil + } + + hostIDs := make([]string, len(apiSvc.HostIds)) + for i, hostID := range apiSvc.HostIds { + hostIDs[i] = string(hostID) + } + + var cpus *float64 + if apiSvc.Cpus != nil { + c, err := parseCPUs(apiSvc.Cpus) + if err != nil { + return nil, fmt.Errorf("failed to parse service CPUs: %w", err) + } + cpus = &c + } + + var memory *uint64 + if apiSvc.Memory != nil { + m, err := parseBytes(apiSvc.Memory) + if err != nil { + return nil, fmt.Errorf("failed to parse service memory: %w", err) + } + memory = &m + } + + return &database.ServiceSpec{ + ServiceID: string(apiSvc.ServiceID), + ServiceType: apiSvc.ServiceType, + Version: apiSvc.Version, + HostIDs: hostIDs, + Port: apiSvc.Port, + Config: apiSvc.Config, + CPUs: cpus, + MemoryBytes: memory, + }, nil +} + +func apiToServiceSpecs(apiServices []*api.ServiceSpec) ([]*database.ServiceSpec, error) { + if len(apiServices) == 0 { + return nil, nil + } + + services := make([]*database.ServiceSpec, len(apiServices)) + for i, apiSvc := range apiServices { + svc, err := apiToServiceSpec(apiSvc) + if err != nil { + return nil, fmt.Errorf("failed to convert service %d: %w", i, err) + } + services[i] = svc + } + return services, nil +} + func apiToDatabaseSpec(id, tID *api.Identifier, apiSpec *api.DatabaseSpec) (*database.Spec, error) { var databaseID string var err error @@ -497,6 +678,10 @@ func apiToDatabaseSpec(id, tID *api.Identifier, apiSpec *api.DatabaseSpec) (*dat Roles: apiUser.Roles, } } + services, err := apiToServiceSpecs(apiSpec.Services) + if err != nil { + return nil, fmt.Errorf("failed to parse services: %w", err) + } backupConfig, err := apiToBackupConfig(apiSpec.BackupConfig) if err != nil { return nil, fmt.Errorf("failed to parse backup configs: %w", err) @@ -517,6 +702,7 @@ func apiToDatabaseSpec(id, tID *api.Identifier, apiSpec *api.DatabaseSpec) (*dat MemoryBytes: memory, Nodes: nodes, DatabaseUsers: users, + Services: services, BackupConfig: backupConfig, PostgreSQLConf: apiSpec.PostgresqlConf, RestoreConfig: restoreConfig, diff --git a/server/internal/api/apiv1/validate.go b/server/internal/api/apiv1/validate.go index b4474715..7546fa64 100644 --- a/server/internal/api/apiv1/validate.go +++ b/server/internal/api/apiv1/validate.go @@ -124,6 +124,21 @@ func validateDatabaseSpec(spec *api.DatabaseSpec) error { errs = append(errs, validateRestoreConfig(spec.RestoreConfig, []string{"restore_config"})...) } + // Validate services + seenServiceIDs := make(ds.Set[string], len(spec.Services)) + for i, svc := range spec.Services { + svcPath := []string{"services", arrayIndexPath(i)} + + // Check for duplicate service IDs + if seenServiceIDs.Has(string(svc.ServiceID)) { + err := errors.New("service IDs must be unique within a database") + errs = append(errs, newValidationError(err, svcPath)) + } + seenServiceIDs.Add(string(svc.ServiceID)) + + errs = append(errs, validateServiceSpec(svc, svcPath)...) + } + return errors.Join(errs...) } @@ -211,6 +226,103 @@ func validateNode(node *api.DatabaseNodeSpec, path []string) []error { return errs } +func validateServiceSpec(svc *api.ServiceSpec, path []string) []error { + var errs []error + + // Validate service_id + serviceIDPath := appendPath(path, "service_id") + errs = append(errs, validateIdentifier(string(svc.ServiceID), serviceIDPath)) + + // Validate service_type (must be "mcp" for now) + if svc.ServiceType != "mcp" { + err := fmt.Errorf("unsupported service type '%s' (only 'mcp' is currently supported)", svc.ServiceType) + errs = append(errs, newValidationError(err, appendPath(path, "service_type"))) + } + + // Validate version (semver pattern or "latest") + if svc.Version != "latest" && !semverPattern.MatchString(svc.Version) { + err := errors.New("version must be in semver format (e.g., '1.0.0') or 'latest'") + errs = append(errs, newValidationError(err, appendPath(path, "version"))) + } + + // Validate host_ids (uniqueness and format) + seenHostIDs := make(ds.Set[string], len(svc.HostIds)) + for i, hostID := range svc.HostIds { + hostIDStr := string(hostID) + hostIDPath := appendPath(path, "host_ids", arrayIndexPath(i)) + + errs = append(errs, validateIdentifier(hostIDStr, hostIDPath)) + + // may need to relax this if there is a use-case for multiple service instances on the same host + if seenHostIDs.Has(hostIDStr) { + err := errors.New("host IDs must be unique within a service") + errs = append(errs, newValidationError(err, hostIDPath)) + } + seenHostIDs.Add(hostIDStr) + } + + // Validate config based on service_type + if svc.ServiceType == "mcp" { + errs = append(errs, validateMCPServiceConfig(svc.Config, appendPath(path, "config"))...) + } + + // Validate cpus if provided + if svc.Cpus != nil { + errs = append(errs, validateCPUs(svc.Cpus, appendPath(path, "cpus"))...) + } + + // Validate memory if provided + if svc.Memory != nil { + errs = append(errs, validateMemory(svc.Memory, appendPath(path, "memory"))...) + } + + return errs +} + +// TODO: this is still a WIP based on use-case reqs... +func validateMCPServiceConfig(config map[string]any, path []string) []error { + var errs []error + + // Required fields for MCP service + requiredFields := []string{"llm_provider", "llm_model"} + for _, field := range requiredFields { + if _, ok := config[field]; !ok { + err := fmt.Errorf("missing required field '%s'", field) + errs = append(errs, newValidationError(err, path)) + } + } + + // Validate llm_provider + if provider, ok := config["llm_provider"].(string); ok { + validProviders := []string{"anthropic", "openai", "ollama"} + if !slices.Contains(validProviders, provider) { + err := fmt.Errorf("unsupported llm_provider '%s' (must be one of: %s)", provider, strings.Join(validProviders, ", ")) + errs = append(errs, newValidationError(err, appendPath(path, mapKeyPath("llm_provider")))) + } + + // Provider-specific API key validation + switch provider { + case "anthropic": + if _, ok := config["anthropic_api_key"]; !ok { + err := errors.New("missing required field 'anthropic_api_key' for anthropic provider") + errs = append(errs, newValidationError(err, path)) + } + case "openai": + if _, ok := config["openai_api_key"]; !ok { + err := errors.New("missing required field 'openai_api_key' for openai provider") + errs = append(errs, newValidationError(err, path)) + } + case "ollama": + if _, ok := config["ollama_url"]; !ok { + err := errors.New("missing required field 'ollama_url' for ollama provider") + errs = append(errs, newValidationError(err, path)) + } + } + } + + return errs +} + func validateCPUs(value *string, path []string) []error { var errs []error @@ -400,6 +512,7 @@ func validateS3RepoProperties(props repoProperties, path []string) []error { } var pgBackRestOptionPattern = regexp.MustCompile(`^[a-z0-9-]+$`) +var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) func validatePgBackRestOptions(opts map[string]string, path []string) []error { var errs []error diff --git a/server/internal/api/apiv1/validate_test.go b/server/internal/api/apiv1/validate_test.go index 0e120a0c..771aab90 100644 --- a/server/internal/api/apiv1/validate_test.go +++ b/server/internal/api/apiv1/validate_test.go @@ -495,6 +495,136 @@ func TestValidateDatabaseSpec(t *testing.T) { }, }, }, + { + name: "valid with services", + spec: &api.DatabaseSpec{ + DatabaseName: "testdb", + PostgresVersion: utils.PointerTo("17.6"), + Nodes: []*api.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []api.Identifier{ + api.Identifier("host-1"), + }, + }, + }, + Services: []*api.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + }, + }, + }, + }, + { + name: "invalid with duplicate service IDs", + spec: &api.DatabaseSpec{ + DatabaseName: "testdb", + PostgresVersion: utils.PointerTo("17.6"), + Nodes: []*api.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []api.Identifier{ + api.Identifier("host-1"), + }, + }, + }, + Services: []*api.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + }, + { + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-2"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + }, + }, + }, + expected: []string{ + "services[1]: service IDs must be unique within a database", + }, + }, + { + name: "invalid with service validation errors", + spec: &api.DatabaseSpec{ + DatabaseName: "testdb", + PostgresVersion: utils.PointerTo("17.6"), + Nodes: []*api.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []api.Identifier{ + api.Identifier("host-1"), + }, + }, + }, + Services: []*api.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "unknown", + Version: "v1.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "unknown", + }, + }, + }, + }, + expected: []string{ + "services[0].service_type: unsupported service type 'unknown'", + "services[0].version: version must be in semver format (e.g., '1.0.0') or 'latest'", + }, + }, + { + name: "invalid with MCP config errors", + spec: &api.DatabaseSpec{ + DatabaseName: "testdb", + PostgresVersion: utils.PointerTo("17.6"), + Nodes: []*api.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []api.Identifier{ + api.Identifier("host-1"), + }, + }, + }, + Services: []*api.ServiceSpec{ + { + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "unknown", + }, + }, + }, + }, + expected: []string{ + "services[0].config: missing required field 'llm_model'", + "services[0].config[llm_provider]: unsupported llm_provider 'unknown'", + }, + }, { name: "invalid", spec: &api.DatabaseSpec{ @@ -558,3 +688,324 @@ func TestValidateDatabaseSpec(t *testing.T) { }) } } + +func TestValidateServiceSpec(t *testing.T) { + for _, tc := range []struct { + name string + svc *api.ServiceSpec + expected []string + }{ + { + name: "valid MCP service with Anthropic", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1", "host-2"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + }, + }, + { + name: "valid MCP service with OpenAI", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "2.1.3", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "openai", + "llm_model": "gpt-4", + "openai_api_key": "sk-...", + }, + }, + }, + { + name: "valid MCP service with Ollama", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.5.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "ollama", + "llm_model": "llama2", + "ollama_url": "http://localhost:11434", + }, + }, + }, + { + name: "valid MCP service with 'latest' version", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "latest", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + }, + }, + { + name: "valid MCP service with CPUs and Memory", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + Cpus: utils.PointerTo("2"), + Memory: utils.PointerTo("1GiB"), + }, + }, + { + name: "invalid service_id", + svc: &api.ServiceSpec{ + ServiceID: "mcp server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + }, + expected: []string{ + "service_id:", + }, + }, + { + name: "unsupported service_type", + svc: &api.ServiceSpec{ + ServiceID: "my-service", + ServiceType: "unknown", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{}, + }, + expected: []string{ + "service_type: unsupported service type 'unknown'", + }, + }, + { + name: "invalid version format", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "v1.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + }, + expected: []string{ + "version: version must be in semver format (e.g., '1.0.0') or 'latest'", + }, + }, + { + name: "duplicate host_ids", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1", "host-1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + }, + expected: []string{ + "host_ids[1]: host IDs must be unique within a service", + }, + }, + { + name: "invalid host_id", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host 1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + }, + expected: []string{ + "host_ids[0]:", + }, + }, + { + name: "missing llm_provider", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_model": "claude-sonnet-4-5", + }, + }, + expected: []string{ + "config: missing required field 'llm_provider'", + }, + }, + { + name: "missing llm_model", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + }, + }, + expected: []string{ + "config: missing required field 'llm_model'", + }, + }, + { + name: "unsupported llm_provider", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "unknown", + "llm_model": "some-model", + }, + }, + expected: []string{ + "config[llm_provider]: unsupported llm_provider 'unknown'", + }, + }, + { + name: "missing anthropic_api_key", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + }, + }, + expected: []string{ + "config: missing required field 'anthropic_api_key'", + }, + }, + { + name: "missing openai_api_key", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "openai", + "llm_model": "gpt-4", + }, + }, + expected: []string{ + "config: missing required field 'openai_api_key'", + }, + }, + { + name: "missing ollama_url", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "ollama", + "llm_model": "llama2", + }, + }, + expected: []string{ + "config: missing required field 'ollama_url'", + }, + }, + { + name: "invalid cpus", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + Cpus: utils.PointerTo("invalid"), + }, + expected: []string{ + "cpus: failed to parse CPUs", + }, + }, + { + name: "invalid memory", + svc: &api.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + HostIds: []api.Identifier{"host-1"}, + Config: map[string]any{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-...", + }, + Memory: utils.PointerTo("invalid"), + }, + expected: []string{ + "memory: failed to parse bytes", + }, + }, + { + name: "multiple validation errors", + svc: &api.ServiceSpec{ + ServiceID: "mcp server", + ServiceType: "unknown", + Version: "v1.0", + HostIds: []api.Identifier{"host-1", "host-1"}, + Config: map[string]any{}, + Cpus: utils.PointerTo("invalid"), + }, + expected: []string{ + "service_id:", + "service_type: unsupported service type", + "version: version must be in semver format (e.g., '1.0.0') or 'latest'", + "host_ids[1]: host IDs must be unique", + "cpus: failed to parse CPUs", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := errors.Join(validateServiceSpec(tc.svc, nil)...) + if len(tc.expected) < 1 { + assert.NoError(t, err) + } else { + for _, expected := range tc.expected { + assert.ErrorContains(t, err, expected) + } + } + }) + } +} diff --git a/server/internal/database/database.go b/server/internal/database/database.go index 149b2a82..91e0bd37 100644 --- a/server/internal/database/database.go +++ b/server/internal/database/database.go @@ -29,13 +29,14 @@ func DatabaseStateModifiable(state DatabaseState) bool { } type Database struct { - DatabaseID string - TenantID *string - CreatedAt time.Time - UpdatedAt time.Time - State DatabaseState - Spec *Spec - Instances []*Instance + DatabaseID string + TenantID *string + CreatedAt time.Time + UpdatedAt time.Time + State DatabaseState + Spec *Spec + Instances []*Instance + ServiceInstances []*ServiceInstance } func databaseToStored(d *Database) *StoredDatabase { @@ -48,19 +49,20 @@ func databaseToStored(d *Database) *StoredDatabase { } } -func storedToDatabase(d *StoredDatabase, storedSpec *StoredSpec, instances []*Instance) *Database { +func storedToDatabase(d *StoredDatabase, storedSpec *StoredSpec, instances []*Instance, serviceInstances []*ServiceInstance) *Database { return &Database{ - DatabaseID: d.DatabaseID, - TenantID: d.TenantID, - CreatedAt: d.CreatedAt, - UpdatedAt: d.UpdatedAt, - State: d.State, - Spec: storedSpec.Spec, - Instances: instances, + DatabaseID: d.DatabaseID, + TenantID: d.TenantID, + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + State: d.State, + Spec: storedSpec.Spec, + Instances: instances, + ServiceInstances: serviceInstances, } } -func storedToDatabases(storedDbs []*StoredDatabase, storedSpecs []*StoredSpec, allInstances []*Instance) []*Database { +func storedToDatabases(storedDbs []*StoredDatabase, storedSpecs []*StoredSpec, allInstances []*Instance, allServiceInstances []*ServiceInstance) []*Database { specsByID := make(map[string]*StoredSpec, len(storedSpecs)) for _, spec := range storedSpecs { specsByID[spec.DatabaseID] = spec @@ -71,11 +73,17 @@ func storedToDatabases(storedDbs []*StoredDatabase, storedSpecs []*StoredSpec, a instancesByID[instance.DatabaseID] = append(instancesByID[instance.DatabaseID], instance) } + serviceInstancesByID := make(map[string][]*ServiceInstance, len(allServiceInstances)) + for _, serviceInstance := range allServiceInstances { + serviceInstancesByID[serviceInstance.DatabaseID] = append(serviceInstancesByID[serviceInstance.DatabaseID], serviceInstance) + } + databases := make([]*Database, len(storedDbs)) for i, stored := range storedDbs { spec := specsByID[stored.DatabaseID] instances := instancesByID[stored.DatabaseID] - databases[i] = storedToDatabase(stored, spec, instances) + serviceInstances := serviceInstancesByID[stored.DatabaseID] + databases[i] = storedToDatabase(stored, spec, instances, serviceInstances) } return databases diff --git a/server/internal/database/instance.go b/server/internal/database/instance.go index 77e24424..65af8747 100644 --- a/server/internal/database/instance.go +++ b/server/internal/database/instance.go @@ -7,7 +7,8 @@ import ( "github.com/pgEdge/control-plane/server/internal/patroni" ) -const InstanceMoniterRefreshInterval = 5 * time.Second +const InstanceMonitorRefreshInterval = 5 * time.Second +const ServiceInstanceMonitorRefreshInterval = 10 * time.Second type InstanceState string @@ -117,7 +118,7 @@ func storedToInstance(instance *StoredInstance, status *StoredInstanceStatus) *I // We want to infer the instance state if the instance is supposed to be // available. if out.State == InstanceStateAvailable && status != nil { - breakpoint := time.Now().Add(-2 * InstanceMoniterRefreshInterval) + breakpoint := time.Now().Add(-2 * InstanceMonitorRefreshInterval) if out.Status.StatusUpdatedAt.Before(breakpoint) { out.State = InstanceStateUnknown out.Status = nil diff --git a/server/internal/database/orchestrator.go b/server/internal/database/orchestrator.go index 02884245..e987b75a 100644 --- a/server/internal/database/orchestrator.go +++ b/server/internal/database/orchestrator.go @@ -14,11 +14,21 @@ import ( const pgEdgeUser = "pgedge" +// ResourceTypeServiceInstance is the resource type identifier for service instances. +// This constant is defined here to avoid import cycles between the orchestrator +// and workflow packages. +const ResourceTypeServiceInstance = "swarm.service_instance" + type InstanceResources struct { Instance *InstanceResource Resources []*resource.ResourceData } +type ServiceInstanceResources struct { + ServiceInstance *ServiceInstance + Resources []*resource.ResourceData +} + func (r *InstanceResources) InstanceID() string { return r.Instance.Spec.InstanceID } @@ -120,7 +130,9 @@ func (c *ConnectionInfo) PeerDSN(dbName string) *postgres.DSN { type Orchestrator interface { GenerateInstanceResources(spec *InstanceSpec) (*InstanceResources, error) GenerateInstanceRestoreResources(spec *InstanceSpec, taskID uuid.UUID) (*InstanceResources, error) + GenerateServiceInstanceResources(spec *ServiceInstanceSpec) (*ServiceInstanceResources, error) GetInstanceConnectionInfo(ctx context.Context, databaseID, instanceID string) (*ConnectionInfo, error) + GetServiceInstanceStatus(ctx context.Context, serviceInstanceID string) (*ServiceInstanceStatus, error) CreatePgBackRestBackup(ctx context.Context, w io.Writer, instanceID string, options *pgbackrest.BackupOptions) error ValidateInstanceSpecs(ctx context.Context, changes []*InstanceSpecChange) ([]*ValidationResult, error) StopInstance(ctx context.Context, instanceID string) error diff --git a/server/internal/database/service.go b/server/internal/database/service.go index ab8caab5..2c6ed473 100644 --- a/server/internal/database/service.go +++ b/server/internal/database/service.go @@ -13,13 +13,14 @@ import ( ) var ( - ErrDatabaseAlreadyExists = errors.New("database already exists") - ErrDatabaseNotFound = errors.New("database not found") - ErrDatabaseNotModifiable = errors.New("database not modifiable") - ErrInstanceNotFound = errors.New("instance not found") - ErrInstanceStopped = errors.New("instance stopped") - ErrInvalidDatabaseUpdate = errors.New("invalid database update") - ErrInvalidSourceNode = errors.New("invalid source node") + ErrDatabaseAlreadyExists = errors.New("database already exists") + ErrDatabaseNotFound = errors.New("database not found") + ErrDatabaseNotModifiable = errors.New("database not modifiable") + ErrInstanceNotFound = errors.New("instance not found") + ErrInstanceStopped = errors.New("instance stopped") + ErrInvalidDatabaseUpdate = errors.New("invalid database update") + ErrInvalidSourceNode = errors.New("invalid source node") + ErrServiceInstanceNotFound = errors.New("service instance not found") ) type Service struct { @@ -96,6 +97,11 @@ func (s *Service) UpdateDatabase(ctx context.Context, state DatabaseState, spec return nil, fmt.Errorf("failed to get database instances: %w", err) } + serviceInstances, err := s.GetServiceInstances(ctx, spec.DatabaseID) + if err != nil { + return nil, fmt.Errorf("failed to get service instances: %w", err) + } + currentSpec.Spec = spec currentDB.UpdatedAt = time.Now() currentDB.State = state @@ -107,12 +113,20 @@ func (s *Service) UpdateDatabase(ctx context.Context, state DatabaseState, spec return nil, fmt.Errorf("failed to persist database: %w", err) } - db := storedToDatabase(currentDB, currentSpec, instances) + db := storedToDatabase(currentDB, currentSpec, instances, serviceInstances) return db, nil } func (s *Service) DeleteDatabase(ctx context.Context, databaseID string) error { + // Note: This method only deletes the database spec and database state from etcd. + // Instances and service instances are deleted via their resource lifecycle + // in the DeleteDatabase workflow (which calls resource.Delete() on each resource). + // The workflow ensures proper cleanup order: + // 1. Scale down and remove Docker containers (via resource Delete methods) + // 2. Delete etcd state (via DeleteInstance/DeleteServiceInstance in resource Delete) + // 3. Delete database spec and state (this method) + var ops []storage.TxnOperation spec, err := s.store.Spec.GetByKey(databaseID).Exec(ctx) @@ -158,7 +172,12 @@ func (s *Service) GetDatabase(ctx context.Context, databaseID string) (*Database return nil, fmt.Errorf("failed to get database instances: %w", err) } - return storedToDatabase(storedDb, storedSpec, instances), nil + serviceInstances, err := s.GetServiceInstances(ctx, databaseID) + if err != nil { + return nil, fmt.Errorf("failed to get service instances: %w", err) + } + + return storedToDatabase(storedDb, storedSpec, instances, serviceInstances), nil } func (s *Service) GetDatabases(ctx context.Context) ([]*Database, error) { @@ -177,7 +196,12 @@ func (s *Service) GetDatabases(ctx context.Context) ([]*Database, error) { return nil, err } - databases := storedToDatabases(storedDbs, storedSpecs, instances) + serviceInstances, err := s.GetAllServiceInstances(ctx) + if err != nil { + return nil, err + } + + databases := storedToDatabases(storedDbs, storedSpecs, instances, serviceInstances) return databases, nil } @@ -355,6 +379,25 @@ func (s *Service) GetAllInstances(ctx context.Context) ([]*Instance, error) { return instances, nil } +func (s *Service) GetAllServiceInstances(ctx context.Context) ([]*ServiceInstance, error) { + storedServiceInstances, err := s.store.ServiceInstance. + GetAll(). + Exec(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get stored service instances: %w", err) + } + storedStatuses, err := s.store.ServiceInstanceStatus. + GetAll(). + Exec(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get stored service instance statuses: %w", err) + } + + serviceInstances := storedToServiceInstances(storedServiceInstances, storedStatuses) + + return serviceInstances, nil +} + func (s *Service) InstanceCountForHost(ctx context.Context, hostID string) (int, error) { storedInstances, err := s.store.Instance. GetAll(). @@ -377,6 +420,9 @@ func (s *Service) PopulateSpecDefaults(ctx context.Context, spec *Spec) error { for _, node := range spec.Nodes { hostIDs = append(hostIDs, node.HostIDs...) } + for _, svc := range spec.Services { + hostIDs = append(hostIDs, svc.HostIDs...) + } hosts, err := s.hostSvc.GetHosts(ctx, hostIDs) if err != nil { return fmt.Errorf("failed to get hosts: %w", err) @@ -422,6 +468,15 @@ func (s *Service) PopulateSpecDefaults(ctx context.Context, spec *Spec) error { } } + // Validate that all service host IDs refer to registered hosts + for _, svc := range spec.Services { + for _, hostID := range svc.HostIDs { + if _, ok := hostsByID[hostID]; !ok { + return fmt.Errorf("service %q: host %s not found", svc.ServiceID, hostID) + } + } + } + return nil } @@ -511,3 +566,196 @@ func tenantIDsMatch(a, b *string) bool { return false } } + +// Service Instance Management Methods + +func (s *Service) UpdateServiceInstance(ctx context.Context, opts *ServiceInstanceUpdateOptions) error { + serviceInstance, err := s.store.ServiceInstance. + GetByKey(opts.DatabaseID, opts.ServiceInstanceID). + Exec(ctx) + if errors.Is(err, storage.ErrNotFound) { + serviceInstance = NewStoredServiceInstance(opts) + } else if err != nil { + return fmt.Errorf("failed to get stored service instance: %w", err) + } else { + serviceInstance.Update(opts) + } + + err = s.store.ServiceInstance. + Put(serviceInstance). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to update stored service instance: %w", err) + } + + return nil +} + +// SetServiceInstanceState performs a targeted state update using a direct +// key lookup instead of scanning all service instances. +func (s *Service) SetServiceInstanceState( + ctx context.Context, + databaseID, serviceInstanceID string, + state ServiceInstanceState, +) error { + stored, err := s.store.ServiceInstance. + GetByKey(databaseID, serviceInstanceID). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to get service instance: %w", err) + } + stored.State = state + stored.Error = "" + stored.UpdatedAt = time.Now() + return s.store.ServiceInstance.Put(stored).Exec(ctx) +} + +func (s *Service) DeleteServiceInstance(ctx context.Context, databaseID, serviceInstanceID string) error { + _, err := s.store.ServiceInstance. + DeleteByKey(databaseID, serviceInstanceID). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete stored service instance: %w", err) + } + _, err = s.store.ServiceInstanceStatus. + DeleteByKey(databaseID, serviceInstanceID). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete stored service instance status: %w", err) + } + + return nil +} + +func (s *Service) UpdateServiceInstanceStatus( + ctx context.Context, + databaseID string, + serviceInstanceID string, + status *ServiceInstanceStatus, +) error { + stored := &StoredServiceInstanceStatus{ + DatabaseID: databaseID, + ServiceInstanceID: serviceInstanceID, + Status: status, + } + err := s.store.ServiceInstanceStatus. + Put(stored). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to update stored service instance status: %w", err) + } + + return nil +} + +func (s *Service) GetServiceInstances(ctx context.Context, databaseID string) ([]*ServiceInstance, error) { + storedServiceInstances, err := s.store.ServiceInstance. + GetByDatabaseID(databaseID). + Exec(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get stored service instances: %w", err) + } + storedStatuses, err := s.store.ServiceInstanceStatus. + GetByDatabaseID(databaseID). + Exec(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get stored service instance statuses: %w", err) + } + + serviceInstances := storedToServiceInstances(storedServiceInstances, storedStatuses) + + return serviceInstances, nil +} + +func (s *Service) GetServiceInstance(ctx context.Context, databaseID, serviceInstanceID string) (*ServiceInstance, error) { + storedServiceInstance, err := s.store.ServiceInstance. + GetByKey(databaseID, serviceInstanceID). + Exec(ctx) + if errors.Is(err, storage.ErrNotFound) { + return nil, ErrServiceInstanceNotFound + } else if err != nil { + return nil, fmt.Errorf("failed to get stored service instance: %w", err) + } + + storedStatus, err := s.store.ServiceInstanceStatus. + GetByKey(databaseID, serviceInstanceID). + Exec(ctx) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + return nil, fmt.Errorf("failed to get stored service instance status: %w", err) + } + + serviceInstance := storedToServiceInstance(storedServiceInstance, storedStatus) + + return serviceInstance, nil +} + +type ServiceInstanceStateUpdate struct { + DatabaseID string `json:"database_id,omitempty"` + State ServiceInstanceState `json:"state"` + Status *ServiceInstanceStatus `json:"status,omitempty"` + Error string `json:"error,omitempty"` +} + +func (s *Service) UpdateServiceInstanceState( + ctx context.Context, + serviceInstanceID string, + update *ServiceInstanceStateUpdate, +) error { + var databaseID string + var serviceID string + var hostID string + + if update.DatabaseID != "" { + // Use targeted lookup when DatabaseID is provided + stored, err := s.store.ServiceInstance. + GetByKey(update.DatabaseID, serviceInstanceID). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to get service instance: %w", err) + } + databaseID = stored.DatabaseID + serviceID = stored.ServiceID + hostID = stored.HostID + } else { + // Fall back to full scan when DatabaseID is not provided + serviceInstances, err := s.GetAllServiceInstances(ctx) + if err != nil { + return fmt.Errorf("failed to get service instances: %w", err) + } + + for _, si := range serviceInstances { + if si.ServiceInstanceID == serviceInstanceID { + databaseID = si.DatabaseID + serviceID = si.ServiceID + hostID = si.HostID + break + } + } + if databaseID == "" { + return fmt.Errorf("service instance %s not found", serviceInstanceID) + } + } + + // Update the service instance state + err := s.UpdateServiceInstance(ctx, &ServiceInstanceUpdateOptions{ + ServiceInstanceID: serviceInstanceID, + ServiceID: serviceID, + DatabaseID: databaseID, + HostID: hostID, + State: update.State, + Error: update.Error, + }) + if err != nil { + return fmt.Errorf("failed to update service instance: %w", err) + } + + // Update the service instance status if provided + if update.Status != nil { + err = s.UpdateServiceInstanceStatus(ctx, databaseID, serviceInstanceID, update.Status) + if err != nil { + return fmt.Errorf("failed to update service instance status: %w", err) + } + } + + return nil +} diff --git a/server/internal/database/service_instance.go b/server/internal/database/service_instance.go new file mode 100644 index 00000000..6bf941e3 --- /dev/null +++ b/server/internal/database/service_instance.go @@ -0,0 +1,224 @@ +package database + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "time" +) + +type ServiceInstanceState string + +const ( + ServiceInstanceStateCreating ServiceInstanceState = "creating" + ServiceInstanceStateRunning ServiceInstanceState = "running" + ServiceInstanceStateFailed ServiceInstanceState = "failed" + ServiceInstanceStateDeleting ServiceInstanceState = "deleting" +) + +type ServiceInstance struct { + ServiceInstanceID string `json:"service_instance_id"` + ServiceID string `json:"service_id"` + DatabaseID string `json:"database_id"` + HostID string `json:"host_id"` + State ServiceInstanceState `json:"state"` + Status *ServiceInstanceStatus `json:"status,omitempty"` + // Credentials is only populated during provisioning workflows. It is not + // persisted to etcd and will be nil when read from the store. + Credentials *ServiceUser `json:"credentials,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Error string `json:"error,omitempty"` +} + +type ServiceInstanceStatus struct { + ContainerID *string `json:"container_id,omitempty"` + ImageVersion *string `json:"image_version,omitempty"` + Hostname *string `json:"hostname,omitempty"` + IPv4Address *string `json:"ipv4_address,omitempty"` + Ports []PortMapping `json:"ports,omitempty"` + HealthCheck *HealthCheckResult `json:"health_check,omitempty"` + LastHealthAt *time.Time `json:"last_health_at,omitempty"` + ServiceReady *bool `json:"service_ready,omitempty"` +} + +type PortMapping struct { + Name string `json:"name"` + ContainerPort int `json:"container_port"` + HostPort *int `json:"host_port,omitempty"` +} + +type HealthCheckResult struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + CheckedAt time.Time `json:"checked_at"` +} + +// ServiceUser represents database credentials for a service instance. +// +// Each service instance receives dedicated database credentials with read-only access. +// This provides security isolation between service instances and prevents services from +// modifying database data. This may be relaxed or configurable in the future depending +// on use-case requirements. +// +// # Credential Generation +// +// Credentials are generated during service instance provisioning by the CreateServiceUser +// workflow activity. The username is deterministic (based on service instance ID), while +// the password is cryptographically random. +// +// # Security Properties +// +// - Unique per service instance (not shared between instances) +// - Read-only database access (SELECT + EXECUTE only, no DML/DDL) +// - 32-character random passwords +// - Storage in etcd alongside service instance metadata +// - Injected as environment variables into service containers +// +// # Usage +// +// Service containers receive credentials via environment variables: +// - PGUSER: Username (e.g., "svc_db1mcp") +// - PGPASSWORD: Password (32-character random string) +// +// The service connects to the database using these credentials, which are restricted +// to read-only operations via the "pgedge_application_read_only" role. +type ServiceUser struct { + Username string `json:"username"` // Format: "svc_{first-8-chars-of-instance-id}" + Password string `json:"password"` // 32-character cryptographically random string + Role string `json:"role"` // Database role, e.g., "pgedge_application_read_only" +} + +// GenerateServiceUsername creates a deterministic username for a service instance. +// +// # Username Format +// +// The username follows the pattern: "svc_{service_id}_{host_id}" +// +// Example: +// +// service_id: "mcp-server", host_id: "host1" +// Generated username: "svc_mcp-server_host1" +// +// # Rationale +// +// - "svc_" prefix: Clearly identifies service accounts vs. application users +// - service_id: Uniquely identifies the service within the database +// - host_id: Distinguishes service instances on different hosts +// - Deterministic: Same service_id + host_id always generates the same username +// +// # Uniqueness +// +// Service instance IDs are unique within a database (format: {db_id}-{service_id}-{host_id}). +// By using the full service_id and host_id, we guarantee uniqueness even when +// multiple services exist on the same database. +// +// # PostgreSQL Compatibility +// +// PostgreSQL identifier length limit is 63 characters. For short names the full +// service_id and host_id are used directly. When the combined username exceeds +// 63 characters, the function appends an 8-character hex hash (from SHA-256 of +// the full untruncated name) to a truncated prefix. This guarantees uniqueness +// even when two inputs share a long common prefix. +// +// Short name format: svc_{service_id}_{host_id} +// Long name format: svc_{first 50 chars of service_id_host_id}_{8-hex-hash} +func GenerateServiceUsername(serviceID, hostID string) string { + username := fmt.Sprintf("svc_%s_%s", serviceID, hostID) + + if len(username) <= 63 { + return username + } + + // Hash the full untruncated username for uniqueness + h := sha256.Sum256([]byte(username)) + suffix := hex.EncodeToString(h[:4]) // 8 hex chars + + // svc_ (4) + prefix (50) + _ (1) + hash (8) = 63 + raw := fmt.Sprintf("%s_%s", serviceID, hostID) + if len(raw) > 50 { + raw = raw[:50] + } + + return fmt.Sprintf("svc_%s_%s", raw, suffix) +} + +// GenerateServiceInstanceID creates a unique ID for a service instance. +// Format: {database_id}-{service_id}-{host_id} +func GenerateServiceInstanceID(databaseID, serviceID, hostID string) string { + return fmt.Sprintf("%s-%s-%s", databaseID, serviceID, hostID) +} + +// GenerateServiceName creates a Docker Swarm service name for a service instance. +// Format: {service_type}-{database_id}-{service_id}-{host_id} +func GenerateServiceName(serviceType, databaseID, serviceID, hostID string) string { + return fmt.Sprintf("%s-%s-%s-%s", serviceType, databaseID, serviceID, hostID) +} + +// GenerateServiceHostname creates a container hostname for a service instance. +// Format: {service_id}-{host_id} +func GenerateServiceHostname(serviceID, hostID string) string { + return fmt.Sprintf("%s-%s", serviceID, hostID) +} + +// GenerateDatabaseNetworkID creates the overlay network ID for a database. +// Format: {database_id} +func GenerateDatabaseNetworkID(databaseID string) string { + return databaseID +} + +// ServiceInstanceSpec contains the specification for generating service instance resources. +type ServiceInstanceSpec struct { + ServiceInstanceID string + ServiceSpec *ServiceSpec + DatabaseID string + DatabaseName string + HostID string + ServiceName string + Hostname string + CohortMemberID string + Credentials *ServiceUser + DatabaseNetworkID string + DatabaseHost string // Postgres instance hostname to connect to + DatabasePort int // Postgres instance port + Port *int // Service instance published port (optional, 0 = random) +} + +// storedToServiceInstance converts stored service instance and status to ServiceInstance. +func storedToServiceInstance(serviceInstance *StoredServiceInstance, status *StoredServiceInstanceStatus) *ServiceInstance { + if serviceInstance == nil { + return nil + } + out := &ServiceInstance{ + ServiceInstanceID: serviceInstance.ServiceInstanceID, + ServiceID: serviceInstance.ServiceID, + DatabaseID: serviceInstance.DatabaseID, + HostID: serviceInstance.HostID, + State: serviceInstance.State, + CreatedAt: serviceInstance.CreatedAt, + UpdatedAt: serviceInstance.UpdatedAt, + Error: serviceInstance.Error, + } + if status != nil { + out.Status = status.Status + } + + return out +} + +// storedToServiceInstances converts arrays of stored service instances and statuses to ServiceInstance array. +func storedToServiceInstances(storedServiceInstances []*StoredServiceInstance, storedStatuses []*StoredServiceInstanceStatus) []*ServiceInstance { + statusesByID := make(map[string]*StoredServiceInstanceStatus, len(storedStatuses)) + for _, s := range storedStatuses { + statusesByID[s.ServiceInstanceID] = s + } + + serviceInstances := make([]*ServiceInstance, len(storedServiceInstances)) + for idx, stored := range storedServiceInstances { + status := statusesByID[stored.ServiceInstanceID] + serviceInstance := storedToServiceInstance(stored, status) + serviceInstances[idx] = serviceInstance + } + + return serviceInstances +} diff --git a/server/internal/database/service_instance_status_store.go b/server/internal/database/service_instance_status_store.go new file mode 100644 index 00000000..4b3a73f9 --- /dev/null +++ b/server/internal/database/service_instance_status_store.go @@ -0,0 +1,63 @@ +package database + +import ( + clientv3 "go.etcd.io/etcd/client/v3" + + "github.com/pgEdge/control-plane/server/internal/storage" +) + +type StoredServiceInstanceStatus struct { + storage.StoredValue + DatabaseID string `json:"database_id"` + ServiceInstanceID string `json:"service_instance_id"` + Status *ServiceInstanceStatus `json:"status"` +} + +type ServiceInstanceStatusStore struct { + client *clientv3.Client + root string +} + +func NewServiceInstanceStatusStore(client *clientv3.Client, root string) *ServiceInstanceStatusStore { + return &ServiceInstanceStatusStore{ + client: client, + root: root, + } +} + +func (s *ServiceInstanceStatusStore) Prefix() string { + return storage.Prefix("/", s.root, "service_instance_statuses") +} + +func (s *ServiceInstanceStatusStore) DatabasePrefix(databaseID string) string { + return storage.Prefix(s.Prefix(), databaseID) +} + +func (s *ServiceInstanceStatusStore) Key(databaseID, serviceInstanceID string) string { + return storage.Key(s.DatabasePrefix(databaseID), serviceInstanceID) +} + +func (s *ServiceInstanceStatusStore) GetByKey(databaseID, serviceInstanceID string) storage.GetOp[*StoredServiceInstanceStatus] { + key := s.Key(databaseID, serviceInstanceID) + return storage.NewGetOp[*StoredServiceInstanceStatus](s.client, key) +} + +func (s *ServiceInstanceStatusStore) GetByDatabaseID(databaseID string) storage.GetMultipleOp[*StoredServiceInstanceStatus] { + prefix := s.DatabasePrefix(databaseID) + return storage.NewGetPrefixOp[*StoredServiceInstanceStatus](s.client, prefix) +} + +func (s *ServiceInstanceStatusStore) GetAll() storage.GetMultipleOp[*StoredServiceInstanceStatus] { + prefix := s.Prefix() + return storage.NewGetPrefixOp[*StoredServiceInstanceStatus](s.client, prefix) +} + +func (s *ServiceInstanceStatusStore) Put(item *StoredServiceInstanceStatus) storage.PutOp[*StoredServiceInstanceStatus] { + key := s.Key(item.DatabaseID, item.ServiceInstanceID) + return storage.NewPutOp(s.client, key, item) +} + +func (s *ServiceInstanceStatusStore) DeleteByKey(databaseID, serviceInstanceID string) storage.DeleteOp { + key := s.Key(databaseID, serviceInstanceID) + return storage.NewDeleteKeyOp(s.client, key) +} diff --git a/server/internal/database/service_instance_store.go b/server/internal/database/service_instance_store.go new file mode 100644 index 00000000..c30a9e68 --- /dev/null +++ b/server/internal/database/service_instance_store.go @@ -0,0 +1,99 @@ +package database + +import ( + "time" + + clientv3 "go.etcd.io/etcd/client/v3" + + "github.com/pgEdge/control-plane/server/internal/storage" +) + +type StoredServiceInstance struct { + storage.StoredValue + ServiceInstanceID string `json:"service_instance_id"` + ServiceID string `json:"service_id"` + DatabaseID string `json:"database_id"` + HostID string `json:"host_id"` + State ServiceInstanceState `json:"state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Error string `json:"error,omitempty"` +} + +type ServiceInstanceUpdateOptions struct { + ServiceInstanceID string `json:"service_instance_id"` + ServiceID string `json:"service_id"` + DatabaseID string `json:"database_id"` + HostID string `json:"host_id"` + State ServiceInstanceState `json:"state"` + Error string `json:"error,omitempty"` +} + +func NewStoredServiceInstance(opts *ServiceInstanceUpdateOptions) *StoredServiceInstance { + now := time.Now() + return &StoredServiceInstance{ + ServiceInstanceID: opts.ServiceInstanceID, + ServiceID: opts.ServiceID, + DatabaseID: opts.DatabaseID, + HostID: opts.HostID, + State: opts.State, + CreatedAt: now, + UpdatedAt: now, + Error: opts.Error, + } +} + +func (s *StoredServiceInstance) Update(opts *ServiceInstanceUpdateOptions) { + s.State = opts.State + s.Error = opts.Error + s.UpdatedAt = time.Now() +} + +type ServiceInstanceStore struct { + client *clientv3.Client + root string +} + +func NewServiceInstanceStore(client *clientv3.Client, root string) *ServiceInstanceStore { + return &ServiceInstanceStore{ + client: client, + root: root, + } +} + +func (s *ServiceInstanceStore) Prefix() string { + return storage.Prefix("/", s.root, "service_instances") +} + +func (s *ServiceInstanceStore) DatabasePrefix(databaseID string) string { + return storage.Prefix(s.Prefix(), databaseID) +} + +func (s *ServiceInstanceStore) Key(databaseID, serviceInstanceID string) string { + return storage.Key(s.DatabasePrefix(databaseID), serviceInstanceID) +} + +func (s *ServiceInstanceStore) GetByKey(databaseID, serviceInstanceID string) storage.GetOp[*StoredServiceInstance] { + key := s.Key(databaseID, serviceInstanceID) + return storage.NewGetOp[*StoredServiceInstance](s.client, key) +} + +func (s *ServiceInstanceStore) GetByDatabaseID(databaseID string) storage.GetMultipleOp[*StoredServiceInstance] { + prefix := s.DatabasePrefix(databaseID) + return storage.NewGetPrefixOp[*StoredServiceInstance](s.client, prefix) +} + +func (s *ServiceInstanceStore) GetAll() storage.GetMultipleOp[*StoredServiceInstance] { + prefix := s.Prefix() + return storage.NewGetPrefixOp[*StoredServiceInstance](s.client, prefix) +} + +func (s *ServiceInstanceStore) Put(item *StoredServiceInstance) storage.PutOp[*StoredServiceInstance] { + key := s.Key(item.DatabaseID, item.ServiceInstanceID) + return storage.NewPutOp(s.client, key, item) +} + +func (s *ServiceInstanceStore) DeleteByKey(databaseID, serviceInstanceID string) storage.DeleteOp { + key := s.Key(databaseID, serviceInstanceID) + return storage.NewDeleteKeyOp(s.client, key) +} diff --git a/server/internal/database/service_instance_test.go b/server/internal/database/service_instance_test.go new file mode 100644 index 00000000..b5c2a43e --- /dev/null +++ b/server/internal/database/service_instance_test.go @@ -0,0 +1,212 @@ +package database + +import ( + "testing" +) + +func TestGenerateServiceUsername(t *testing.T) { + tests := []struct { + name string + serviceID string + hostID string + want string + }{ + { + name: "standard service instance", + serviceID: "mcp-server", + hostID: "host1", + want: "svc_mcp-server_host1", + }, + { + name: "multiple services on same database - service 1", + serviceID: "appmcp-1", + hostID: "host1", + want: "svc_appmcp-1_host1", + }, + { + name: "multiple services on same database - service 2", + serviceID: "appmcp-2", + hostID: "host1", + want: "svc_appmcp-2_host1", + }, + { + name: "service with multi-part service ID", + serviceID: "my-mcp-service", + hostID: "host2", + want: "svc_my-mcp-service_host2", + }, + { + name: "simple service and host IDs", + serviceID: "mcp", + hostID: "n1", + want: "svc_mcp_n1", + }, + { + name: "long service ID uses hash suffix", + serviceID: "very-long-service-name-that-exceeds-postgres-limit-significantly", + hostID: "host1", + want: "svc_very-long-service-name-that-exceeds-postgres-limit_175de8cf", + }, + { + name: "long names with shared prefix produce different usernames (case A)", + serviceID: "very-long-service-name-that-exceeds-postgres-limit-AAA", + hostID: "host1", + want: "svc_very-long-service-name-that-exceeds-postgres-limit_860c8613", + }, + { + name: "long names with shared prefix produce different usernames (case B)", + serviceID: "very-long-service-name-that-exceeds-postgres-limit-BBB", + hostID: "host1", + want: "svc_very-long-service-name-that-exceeds-postgres-limit_c9cb0bb2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateServiceUsername(tt.serviceID, tt.hostID) + if got != tt.want { + t.Errorf("GenerateServiceUsername() = %v, want %v", got, tt.want) + } + if len(got) > 63 { + t.Errorf("GenerateServiceUsername() length = %d, must be <= 63", len(got)) + } + }) + } +} + +func TestGenerateServiceInstanceID(t *testing.T) { + tests := []struct { + name string + databaseID string + serviceID string + hostID string + want string + }{ + { + name: "standard service instance", + databaseID: "db1", + serviceID: "mcp-server", + hostID: "host1", + want: "db1-mcp-server-host1", + }, + { + name: "multi-part identifiers", + databaseID: "my-database", + serviceID: "my-service", + hostID: "my-host", + want: "my-database-my-service-my-host", + }, + { + name: "simple identifiers", + databaseID: "db", + serviceID: "svc", + hostID: "h1", + want: "db-svc-h1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateServiceInstanceID(tt.databaseID, tt.serviceID, tt.hostID) + if got != tt.want { + t.Errorf("GenerateServiceInstanceID() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGenerateServiceName(t *testing.T) { + tests := []struct { + name string + serviceType string + databaseID string + serviceID string + hostID string + want string + }{ + { + name: "mcp service", + serviceType: "mcp", + databaseID: "db1", + serviceID: "mcp-server", + hostID: "host1", + want: "mcp-db1-mcp-server-host1", + }, + { + name: "simple identifiers", + serviceType: "svc", + databaseID: "db", + serviceID: "s1", + hostID: "h1", + want: "svc-db-s1-h1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateServiceName(tt.serviceType, tt.databaseID, tt.serviceID, tt.hostID) + if got != tt.want { + t.Errorf("GenerateServiceName() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGenerateServiceHostname(t *testing.T) { + tests := []struct { + name string + serviceID string + hostID string + want string + }{ + { + name: "standard service instance", + serviceID: "mcp-server", + hostID: "host1", + want: "mcp-server-host1", + }, + { + name: "simple identifiers", + serviceID: "svc", + hostID: "h1", + want: "svc-h1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateServiceHostname(tt.serviceID, tt.hostID) + if got != tt.want { + t.Errorf("GenerateServiceHostname() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGenerateDatabaseNetworkID(t *testing.T) { + tests := []struct { + name string + databaseID string + want string + }{ + { + name: "standard database", + databaseID: "db1", + want: "db1", + }, + { + name: "multi-part database ID", + databaseID: "my-database", + want: "my-database", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateDatabaseNetworkID(tt.databaseID) + if got != tt.want { + t.Errorf("GenerateDatabaseNetworkID() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/server/internal/database/spec.go b/server/internal/database/spec.go index edb18aa4..2fd2123b 100644 --- a/server/internal/database/spec.go +++ b/server/internal/database/spec.go @@ -113,6 +113,33 @@ func (u *User) DefaultOptionalFieldsFrom(other *User) { } } +type ServiceSpec struct { + ServiceID string `json:"service_id"` + ServiceType string `json:"service_type"` + Version string `json:"version"` + HostIDs []string `json:"host_ids"` + Config map[string]any `json:"config"` + Port *int `json:"port,omitempty"` + CPUs *float64 `json:"cpus,omitempty"` + MemoryBytes *uint64 `json:"memory,omitempty"` +} + +func (s *ServiceSpec) Clone() *ServiceSpec { + if s == nil { + return nil + } + return &ServiceSpec{ + ServiceID: s.ServiceID, + ServiceType: s.ServiceType, + Version: s.Version, + HostIDs: slices.Clone(s.HostIDs), + Config: maps.Clone(s.Config), + Port: utils.ClonePointer(s.Port), + CPUs: utils.ClonePointer(s.CPUs), + MemoryBytes: utils.ClonePointer(s.MemoryBytes), + } +} + type Extension struct { Name string `json:"name"` Version string `json:"version"` @@ -260,6 +287,7 @@ type Spec struct { MemoryBytes uint64 `json:"memory"` Nodes []*Node `json:"nodes"` DatabaseUsers []*User `json:"database_users"` + Services []*ServiceSpec `json:"services,omitempty"` BackupConfig *BackupConfig `json:"backup_config"` RestoreConfig *RestoreConfig `json:"restore_config"` PostgreSQLConf map[string]any `json:"postgresql_conf"` @@ -329,6 +357,10 @@ func (s *Spec) Clone() *Spec { for i, user := range s.DatabaseUsers { users[i] = user.Clone() } + services := make([]*ServiceSpec, len(s.Services)) + for i, service := range s.Services { + services[i] = service.Clone() + } return &Spec{ DatabaseID: s.DatabaseID, @@ -342,6 +374,7 @@ func (s *Spec) Clone() *Spec { PostgreSQLConf: maps.Clone(s.PostgreSQLConf), Nodes: nodes, DatabaseUsers: users, + Services: services, BackupConfig: s.BackupConfig.Clone(), RestoreConfig: s.RestoreConfig.Clone(), OrchestratorOpts: s.OrchestratorOpts.Clone(), diff --git a/server/internal/database/store.go b/server/internal/database/store.go index 2322511e..c7aed96f 100644 --- a/server/internal/database/store.go +++ b/server/internal/database/store.go @@ -7,20 +7,24 @@ import ( ) type Store struct { - client *clientv3.Client - Spec *SpecStore - Database *DatabaseStore - Instance *InstanceStore - InstanceStatus *InstanceStatusStore + client *clientv3.Client + Spec *SpecStore + Database *DatabaseStore + Instance *InstanceStore + InstanceStatus *InstanceStatusStore + ServiceInstance *ServiceInstanceStore + ServiceInstanceStatus *ServiceInstanceStatusStore } func NewStore(client *clientv3.Client, root string) *Store { return &Store{ - client: client, - Spec: NewSpecStore(client, root), - Database: NewDatabaseStore(client, root), - Instance: NewInstanceStore(client, root), - InstanceStatus: NewInstanceStatusStore(client, root), + client: client, + Spec: NewSpecStore(client, root), + Database: NewDatabaseStore(client, root), + Instance: NewInstanceStore(client, root), + InstanceStatus: NewInstanceStatusStore(client, root), + ServiceInstance: NewServiceInstanceStore(client, root), + ServiceInstanceStatus: NewServiceInstanceStatusStore(client, root), } } diff --git a/server/internal/docker/docker.go b/server/internal/docker/docker.go index d4c85314..2145eeb1 100644 --- a/server/internal/docker/docker.go +++ b/server/internal/docker/docker.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "github.com/rs/zerolog" + v1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pgEdge/control-plane/server/internal/common" "github.com/samber/do" @@ -33,14 +35,18 @@ var _ do.Shutdownable = (*Docker)(nil) type Docker struct { client *client.Client + logger zerolog.Logger } -func NewDocker() (*Docker, error) { +func NewDocker(logger zerolog.Logger) (*Docker, error) { cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { return nil, fmt.Errorf("failed to create docker client: %w", err) } - return &Docker{client: cli}, nil + return &Docker{ + client: cli, + logger: logger.With().Str("component", "docker").Logger(), + }, nil } func (d *Docker) Exec(ctx context.Context, w io.Writer, containerID string, command []string) error { @@ -353,6 +359,16 @@ func (d *Docker) TasksByServiceID(ctx context.Context) (map[string][]swarm.Task, return tasksByServiceID, nil } +func (d *Docker) TaskList(ctx context.Context, filter filters.Args) ([]swarm.Task, error) { + tasks, err := d.client.TaskList(ctx, types.TaskListOptions{ + Filters: filter, + }) + if err != nil { + return nil, fmt.Errorf("failed to list tasks: %w", errTranslate(err)) + } + return tasks, nil +} + // WaitForService waits until the given service achieves the desired state and // number of tasks. The Swarm API can return stale data before the updated spec // has propagated to all manager nodes, so the optional 'previous swarm.Version' @@ -380,10 +396,18 @@ func (d *Docker) WaitForService(ctx context.Context, serviceID string, timeout t return fmt.Errorf("WaitForService is only usable for replicated services: %w", err) } if service.UpdateStatus != nil && service.UpdateStatus.State != swarm.UpdateStateCompleted { + d.logger.Debug(). + Str("service_id", serviceID). + Str("update_status", string(service.UpdateStatus.State)). + Msg("service update in progress, waiting") continue } if service.Version.Index == previous.Index { // The old service version was returned + d.logger.Debug(). + Str("service_id", serviceID). + Uint64("version_index", service.Version.Index). + Msg("old service version returned, waiting") continue } var desired uint64 @@ -399,17 +423,60 @@ func (d *Docker) WaitForService(ctx context.Context, serviceID string, timeout t if err != nil { return fmt.Errorf("failed to list tasks for service: %w", errTranslate(err)) } - var running, stopping uint64 + var running, stopping, failed, preparing, pending uint64 + var lastFailureMsg string + var taskStates []string for _, t := range tasks { + taskStates = append(taskStates, string(t.Status.State)) if t.Status.State == swarm.TaskStateRunning { if t.DesiredState == swarm.TaskStateRunning { running++ } else { stopping++ } + } else if t.Status.State == swarm.TaskStateFailed || + t.Status.State == swarm.TaskStateRejected { + failed++ + // Capture the error message from the most recent failed task + if t.Status.Err != "" { + lastFailureMsg = t.Status.Err + } else if t.Status.Message != "" { + lastFailureMsg = t.Status.Message + } + } else if t.Status.State == swarm.TaskStatePreparing { + preparing++ + } else if t.Status.State == swarm.TaskStatePending || + t.Status.State == swarm.TaskStateAssigned || + t.Status.State == swarm.TaskStateAccepted { + pending++ + } + } + + d.logger.Debug(). + Str("service_id", serviceID). + Uint64("desired", desired). + Uint64("running", running). + Uint64("stopping", stopping). + Uint64("failed", failed). + Uint64("preparing", preparing). + Uint64("pending", pending). + Int("total_tasks", len(tasks)). + Strs("task_states", taskStates). + Msg("checking service task status") + + // If we have failed tasks and no running tasks, the service won't start + if failed > 0 && running == 0 { + if lastFailureMsg != "" { + return fmt.Errorf("service tasks failed: %s", lastFailureMsg) } + return fmt.Errorf("service has %d failed task(s), expected %d running", failed, desired) } + if running == desired && stopping == 0 { + d.logger.Info(). + Str("service_id", serviceID). + Uint64("running_tasks", running). + Msg("service tasks ready") return nil } } diff --git a/server/internal/docker/provide.go b/server/internal/docker/provide.go index e14331e1..3c4dd85c 100644 --- a/server/internal/docker/provide.go +++ b/server/internal/docker/provide.go @@ -1,10 +1,14 @@ package docker -import "github.com/samber/do" +import ( + "github.com/rs/zerolog" + "github.com/samber/do" +) func Provide(i *do.Injector) { do.Provide(i, func(i *do.Injector) (*Docker, error) { - cli, err := NewDocker() + logger := do.MustInvoke[zerolog.Logger](i) + cli, err := NewDocker(logger) if err != nil { return nil, err } diff --git a/server/internal/monitor/host_monitor.go b/server/internal/monitor/host_monitor.go index 40c5aeda..880e6f2b 100644 --- a/server/internal/monitor/host_monitor.go +++ b/server/internal/monitor/host_monitor.go @@ -22,7 +22,7 @@ func NewHostMonitor( } m.monitor = NewMonitor( logger, - database.InstanceMoniterRefreshInterval, + database.InstanceMonitorRefreshInterval, m.checkStatus, ) return m diff --git a/server/internal/monitor/instance_monitor.go b/server/internal/monitor/instance_monitor.go index 094e746a..3ed3d30b 100644 --- a/server/internal/monitor/instance_monitor.go +++ b/server/internal/monitor/instance_monitor.go @@ -45,7 +45,7 @@ func NewInstanceMonitor( } m.statusMonitor = NewMonitor( logger, - database.InstanceMoniterRefreshInterval, + database.InstanceMonitorRefreshInterval, m.checkStatus, ) return m diff --git a/server/internal/monitor/resources.go b/server/internal/monitor/resources.go index 20bdff07..e8a234e3 100644 --- a/server/internal/monitor/resources.go +++ b/server/internal/monitor/resources.go @@ -4,4 +4,5 @@ import "github.com/pgEdge/control-plane/server/internal/resource" func RegisterResourceTypes(registry *resource.Registry) { resource.RegisterResourceType[*InstanceMonitorResource](registry, ResourceTypeInstanceMonitorResource) + resource.RegisterResourceType[*ServiceInstanceMonitorResource](registry, ResourceTypeServiceInstanceMonitorResource) } diff --git a/server/internal/monitor/service.go b/server/internal/monitor/service.go index 59e7c68a..31e9c131 100644 --- a/server/internal/monitor/service.go +++ b/server/internal/monitor/service.go @@ -16,15 +16,16 @@ import ( var _ do.Shutdownable = (*Service)(nil) type Service struct { - appCtx context.Context - cfg config.Config - logger zerolog.Logger - dbSvc *database.Service - certSvc *certificates.Service - dbOrch database.Orchestrator - store *Store - hostMoniter *HostMonitor - instances map[string]*InstanceMonitor + appCtx context.Context + cfg config.Config + logger zerolog.Logger + dbSvc *database.Service + certSvc *certificates.Service + dbOrch database.Orchestrator + store *Store + hostMoniter *HostMonitor + instances map[string]*InstanceMonitor + serviceInstances map[string]*ServiceInstanceMonitor } func NewService( @@ -37,14 +38,15 @@ func NewService( hostSvc *host.Service, ) *Service { return &Service{ - cfg: cfg, - logger: logger, - dbSvc: dbSvc, - certSvc: certSvc, - dbOrch: dbOrch, - store: store, - instances: map[string]*InstanceMonitor{}, - hostMoniter: NewHostMonitor(logger, hostSvc), + cfg: cfg, + logger: logger, + dbSvc: dbSvc, + certSvc: certSvc, + dbOrch: dbOrch, + store: store, + instances: map[string]*InstanceMonitor{}, + serviceInstances: map[string]*ServiceInstanceMonitor{}, + hostMoniter: NewHostMonitor(logger, hostSvc), } } @@ -70,6 +72,21 @@ func (s *Service) Start(ctx context.Context) error { ) } + storedSvcInst, err := s.store.ServiceInstanceMonitor. + GetAllByHostID(s.cfg.HostID). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to retrieve existing service instance monitors: %w", err) + } + + for _, svcInst := range storedSvcInst { + s.addServiceInstanceMonitor( + svcInst.DatabaseID, + svcInst.ServiceInstanceID, + svcInst.HostID, + ) + } + return nil } @@ -82,6 +99,12 @@ func (s *Service) Shutdown() error { s.instances = map[string]*InstanceMonitor{} + for _, mon := range s.serviceInstances { + mon.Stop() + } + + s.serviceInstances = map[string]*ServiceInstanceMonitor{} + return nil } @@ -143,3 +166,60 @@ func (s *Service) addInstanceMonitor(databaseID, instanceID, dbName string) { mon.Start(s.appCtx) s.instances[instanceID] = mon } + +func (s *Service) CreateServiceInstanceMonitor(ctx context.Context, databaseID, serviceInstanceID, hostID string) error { + if s.HasServiceInstanceMonitor(serviceInstanceID) { + err := s.DeleteServiceInstanceMonitor(ctx, serviceInstanceID) + if err != nil { + return fmt.Errorf("failed to delete existing service instance monitor: %w", err) + } + } + + err := s.store.ServiceInstanceMonitor.Put(&StoredServiceInstanceMonitor{ + HostID: hostID, + DatabaseID: databaseID, + ServiceInstanceID: serviceInstanceID, + }).Exec(ctx) + if err != nil { + return fmt.Errorf("failed to persist service instance monitor: %w", err) + } + + s.addServiceInstanceMonitor(databaseID, serviceInstanceID, hostID) + + return nil +} + +func (s *Service) DeleteServiceInstanceMonitor(ctx context.Context, serviceInstanceID string) error { + mon, ok := s.serviceInstances[serviceInstanceID] + if ok { + mon.Stop() + delete(s.serviceInstances, serviceInstanceID) + } + + _, err := s.store.ServiceInstanceMonitor. + DeleteByKey(s.cfg.HostID, serviceInstanceID). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete service instance monitor: %w", err) + } + + return nil +} + +func (s *Service) HasServiceInstanceMonitor(serviceInstanceID string) bool { + _, ok := s.serviceInstances[serviceInstanceID] + return ok +} + +func (s *Service) addServiceInstanceMonitor(databaseID, serviceInstanceID, hostID string) { + mon := NewServiceInstanceMonitor( + s.dbOrch, + s.dbSvc, + s.logger, + databaseID, + serviceInstanceID, + hostID, + ) + mon.Start(s.appCtx) + s.serviceInstances[serviceInstanceID] = mon +} diff --git a/server/internal/monitor/service_instance_monitor.go b/server/internal/monitor/service_instance_monitor.go new file mode 100644 index 00000000..3d4183cd --- /dev/null +++ b/server/internal/monitor/service_instance_monitor.go @@ -0,0 +1,318 @@ +package monitor + +import ( + "context" + "fmt" + "time" + + "github.com/rs/zerolog" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/utils" +) + +const serviceInstanceCreationTimeout = 5 * time.Minute + +// serviceInstanceRunningGracePeriod is the time we tolerate nil or error status +// for a recently-transitioned "running" instance. This accounts for the delay +// between Docker Swarm service creation and container label propagation. +const serviceInstanceRunningGracePeriod = 30 * time.Second + +type ServiceInstanceMonitor struct { + statusMonitor *Monitor + dbOrch database.Orchestrator + dbSvc *database.Service + logger zerolog.Logger + databaseID string + serviceInstanceID string + hostID string +} + +func NewServiceInstanceMonitor( + orch database.Orchestrator, + dbSvc *database.Service, + logger zerolog.Logger, + databaseID string, + serviceInstanceID string, + hostID string, +) *ServiceInstanceMonitor { + m := &ServiceInstanceMonitor{ + dbOrch: orch, + dbSvc: dbSvc, + logger: logger, + databaseID: databaseID, + serviceInstanceID: serviceInstanceID, + hostID: hostID, + } + m.statusMonitor = NewMonitor( + logger, + database.ServiceInstanceMonitorRefreshInterval, + m.checkStatus, + ) + return m +} + +func (m *ServiceInstanceMonitor) Start(ctx context.Context) { + m.statusMonitor.Start(ctx) +} + +func (m *ServiceInstanceMonitor) Stop() { + m.statusMonitor.Stop() +} + +func (m *ServiceInstanceMonitor) checkStatus(ctx context.Context) error { + m.logger.Debug(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Msg("checking service instance status") + + // Get current service instance + serviceInstance, err := m.dbSvc.GetServiceInstance(ctx, m.databaseID, m.serviceInstanceID) + if err != nil { + m.logger.Err(err). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Msg("failed to get service instance") + return fmt.Errorf("failed to get service instance: %w", err) + } + + if serviceInstance == nil { + m.logger.Warn(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Msg("service instance not found") + return fmt.Errorf("service instance not found") + } + + // Get status from orchestrator + status, err := m.dbOrch.GetServiceInstanceStatus(ctx, m.serviceInstanceID) + if err != nil { + m.logger.Err(err). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Msg("failed to get service instance status from orchestrator") + return m.handleStatusError(ctx, serviceInstance, err) + } + + // Handle state transitions based on current state and status + return m.handleStateTransition(ctx, serviceInstance, status) +} + +func (m *ServiceInstanceMonitor) handleStatusError( + ctx context.Context, + serviceInstance *database.ServiceInstance, + statusErr error, +) error { + // If we're in creating state and timeout has elapsed, mark as failed + if serviceInstance.State == database.ServiceInstanceStateCreating { + elapsed := time.Since(serviceInstance.CreatedAt) + if elapsed > serviceInstanceCreationTimeout { + m.logger.Error(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Dur("elapsed", elapsed). + Msg("service instance creation timeout - marking as failed") + + return m.updateState(ctx, database.ServiceInstanceStateFailed, nil, + fmt.Sprintf("creation timeout after %s: %v", elapsed, statusErr)) + } + // Still within timeout, don't update state yet + return statusErr + } + + // If we're in running state and getting errors, allow a grace period + // for recently-transitioned instances (container labels may not be visible yet) + if serviceInstance.State == database.ServiceInstanceStateRunning { + if time.Since(serviceInstance.UpdatedAt) < serviceInstanceRunningGracePeriod { + m.logger.Warn(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Err(statusErr). + Msg("service instance status check failed but within grace period") + return statusErr + } + + m.logger.Error(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Err(statusErr). + Msg("service instance status check failed - marking as failed") + + return m.updateState(ctx, database.ServiceInstanceStateFailed, nil, + fmt.Sprintf("status check failed: %v", statusErr)) + } + + return statusErr +} + +func (m *ServiceInstanceMonitor) handleStateTransition( + ctx context.Context, + serviceInstance *database.ServiceInstance, + status *database.ServiceInstanceStatus, +) error { + // Status is nil means container is not running + if status == nil { + return m.handleNilStatus(ctx, serviceInstance) + } + + // Check container health + isHealthy := m.isContainerHealthy(status) + + switch serviceInstance.State { + case database.ServiceInstanceStateCreating: + // Transition from creating to running when container is healthy + if isHealthy { + m.logger.Info(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Msg("service instance is healthy - transitioning to running") + return m.updateState(ctx, database.ServiceInstanceStateRunning, status, "") + } + + // Still creating, check for timeout + elapsed := time.Since(serviceInstance.CreatedAt) + if elapsed > serviceInstanceCreationTimeout { + m.logger.Error(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Dur("elapsed", elapsed). + Msg("service instance creation timeout - container not healthy") + return m.updateState(ctx, database.ServiceInstanceStateFailed, status, + fmt.Sprintf("creation timeout after %s - container not healthy", elapsed)) + } + + // Update status but keep state as creating + return m.updateStatus(ctx, status) + + case database.ServiceInstanceStateRunning: + // If no longer healthy, mark as failed + if !isHealthy { + m.logger.Error(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Msg("service instance is no longer healthy - marking as failed") + return m.updateState(ctx, database.ServiceInstanceStateFailed, status, + "container is no longer healthy") + } + + // Update status to keep it fresh + return m.updateStatus(ctx, status) + + default: + // For other states (failed, deleting), just update status + return m.updateStatus(ctx, status) + } +} + +func (m *ServiceInstanceMonitor) handleNilStatus( + ctx context.Context, + serviceInstance *database.ServiceInstance, +) error { + switch serviceInstance.State { + case database.ServiceInstanceStateCreating: + // Check for timeout + elapsed := time.Since(serviceInstance.CreatedAt) + if elapsed > serviceInstanceCreationTimeout { + m.logger.Error(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Dur("elapsed", elapsed). + Msg("service instance creation timeout - no status available") + return m.updateState(ctx, database.ServiceInstanceStateFailed, nil, + fmt.Sprintf("creation timeout after %s - no status available", elapsed)) + } + // Still within timeout, but log for debugging + m.logger.Debug(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Dur("elapsed", elapsed). + Msg("service instance status not yet available (still waiting)") + return nil + + case database.ServiceInstanceStateRunning: + // Allow a grace period for recently-transitioned instances + // (container labels may not be visible yet after deployment) + if time.Since(serviceInstance.UpdatedAt) < serviceInstanceRunningGracePeriod { + m.logger.Warn(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Msg("service instance status not available but within grace period") + return nil + } + + // Container disappeared, mark as failed + m.logger.Error(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Msg("service instance status not available - marking as failed") + return m.updateState(ctx, database.ServiceInstanceStateFailed, nil, + "container status not available") + + default: + // For other states, nil status is acceptable + return nil + } +} + +func (m *ServiceInstanceMonitor) isContainerHealthy(status *database.ServiceInstanceStatus) bool { + // Service is ready if ServiceReady flag is true + if status.ServiceReady != nil && *status.ServiceReady { + return true + } + + // Also check health check status if available + if status.HealthCheck != nil { + return status.HealthCheck.Status == "healthy" + } + + // If no explicit health information, consider it healthy if status exists + // (container is running) + return true +} + +func (m *ServiceInstanceMonitor) updateState( + ctx context.Context, + state database.ServiceInstanceState, + status *database.ServiceInstanceStatus, + errorMsg string, +) error { + m.logger.Debug(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Str("new_state", string(state)). + Str("error", errorMsg). + Msg("updating service instance state") + + err := m.dbSvc.UpdateServiceInstanceState(ctx, m.serviceInstanceID, &database.ServiceInstanceStateUpdate{ + DatabaseID: m.databaseID, + State: state, + Status: status, + Error: errorMsg, + }) + if err != nil { + return fmt.Errorf("failed to update service instance state: %w", err) + } + + return nil +} + +func (m *ServiceInstanceMonitor) updateStatus( + ctx context.Context, + status *database.ServiceInstanceStatus, +) error { + // Update last health check time + now := time.Now() + status.LastHealthAt = utils.PointerTo(now) + + m.logger.Debug(). + Str("service_instance_id", m.serviceInstanceID). + Str("database_id", m.databaseID). + Msg("updating service instance status") + + err := m.dbSvc.UpdateServiceInstanceStatus(ctx, m.databaseID, m.serviceInstanceID, status) + if err != nil { + return fmt.Errorf("failed to update service instance status: %w", err) + } + + return nil +} diff --git a/server/internal/monitor/service_instance_monitor_resource.go b/server/internal/monitor/service_instance_monitor_resource.go new file mode 100644 index 00000000..11526d35 --- /dev/null +++ b/server/internal/monitor/service_instance_monitor_resource.go @@ -0,0 +1,96 @@ +package monitor + +import ( + "context" + "fmt" + + "github.com/pgEdge/control-plane/server/internal/resource" + "github.com/samber/do" +) + +var _ resource.Resource = (*ServiceInstanceMonitorResource)(nil) + +const ResourceTypeServiceInstanceMonitorResource resource.Type = "monitor.service_instance" + +func ServiceInstanceMonitorResourceIdentifier(serviceInstanceID string) resource.Identifier { + return resource.Identifier{ + ID: serviceInstanceID, + Type: ResourceTypeServiceInstanceMonitorResource, + } +} + +type ServiceInstanceMonitorResource struct { + DatabaseID string `json:"database_id"` + ServiceInstanceID string `json:"service_instance_id"` + HostID string `json:"host_id"` +} + +func (m *ServiceInstanceMonitorResource) ResourceVersion() string { + return "1" +} + +func (m *ServiceInstanceMonitorResource) DiffIgnore() []string { + return nil +} + +func (m *ServiceInstanceMonitorResource) Executor() resource.Executor { + return resource.HostExecutor(m.HostID) +} + +func (m *ServiceInstanceMonitorResource) Identifier() resource.Identifier { + return ServiceInstanceMonitorResourceIdentifier(m.ServiceInstanceID) +} + +func (m *ServiceInstanceMonitorResource) Dependencies() []resource.Identifier { + return []resource.Identifier{ + { + ID: m.ServiceInstanceID, + Type: "swarm.service_instance", + }, + } +} + +func (m *ServiceInstanceMonitorResource) Refresh(ctx context.Context, rc *resource.Context) error { + service, err := do.Invoke[*Service](rc.Injector) + if err != nil { + return err + } + + if !service.HasServiceInstanceMonitor(m.ServiceInstanceID) { + return resource.ErrNotFound + } + + return nil +} + +func (m *ServiceInstanceMonitorResource) Create(ctx context.Context, rc *resource.Context) error { + service, err := do.Invoke[*Service](rc.Injector) + if err != nil { + return err + } + + err = service.CreateServiceInstanceMonitor(ctx, m.DatabaseID, m.ServiceInstanceID, m.HostID) + if err != nil { + return fmt.Errorf("failed to create service instance monitor: %w", err) + } + + return nil +} + +func (m *ServiceInstanceMonitorResource) Update(ctx context.Context, rc *resource.Context) error { + return m.Create(ctx, rc) +} + +func (m *ServiceInstanceMonitorResource) Delete(ctx context.Context, rc *resource.Context) error { + service, err := do.Invoke[*Service](rc.Injector) + if err != nil { + return err + } + + err = service.DeleteServiceInstanceMonitor(ctx, m.ServiceInstanceID) + if err != nil { + return fmt.Errorf("failed to delete service instance monitor: %w", err) + } + + return nil +} diff --git a/server/internal/monitor/service_instance_monitor_store.go b/server/internal/monitor/service_instance_monitor_store.go new file mode 100644 index 00000000..63fbc5e7 --- /dev/null +++ b/server/internal/monitor/service_instance_monitor_store.go @@ -0,0 +1,63 @@ +package monitor + +import ( + clientv3 "go.etcd.io/etcd/client/v3" + + "github.com/pgEdge/control-plane/server/internal/storage" +) + +type StoredServiceInstanceMonitor struct { + storage.StoredValue + HostID string `json:"host_id"` + DatabaseID string `json:"database_id"` + ServiceInstanceID string `json:"service_instance_id"` +} + +type ServiceInstanceMonitorStore struct { + client *clientv3.Client + root string +} + +func NewServiceInstanceMonitorStore(client *clientv3.Client, root string) *ServiceInstanceMonitorStore { + return &ServiceInstanceMonitorStore{ + client: client, + root: root, + } +} + +func (s *ServiceInstanceMonitorStore) Prefix() string { + return storage.Prefix("/", s.root, "service_instance_monitors") +} + +func (s *ServiceInstanceMonitorStore) HostPrefix(hostID string) string { + return storage.Prefix(s.Prefix(), hostID) +} + +func (s *ServiceInstanceMonitorStore) Key(hostID, serviceInstanceID string) string { + return storage.Key(s.HostPrefix(hostID), serviceInstanceID) +} + +func (s *ServiceInstanceMonitorStore) GetAllByHostID(hostID string) storage.GetMultipleOp[*StoredServiceInstanceMonitor] { + prefix := s.HostPrefix(hostID) + return storage.NewGetPrefixOp[*StoredServiceInstanceMonitor](s.client, prefix) +} + +func (s *ServiceInstanceMonitorStore) GetByKey(hostID, serviceInstanceID string) storage.GetOp[*StoredServiceInstanceMonitor] { + key := s.Key(hostID, serviceInstanceID) + return storage.NewGetOp[*StoredServiceInstanceMonitor](s.client, key) +} + +func (s *ServiceInstanceMonitorStore) DeleteByHostID(hostID string) storage.DeleteOp { + prefix := s.HostPrefix(hostID) + return storage.NewDeletePrefixOp(s.client, prefix) +} + +func (s *ServiceInstanceMonitorStore) DeleteByKey(hostID, serviceInstanceID string) storage.DeleteOp { + key := s.Key(hostID, serviceInstanceID) + return storage.NewDeleteKeyOp(s.client, key) +} + +func (s *ServiceInstanceMonitorStore) Put(item *StoredServiceInstanceMonitor) storage.PutOp[*StoredServiceInstanceMonitor] { + key := s.Key(item.HostID, item.ServiceInstanceID) + return storage.NewPutOp(s.client, key, item) +} diff --git a/server/internal/monitor/store.go b/server/internal/monitor/store.go index 236dd173..fc5522d1 100644 --- a/server/internal/monitor/store.go +++ b/server/internal/monitor/store.go @@ -7,14 +7,16 @@ import ( ) type Store struct { - client *clientv3.Client - InstanceMonitor *InstanceMonitorStore + client *clientv3.Client + InstanceMonitor *InstanceMonitorStore + ServiceInstanceMonitor *ServiceInstanceMonitorStore } func NewStore(client *clientv3.Client, root string) *Store { return &Store{ - client: client, - InstanceMonitor: NewInstanceMonitorStore(client, root), + client: client, + InstanceMonitor: NewInstanceMonitorStore(client, root), + ServiceInstanceMonitor: NewServiceInstanceMonitorStore(client, root), } } diff --git a/server/internal/orchestrator/swarm/orchestrator.go b/server/internal/orchestrator/swarm/orchestrator.go index c9c8a503..bb6769b7 100644 --- a/server/internal/orchestrator/swarm/orchestrator.go +++ b/server/internal/orchestrator/swarm/orchestrator.go @@ -43,6 +43,7 @@ const ( type Orchestrator struct { cfg config.Config versions *Versions + serviceVersions *ServiceVersions docker *docker.Docker logger zerolog.Logger dbNetworkAllocator Allocator @@ -86,10 +87,11 @@ func NewOrchestrator( } return &Orchestrator{ - cfg: cfg, - versions: NewVersions(cfg), - docker: d, - logger: logger, + cfg: cfg, + versions: NewVersions(cfg), + serviceVersions: NewServiceVersions(cfg), + docker: d, + logger: logger, dbNetworkAllocator: Allocator{ Prefix: dbNetworkPrefix, Bits: cfg.DockerSwarm.DatabaseNetworksSubnetBits, @@ -381,6 +383,89 @@ func (o *Orchestrator) GenerateInstanceRestoreResources(spec *database.InstanceS return instanceResources, nil } +func (o *Orchestrator) GenerateServiceInstanceResources(spec *database.ServiceInstanceSpec) (*database.ServiceInstanceResources, error) { + // Get service image based on service type and version + imageStr, err := o.serviceVersions.GetServiceImage(spec.ServiceSpec.ServiceType, spec.ServiceSpec.Version) + if err != nil { + return nil, fmt.Errorf("failed to get service image: %w", err) + } + + // Create ServiceImages struct + images := &ServiceImages{ + Image: imageStr, + } + + // Database network (shared with postgres instances) + databaseNetwork := &Network{ + Scope: "swarm", + Driver: OverlayDriver, + Name: fmt.Sprintf("%s-database", spec.DatabaseID), + Allocator: o.dbNetworkAllocator, + } + + // Service user role resource (manages database user lifecycle) + serviceUserRole := &ServiceUserRole{ + ServiceInstanceID: spec.ServiceInstanceID, + DatabaseID: spec.DatabaseID, + DatabaseName: spec.DatabaseName, + Username: spec.Credentials.Username, + HostID: spec.HostID, + } + + // Service instance spec resource + serviceInstanceSpec := &ServiceInstanceSpecResource{ + ServiceInstanceID: spec.ServiceInstanceID, + ServiceSpec: spec.ServiceSpec, + DatabaseID: spec.DatabaseID, + DatabaseName: spec.DatabaseName, + HostID: spec.HostID, + ServiceName: spec.ServiceName, + Hostname: spec.Hostname, + CohortMemberID: o.swarmNodeID, // Use orchestrator's swarm node ID (same as Postgres instances) + ServiceImages: images, + Credentials: spec.Credentials, + DatabaseNetworkID: databaseNetwork.Name, + DatabaseHost: spec.DatabaseHost, + DatabasePort: spec.DatabasePort, + Port: spec.Port, + } + + // Service instance resource (actual Docker service) + serviceInstance := &ServiceInstanceResource{ + ServiceInstanceID: spec.ServiceInstanceID, + DatabaseID: spec.DatabaseID, + ServiceName: spec.ServiceName, + } + + orchestratorResources := []resource.Resource{ + databaseNetwork, + serviceUserRole, + serviceInstanceSpec, + serviceInstance, + } + + // Convert to resource data + data := make([]*resource.ResourceData, len(orchestratorResources)) + for i, res := range orchestratorResources { + d, err := resource.ToResourceData(res) + if err != nil { + return nil, fmt.Errorf("failed to convert resource to resource data: %w", err) + } + data[i] = d + } + + return &database.ServiceInstanceResources{ + ServiceInstance: &database.ServiceInstance{ + ServiceInstanceID: spec.ServiceInstanceID, + ServiceID: spec.ServiceSpec.ServiceID, + DatabaseID: spec.DatabaseID, + HostID: spec.HostID, + State: database.ServiceInstanceStateCreating, + }, + Resources: data, + }, nil +} + func (o *Orchestrator) GetInstanceConnectionInfo(ctx context.Context, databaseID, instanceID string) (*database.ConnectionInfo, error) { container, err := GetPostgresContainer(ctx, o.docker, instanceID) if err != nil { @@ -430,6 +515,63 @@ func (o *Orchestrator) GetInstanceConnectionInfo(ctx context.Context, databaseID }, nil } +func (o *Orchestrator) GetServiceInstanceStatus(ctx context.Context, serviceInstanceID string) (*database.ServiceInstanceStatus, error) { + // Get the service container to retrieve network info and ports + // This activity is routed to the specific host where the service is constrained + container, err := GetServiceContainer(ctx, o.docker, serviceInstanceID) + if err != nil { + if errors.Is(err, ErrNoServiceContainer) { + // Container not found - service is still starting up + // Return nil status (not an error) so the workflow can continue + // The status will be populated later by monitoring or retry + return nil, nil + } + return nil, fmt.Errorf("failed to get service container: %w", err) + } + + // Inspect the container to get network information and ports + inspect, err := o.docker.ContainerInspect(ctx, container.ID) + if err != nil { + return nil, fmt.Errorf("failed to inspect service container: %w", err) + } + + // Extract ports from container port bindings + var ports []database.PortMapping + for portStr, bindings := range inspect.NetworkSettings.Ports { + // Skip if no bindings (unpublished ports) + if len(bindings) == 0 { + continue + } + + // Parse container port + containerPort, err := strconv.Atoi(portStr.Port()) + if err != nil { + continue // Skip malformed port + } + + // Get host port from first binding + hostPort, err := strconv.Atoi(bindings[0].HostPort) + if err != nil { + continue // Skip malformed port + } + + ports = append(ports, database.PortMapping{ + Name: portStr.Proto(), + ContainerPort: containerPort, + HostPort: utils.PointerTo(hostPort), + }) + } + + return &database.ServiceInstanceStatus{ + ContainerID: utils.PointerTo(inspect.ID), + ImageVersion: utils.PointerTo(inspect.Config.Image), + Hostname: utils.PointerTo(inspect.Config.Hostname), + IPv4Address: utils.PointerTo(o.cfg.IPv4Address), + Ports: ports, + ServiceReady: utils.PointerTo(true), + }, nil +} + func (o *Orchestrator) WorkerQueues() ([]workflow.Queue, error) { queues := []workflow.Queue{ utils.AnyQueue(), diff --git a/server/internal/orchestrator/swarm/resources.go b/server/internal/orchestrator/swarm/resources.go index 28320971..6f51e1d0 100644 --- a/server/internal/orchestrator/swarm/resources.go +++ b/server/internal/orchestrator/swarm/resources.go @@ -5,6 +5,9 @@ import "github.com/pgEdge/control-plane/server/internal/resource" func RegisterResourceTypes(registry *resource.Registry) { resource.RegisterResourceType[*PostgresServiceSpecResource](registry, ResourceTypePostgresServiceSpec) resource.RegisterResourceType[*PostgresService](registry, ResourceTypePostgresService) + resource.RegisterResourceType[*ServiceInstanceSpecResource](registry, ResourceTypeServiceInstanceSpec) + resource.RegisterResourceType[*ServiceInstanceResource](registry, ResourceTypeServiceInstance) + resource.RegisterResourceType[*ServiceUserRole](registry, ResourceTypeServiceUserRole) resource.RegisterResourceType[*Network](registry, ResourceTypeNetwork) resource.RegisterResourceType[*EtcdCreds](registry, ResourceTypeEtcdCreds) resource.RegisterResourceType[*PatroniConfig](registry, ResourceTypePatroniConfig) diff --git a/server/internal/orchestrator/swarm/service_images.go b/server/internal/orchestrator/swarm/service_images.go new file mode 100644 index 00000000..f03f6efc --- /dev/null +++ b/server/internal/orchestrator/swarm/service_images.go @@ -0,0 +1,89 @@ +package swarm + +import ( + "fmt" + "strings" + + "github.com/pgEdge/control-plane/server/internal/config" +) + +type ServiceImages struct { + Image string +} + +type ServiceVersions struct { + cfg config.Config + images map[string]map[string]*ServiceImages +} + +func NewServiceVersions(cfg config.Config) *ServiceVersions { + versions := &ServiceVersions{ + cfg: cfg, + images: make(map[string]map[string]*ServiceImages), + } + + // MCP service versions + // TODO: there is no "1.0.0" image yet - the latest is something like "1.0.0-beta3" + versions.addServiceImage("mcp", "1.0.0", &ServiceImages{ + Image: serviceImageTag(cfg, "postgres-mcp:1.0.0"), + }) + versions.addServiceImage("mcp", "latest", &ServiceImages{ + Image: serviceImageTag(cfg, "postgres-mcp:latest"), + }) + + return versions +} + +func (sv *ServiceVersions) addServiceImage(serviceType string, version string, images *ServiceImages) { + if _, ok := sv.images[serviceType]; !ok { + sv.images[serviceType] = make(map[string]*ServiceImages) + } + + sv.images[serviceType][version] = images +} + +func (sv *ServiceVersions) GetServiceImage(serviceType string, version string) (string, error) { + versionMap, ok := sv.images[serviceType] + if !ok { + return "", fmt.Errorf("unsupported service type %q", serviceType) + } + + images, ok := versionMap[version] + if !ok { + return "", fmt.Errorf("unsupported version %q for service type %q", version, serviceType) + } + + return images.Image, nil +} + +func (sv *ServiceVersions) SupportedServiceVersions(serviceType string) ([]string, error) { + versionMap, ok := sv.images[serviceType] + if !ok { + return nil, fmt.Errorf("unsupported service type %q", serviceType) + } + + versions := make([]string, 0, len(versionMap)) + for version := range versionMap { + versions = append(versions, version) + } + + return versions, nil +} + +func serviceImageTag(cfg config.Config, imageRef string) string { + // If the image reference already contains a registry (has a slash before the first colon), + // use it as-is. Otherwise, prepend the configured repository host. + if strings.Contains(imageRef, "/") { + // Image already has a registry or organization prefix + parts := strings.Split(imageRef, "/") + firstPart := parts[0] + // Check if first part looks like a registry (has a dot, colon, or is localhost) + if strings.Contains(firstPart, ".") || strings.Contains(firstPart, ":") || firstPart == "localhost" { + // First part looks like a registry + return imageRef + } + } + + // Prepend repository host + return fmt.Sprintf("%s/%s", cfg.DockerSwarm.ImageRepositoryHost, imageRef) +} diff --git a/server/internal/orchestrator/swarm/service_images_test.go b/server/internal/orchestrator/swarm/service_images_test.go new file mode 100644 index 00000000..27483f0e --- /dev/null +++ b/server/internal/orchestrator/swarm/service_images_test.go @@ -0,0 +1,143 @@ +package swarm + +import ( + "testing" + + "github.com/pgEdge/control-plane/server/internal/config" +) + +func TestGetServiceImage(t *testing.T) { + cfg := config.Config{ + DockerSwarm: config.DockerSwarm{ + ImageRepositoryHost: "ghcr.io/pgedge", + }, + } + sv := NewServiceVersions(cfg) + + tests := []struct { + name string + serviceType string + version string + want string + wantErr bool + }{ + { + name: "valid mcp 1.0.0", + serviceType: "mcp", + version: "1.0.0", + want: "ghcr.io/pgedge/postgres-mcp:1.0.0", + wantErr: false, + }, + { + name: "unsupported service type", + serviceType: "unknown", + version: "1.0.0", + want: "", + wantErr: true, + }, + { + name: "unsupported version", + serviceType: "mcp", + version: "99.99.99", + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sv.GetServiceImage(tt.serviceType, tt.version) + if (err != nil) != tt.wantErr { + t.Errorf("GetServiceImage() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetServiceImage() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSupportedServiceVersions(t *testing.T) { + cfg := config.Config{ + DockerSwarm: config.DockerSwarm{ + ImageRepositoryHost: "ghcr.io/pgedge", + }, + } + sv := NewServiceVersions(cfg) + + tests := []struct { + name string + serviceType string + wantLen int + wantErr bool + }{ + { + name: "mcp service has versions", + serviceType: "mcp", + wantLen: 2, // "1.0.0" and "latest" + wantErr: false, + }, + { + name: "unsupported service type", + serviceType: "unknown", + wantLen: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sv.SupportedServiceVersions(tt.serviceType) + if (err != nil) != tt.wantErr { + t.Errorf("SupportedServiceVersions() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != tt.wantLen { + t.Errorf("SupportedServiceVersions() returned %d versions, want %d", len(got), tt.wantLen) + } + }) + } +} + +func TestServiceImageTag(t *testing.T) { + tests := []struct { + name string + imageRef string + repoHost string + want string + }{ + { + name: "image without registry", + imageRef: "pgedge/postgres-mcp:1.0.0", + repoHost: "ghcr.io/pgedge", + want: "ghcr.io/pgedge/pgedge/postgres-mcp:1.0.0", + }, + { + name: "image with registry", + imageRef: "docker.io/pgedge/postgres-mcp:1.0.0", + repoHost: "ghcr.io/pgedge", + want: "docker.io/pgedge/postgres-mcp:1.0.0", + }, + { + name: "image with localhost registry", + imageRef: "localhost:5000/postgres-mcp:1.0.0", + repoHost: "ghcr.io/pgedge", + want: "localhost:5000/postgres-mcp:1.0.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.Config{ + DockerSwarm: config.DockerSwarm{ + ImageRepositoryHost: tt.repoHost, + }, + } + got := serviceImageTag(cfg, tt.imageRef) + if got != tt.want { + t.Errorf("serviceImageTag() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/server/internal/orchestrator/swarm/service_instance.go b/server/internal/orchestrator/swarm/service_instance.go new file mode 100644 index 00000000..d39a24cc --- /dev/null +++ b/server/internal/orchestrator/swarm/service_instance.go @@ -0,0 +1,246 @@ +package swarm + +import ( + "context" + "errors" + "fmt" + "reflect" + "time" + + "github.com/docker/docker/api/types/swarm" + "github.com/rs/zerolog" + "github.com/samber/do" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/docker" + "github.com/pgEdge/control-plane/server/internal/monitor" + "github.com/pgEdge/control-plane/server/internal/resource" + "github.com/pgEdge/control-plane/server/internal/utils" +) + +var _ resource.Resource = (*ServiceInstanceResource)(nil) + +const ResourceTypeServiceInstance resource.Type = database.ResourceTypeServiceInstance + +func ServiceInstanceResourceIdentifier(serviceInstanceID string) resource.Identifier { + return resource.Identifier{ + ID: serviceInstanceID, + Type: ResourceTypeServiceInstance, + } +} + +type ServiceInstanceResource struct { + ServiceInstanceID string `json:"service_instance_id"` + DatabaseID string `json:"database_id"` + ServiceName string `json:"service_name"` + ServiceID string `json:"service_id"` + NeedsUpdate bool `json:"needs_update"` +} + +func (s *ServiceInstanceResource) ResourceVersion() string { + return "1" +} + +func (s *ServiceInstanceResource) DiffIgnore() []string { + return []string{ + "/database_id", + "/service_id", + } +} + +func (s *ServiceInstanceResource) Identifier() resource.Identifier { + return ServiceInstanceResourceIdentifier(s.ServiceInstanceID) +} + +func (s *ServiceInstanceResource) Executor() resource.Executor { + return resource.ManagerExecutor() +} + +func (s *ServiceInstanceResource) Dependencies() []resource.Identifier { + return []resource.Identifier{ + ServiceUserRoleIdentifier(s.ServiceInstanceID), + ServiceInstanceSpecResourceIdentifier(s.ServiceInstanceID), + } +} + +func (s *ServiceInstanceResource) Refresh(ctx context.Context, rc *resource.Context) error { + client, err := do.Invoke[*docker.Docker](rc.Injector) + if err != nil { + return err + } + + desired, err := resource.FromContext[*ServiceInstanceSpecResource](rc, ServiceInstanceSpecResourceIdentifier(s.ServiceInstanceID)) + if err != nil { + return fmt.Errorf("failed to get desired service spec from state: %w", err) + } + + resp, err := client.ServiceInspectByLabels(ctx, map[string]string{ + "pgedge.component": "service", + "pgedge.service.instance.id": s.ServiceInstanceID, + }) + if errors.Is(err, docker.ErrNotFound) { + return resource.ErrNotFound + } else if err != nil { + return fmt.Errorf("failed to inspect service instance: %w", err) + } + s.ServiceID = resp.ID + s.ServiceName = resp.Spec.Name + s.NeedsUpdate = s.needsUpdate(resp.Spec, desired.Spec) + + return nil +} + +func (s *ServiceInstanceResource) Create(ctx context.Context, rc *resource.Context) error { + client, err := do.Invoke[*docker.Docker](rc.Injector) + if err != nil { + return err + } + + logger, err := do.Invoke[zerolog.Logger](rc.Injector) + if err != nil { + return err + } + + specResourceID := ServiceInstanceSpecResourceIdentifier(s.ServiceInstanceID) + spec, err := resource.FromContext[*ServiceInstanceSpecResource](rc, specResourceID) + if err != nil { + return fmt.Errorf("failed to get service instance spec from state: %w", err) + } + + logger.Info(). + Str("service_instance_id", s.ServiceInstanceID). + Str("image", spec.Spec.TaskTemplate.ContainerSpec.Image). + Msg("deploying service instance") + + res, err := client.ServiceDeploy(ctx, spec.Spec) + if err != nil { + return fmt.Errorf("failed to deploy service instance: %w", err) + } + + s.ServiceID = res.ServiceID + + logger.Info(). + Str("service_instance_id", s.ServiceInstanceID). + Str("service_id", res.ServiceID). + Msg("service deployed, waiting for tasks to start") + + if err := client.WaitForService(ctx, res.ServiceID, 5*time.Minute, res.Previous); err != nil { + logger.Error(). + Err(err). + Str("service_instance_id", s.ServiceInstanceID). + Str("service_id", res.ServiceID). + Msg("service instance failed to start") + return fmt.Errorf("failed to wait for service instance to start: %w", err) + } + + logger.Info(). + Str("service_instance_id", s.ServiceInstanceID). + Str("service_id", res.ServiceID). + Msg("service instance started successfully") + + // Transition state to "running" immediately after successful deployment. + // This mirrors the database instance pattern where state is set + // deterministically within the resource activity, not deferred to the monitor. + dbSvc, err := do.Invoke[*database.Service](rc.Injector) + if err != nil { + logger.Warn().Err(err).Msg("failed to get database service for state update") + } else { + if err := dbSvc.SetServiceInstanceState(ctx, s.DatabaseID, s.ServiceInstanceID, database.ServiceInstanceStateRunning); err != nil { + logger.Warn().Err(err). + Str("service_instance_id", s.ServiceInstanceID). + Msg("failed to update service instance state to running (monitor will handle it)") + } + } + + return nil +} + +func (s *ServiceInstanceResource) Update(ctx context.Context, rc *resource.Context) error { + client, err := do.Invoke[*docker.Docker](rc.Injector) + if err != nil { + return err + } + + resp, err := client.ServiceInspectByLabels(ctx, map[string]string{ + "pgedge.component": "service", + "pgedge.service.instance.id": s.ServiceInstanceID, + }) + if err != nil && !errors.Is(err, docker.ErrNotFound) { + return fmt.Errorf("failed to inspect service instance: %w", err) + } + if err == nil && resp.Spec.Name != s.ServiceName { + // If the service name has changed, we need to remove the service with + // the old name so that it can be recreated with the new name. + if err := client.ServiceRemove(ctx, resp.Spec.Name); err != nil { + return fmt.Errorf("failed to remove service instance for service name update: %w", err) + } + } + + return s.Create(ctx, rc) +} + +func (s *ServiceInstanceResource) Delete(ctx context.Context, rc *resource.Context) error { + client, err := do.Invoke[*docker.Docker](rc.Injector) + if err != nil { + return err + } + + // We scale down before removing the service so that we can guarantee that + // the containers have stopped before this function returns. Otherwise, we + // can encounter errors from trying to remove other resources while the + // containers are still up. + err = client.ServiceScale(ctx, docker.ServiceScaleOptions{ + ServiceID: s.ServiceName, + Scale: 0, + Wait: true, + WaitTimeout: time.Minute, + }) + if errors.Is(err, docker.ErrNotFound) { + // Service is already deleted, but still try to clean up etcd state + } else if err != nil { + return fmt.Errorf("failed to scale down service instance before removal: %w", err) + } else { + // Only try to remove the service if it exists + if err := client.ServiceRemove(ctx, s.ServiceName); err != nil { + return fmt.Errorf("failed to remove service instance: %w", err) + } + } + + // Remove service instance monitor + // Note: This is best-effort cleanup. If the monitor doesn't exist or deletion fails, + // we still proceed with service instance deletion. Monitors will be cleaned up on restart. + if monitorSvc, err := do.Invoke[*monitor.Service](rc.Injector); err == nil { + if err := monitorSvc.DeleteServiceInstanceMonitor(ctx, s.ServiceInstanceID); err != nil { + if logger, logErr := do.Invoke[zerolog.Logger](rc.Injector); logErr == nil { + logger.Warn().Err(err).Str("service_instance_id", s.ServiceInstanceID).Msg("failed to delete service instance monitor during cleanup") + } + // Continue - not critical for deletion + } + } + + // Remove service instance from etcd storage + svc, err := do.Invoke[*database.Service](rc.Injector) + if err != nil { + return err + } + + err = svc.DeleteServiceInstance(ctx, s.DatabaseID, s.ServiceInstanceID) + if err != nil { + return err + } + + return nil +} + +func (s *ServiceInstanceResource) needsUpdate(curr swarm.ServiceSpec, desired swarm.ServiceSpec) bool { + if curr.Mode.Replicated != nil && utils.FromPointer(curr.Mode.Replicated.Replicas) != 1 { + // This means that the service is scaled down + return true + } + + // For service instances, we do a simple comparison of the task spec + // We don't have a CheckWillRestart resource like PostgresService does + return !reflect.DeepEqual(curr.TaskTemplate, desired.TaskTemplate) || + !reflect.DeepEqual(curr.EndpointSpec, desired.EndpointSpec) || + !reflect.DeepEqual(curr.Annotations, desired.Annotations) +} diff --git a/server/internal/orchestrator/swarm/service_instance_spec.go b/server/internal/orchestrator/swarm/service_instance_spec.go new file mode 100644 index 00000000..6d23a923 --- /dev/null +++ b/server/internal/orchestrator/swarm/service_instance_spec.go @@ -0,0 +1,113 @@ +package swarm + +import ( + "context" + "fmt" + + "github.com/docker/docker/api/types/swarm" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/resource" +) + +var _ resource.Resource = (*ServiceInstanceSpecResource)(nil) + +const ResourceTypeServiceInstanceSpec resource.Type = "swarm.service_instance_spec" + +func ServiceInstanceSpecResourceIdentifier(serviceInstanceID string) resource.Identifier { + return resource.Identifier{ + ID: serviceInstanceID, + Type: ResourceTypeServiceInstanceSpec, + } +} + +type ServiceInstanceSpecResource struct { + ServiceInstanceID string `json:"service_instance_id"` + ServiceSpec *database.ServiceSpec `json:"service_spec"` + DatabaseID string `json:"database_id"` + DatabaseName string `json:"database_name"` + HostID string `json:"host_id"` + ServiceName string `json:"service_name"` + Hostname string `json:"hostname"` + CohortMemberID string `json:"cohort_member_id"` + ServiceImages *ServiceImages `json:"service_images"` + Credentials *database.ServiceUser `json:"credentials"` + DatabaseNetworkID string `json:"database_network_id"` + DatabaseHost string `json:"database_host"` // Postgres instance hostname + DatabasePort int `json:"database_port"` // Postgres instance port + Port *int `json:"port"` // Service published port (optional, 0 = random) + Spec swarm.ServiceSpec `json:"spec"` +} + +func (s *ServiceInstanceSpecResource) ResourceVersion() string { + return "1" +} + +func (s *ServiceInstanceSpecResource) DiffIgnore() []string { + return []string{ + "/spec", + } +} + +func (s *ServiceInstanceSpecResource) Identifier() resource.Identifier { + return ServiceInstanceSpecResourceIdentifier(s.ServiceInstanceID) +} + +func (s *ServiceInstanceSpecResource) Executor() resource.Executor { + return resource.HostExecutor(s.HostID) +} + +func (s *ServiceInstanceSpecResource) Dependencies() []resource.Identifier { + // Service instances depend on the database network existing + return []resource.Identifier{ + NetworkResourceIdentifier(s.DatabaseNetworkID), + } +} + +func (s *ServiceInstanceSpecResource) Refresh(ctx context.Context, rc *resource.Context) error { + network, err := resource.FromContext[*Network](rc, NetworkResourceIdentifier(s.DatabaseNetworkID)) + if err != nil { + return fmt.Errorf("failed to get database network from state: %w", err) + } + + // DatabaseHost and DatabasePort are populated by the ProvisionServices workflow, + // which prefers a co-located instance for lower latency but falls back to any + // instance in the database when no local instance exists. + // TODO: consider alternatives and discuss with the team + + spec, err := ServiceContainerSpec(&ServiceContainerSpecOptions{ + ServiceSpec: s.ServiceSpec, + ServiceInstanceID: s.ServiceInstanceID, + DatabaseID: s.DatabaseID, + DatabaseName: s.DatabaseName, + HostID: s.HostID, + ServiceName: s.ServiceName, + Hostname: s.Hostname, + CohortMemberID: s.CohortMemberID, + ServiceImages: s.ServiceImages, + Credentials: s.Credentials, + DatabaseNetworkID: network.NetworkID, + DatabaseHost: s.DatabaseHost, + DatabasePort: s.DatabasePort, + Port: s.Port, + }) + if err != nil { + return fmt.Errorf("failed to generate service container spec: %w", err) + } + s.Spec = spec + + return nil +} + +func (s *ServiceInstanceSpecResource) Create(ctx context.Context, rc *resource.Context) error { + return s.Refresh(ctx, rc) +} + +func (s *ServiceInstanceSpecResource) Update(ctx context.Context, rc *resource.Context) error { + return s.Refresh(ctx, rc) +} + +func (s *ServiceInstanceSpecResource) Delete(ctx context.Context, rc *resource.Context) error { + // This is a virtual resource, so there's nothing to delete. + return nil +} diff --git a/server/internal/orchestrator/swarm/service_spec.go b/server/internal/orchestrator/swarm/service_spec.go new file mode 100644 index 00000000..032871c6 --- /dev/null +++ b/server/internal/orchestrator/swarm/service_spec.go @@ -0,0 +1,196 @@ +package swarm + +import ( + "fmt" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/swarm" + + "github.com/pgEdge/control-plane/server/internal/database" +) + +// ServiceContainerSpecOptions contains all parameters needed to build a service container spec. +type ServiceContainerSpecOptions struct { + ServiceSpec *database.ServiceSpec + ServiceInstanceID string + DatabaseID string + DatabaseName string + HostID string + ServiceName string + Hostname string + CohortMemberID string + ServiceImages *ServiceImages + Credentials *database.ServiceUser + DatabaseNetworkID string + // Database connection info + DatabaseHost string + DatabasePort int + // Service port configuration + Port *int +} + +// ServiceContainerSpec builds a Docker Swarm service spec for a service instance. +func ServiceContainerSpec(opts *ServiceContainerSpecOptions) (swarm.ServiceSpec, error) { + // Build labels for service discovery + labels := map[string]string{ + "pgedge.component": "service", + "pgedge.service.instance.id": opts.ServiceInstanceID, + "pgedge.service.id": opts.ServiceSpec.ServiceID, + "pgedge.database.id": opts.DatabaseID, + "pgedge.host.id": opts.HostID, + } + + // Build networks - attach to both bridge and database overlay networks + // Bridge network provides: + // - Control Plane access to service health/API endpoints (port 8080) + // - External accessibility for end-users via published ports + // Database overlay network provides: + // - Connectivity to Postgres instances + // - Network isolation per database + networks := []swarm.NetworkAttachmentConfig{ + { + Target: "bridge", + }, + { + Target: opts.DatabaseNetworkID, + }, + } + + // Build environment variables for database connection and LLM config + env := buildServiceEnvVars(opts) + + // Get container image (already resolved in ServiceImages) + image := opts.ServiceImages.Image + + // Build port configuration (expose 8080 for HTTP API) + ports := buildServicePortConfig(opts.Port) + + // Build resource limits + var resources *swarm.ResourceRequirements + if opts.ServiceSpec.CPUs != nil || opts.ServiceSpec.MemoryBytes != nil { + resources = &swarm.ResourceRequirements{ + Limits: &swarm.Limit{}, + } + if opts.ServiceSpec.CPUs != nil { + resources.Limits.NanoCPUs = int64(*opts.ServiceSpec.CPUs * 1e9) + } + if opts.ServiceSpec.MemoryBytes != nil { + resources.Limits.MemoryBytes = int64(*opts.ServiceSpec.MemoryBytes) + } + } + + return swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Image: image, + Labels: labels, + Hostname: opts.Hostname, + Env: env, + Healthcheck: &container.HealthConfig{ + Test: []string{"CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"}, + StartPeriod: time.Second * 30, + Interval: time.Second * 10, + Timeout: time.Second * 5, + Retries: 3, + }, + Mounts: []mount.Mount{}, // No persistent volumes for services in Phase 1 + }, + Networks: networks, + Placement: &swarm.Placement{ + Constraints: []string{ + "node.id==" + opts.CohortMemberID, + }, + }, + Resources: resources, + }, + EndpointSpec: &swarm.EndpointSpec{ + Mode: swarm.ResolutionModeVIP, + Ports: ports, + }, + Annotations: swarm.Annotations{ + Name: opts.ServiceName, + Labels: labels, + }, + }, nil +} + +// buildServiceEnvVars constructs environment variables for the service container. +func buildServiceEnvVars(opts *ServiceContainerSpecOptions) []string { + env := []string{ + // Database connection + fmt.Sprintf("PGHOST=%s", opts.DatabaseHost), + fmt.Sprintf("PGPORT=%d", opts.DatabasePort), + fmt.Sprintf("PGDATABASE=%s", opts.DatabaseName), + "PGSSLMODE=prefer", + + // Service metadata + fmt.Sprintf("PGEDGE_SERVICE_ID=%s", opts.ServiceSpec.ServiceID), + fmt.Sprintf("PGEDGE_DATABASE_ID=%s", opts.DatabaseID), + } + + // Add credentials if provided + if opts.Credentials != nil { + env = append(env, + fmt.Sprintf("PGUSER=%s", opts.Credentials.Username), + fmt.Sprintf("PGPASSWORD=%s", opts.Credentials.Password), + ) + } + + // LLM configuration from serviceSpec.Config + if provider, ok := opts.ServiceSpec.Config["llm_provider"].(string); ok { + env = append(env, fmt.Sprintf("PGEDGE_LLM_PROVIDER=%s", provider)) + } + if model, ok := opts.ServiceSpec.Config["llm_model"].(string); ok { + env = append(env, fmt.Sprintf("PGEDGE_LLM_MODEL=%s", model)) + } + + // Provider-specific API keys + if provider, ok := opts.ServiceSpec.Config["llm_provider"].(string); ok { + switch provider { + case "anthropic": + if key, ok := opts.ServiceSpec.Config["anthropic_api_key"].(string); ok { + env = append(env, fmt.Sprintf("PGEDGE_ANTHROPIC_API_KEY=%s", key)) + } + case "openai": + if key, ok := opts.ServiceSpec.Config["openai_api_key"].(string); ok { + env = append(env, fmt.Sprintf("PGEDGE_OPENAI_API_KEY=%s", key)) + } + case "ollama": + if url, ok := opts.ServiceSpec.Config["ollama_url"].(string); ok { + env = append(env, fmt.Sprintf("PGEDGE_OLLAMA_URL=%s", url)) + } + } + } + + return env +} + +// buildServicePortConfig builds port configuration for service containers. +// Exposes port 8080 for the HTTP API. +// If port is nil, no port is published. +// If port is non-nil and > 0, publish on that specific port. +// If port is non-nil and == 0, let Docker assign a random port. +func buildServicePortConfig(port *int) []swarm.PortConfig { + if port == nil { + // Do not expose any port if not specified + return nil + } + + config := swarm.PortConfig{ + PublishMode: swarm.PortConfigPublishModeHost, + TargetPort: 8080, + Name: "http", + Protocol: swarm.PortConfigProtocolTCP, + } + + if *port > 0 { + config.PublishedPort = uint32(*port) + } else if *port == 0 { + // Port 0 means random port assigned + config.PublishedPort = 0 + } + + return []swarm.PortConfig{config} +} diff --git a/server/internal/orchestrator/swarm/service_spec_test.go b/server/internal/orchestrator/swarm/service_spec_test.go new file mode 100644 index 00000000..920a987e --- /dev/null +++ b/server/internal/orchestrator/swarm/service_spec_test.go @@ -0,0 +1,600 @@ +package swarm + +import ( + "testing" + + "github.com/docker/docker/api/types/swarm" + + "github.com/pgEdge/control-plane/server/internal/database" +) + +func TestServiceContainerSpec(t *testing.T) { + cpus := 2.0 + memoryBytes := uint64(2147483648) // 2GB + + tests := []struct { + name string + opts *ServiceContainerSpecOptions + wantErr bool + // Validation functions + checkLabels func(t *testing.T, labels map[string]string) + checkNetworks func(t *testing.T, networks []swarm.NetworkAttachmentConfig) + checkEnv func(t *testing.T, env []string) + checkPlacement func(t *testing.T, placement *swarm.Placement) + checkResources func(t *testing.T, resources *swarm.ResourceRequirements) + checkHealthcheck func(t *testing.T, healthcheck *swarm.ContainerSpec) + checkPorts func(t *testing.T, ports []swarm.PortConfig) + }{ + { + name: "basic MCP service", + opts: &ServiceContainerSpecOptions{ + ServiceSpec: &database.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + Config: map[string]interface{}{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-api03-test", + }, + }, + ServiceInstanceID: "db1-mcp-server-host1", + DatabaseID: "db1", + DatabaseName: "testdb", + HostID: "host1", + ServiceName: "db1-mcp-server-host1", + Hostname: "mcp-server-host1", + CohortMemberID: "swarm-node-123", + ServiceImages: &ServiceImages{ + Image: "ghcr.io/pgedge/postgres-mcp:1.0.0", + }, + Credentials: &database.ServiceUser{ + Username: "svc_db1mcp", + Password: "testpassword", + Role: "pgedge_application_read_only", + }, + DatabaseNetworkID: "db1-database", + DatabaseHost: "postgres-instance-1", + DatabasePort: 5432, + Port: intPtr(8080), + }, + wantErr: false, + checkLabels: func(t *testing.T, labels map[string]string) { + expectedLabels := map[string]string{ + "pgedge.component": "service", + "pgedge.service.instance.id": "db1-mcp-server-host1", + "pgedge.service.id": "mcp-server", + "pgedge.database.id": "db1", + "pgedge.host.id": "host1", + } + for k, v := range expectedLabels { + if labels[k] != v { + t.Errorf("label %s = %v, want %v", k, labels[k], v) + } + } + }, + checkNetworks: func(t *testing.T, networks []swarm.NetworkAttachmentConfig) { + if len(networks) != 2 { + t.Errorf("got %d networks, want 2", len(networks)) + return + } + // First network should be bridge + if networks[0].Target != "bridge" { + t.Errorf("first network = %v, want bridge", networks[0].Target) + } + // Second network should be database overlay + if networks[1].Target != "db1-database" { + t.Errorf("second network = %v, want db1-database", networks[1].Target) + } + }, + checkEnv: func(t *testing.T, env []string) { + expectedEnv := []string{ + "PGHOST=postgres-instance-1", + "PGPORT=5432", + "PGDATABASE=testdb", + "PGSSLMODE=prefer", + "PGEDGE_SERVICE_ID=mcp-server", + "PGEDGE_DATABASE_ID=db1", + "PGUSER=svc_db1mcp", + "PGPASSWORD=testpassword", + "PGEDGE_LLM_PROVIDER=anthropic", + "PGEDGE_LLM_MODEL=claude-sonnet-4-5", + "PGEDGE_ANTHROPIC_API_KEY=sk-ant-api03-test", + } + if len(env) != len(expectedEnv) { + t.Errorf("got %d env vars, want %d", len(env), len(expectedEnv)) + } + for _, e := range expectedEnv { + found := false + for _, got := range env { + if got == e { + found = true + break + } + } + if !found { + t.Errorf("missing env var: %s", e) + } + } + }, + checkPlacement: func(t *testing.T, placement *swarm.Placement) { + if len(placement.Constraints) != 1 { + t.Errorf("got %d constraints, want 1", len(placement.Constraints)) + } + if placement.Constraints[0] != "node.id==swarm-node-123" { + t.Errorf("constraint = %v, want node.id==swarm-node-123", placement.Constraints[0]) + } + }, + checkResources: func(t *testing.T, resources *swarm.ResourceRequirements) { + if resources != nil { + t.Errorf("expected no resource limits, got %+v", resources) + } + }, + checkHealthcheck: func(t *testing.T, containerSpec *swarm.ContainerSpec) { + if containerSpec.Healthcheck == nil { + t.Fatal("healthcheck is nil") + } + if len(containerSpec.Healthcheck.Test) == 0 { + t.Error("healthcheck test is empty") + } + }, + checkPorts: func(t *testing.T, ports []swarm.PortConfig) { + if len(ports) != 1 { + t.Errorf("got %d ports, want 1", len(ports)) + return + } + if ports[0].TargetPort != 8080 { + t.Errorf("target port = %d, want 8080", ports[0].TargetPort) + } + if ports[0].Protocol != swarm.PortConfigProtocolTCP { + t.Errorf("protocol = %v, want TCP", ports[0].Protocol) + } + }, + }, + { + name: "service with resource limits", + opts: &ServiceContainerSpecOptions{ + ServiceSpec: &database.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + CPUs: &cpus, + MemoryBytes: &memoryBytes, + Config: map[string]interface{}{ + "llm_provider": "openai", + "llm_model": "gpt-4", + "openai_api_key": "sk-test", + }, + }, + ServiceInstanceID: "db1-mcp-server-host1", + DatabaseID: "db1", + DatabaseName: "testdb", + HostID: "host1", + ServiceName: "db1-mcp-server-host1", + Hostname: "mcp-server-host1", + CohortMemberID: "swarm-node-123", + ServiceImages: &ServiceImages{ + Image: "ghcr.io/pgedge/postgres-mcp:1.0.0", + }, + DatabaseNetworkID: "db1-database", + DatabaseHost: "postgres-instance-1", + DatabasePort: 5432, + }, + wantErr: false, + checkResources: func(t *testing.T, resources *swarm.ResourceRequirements) { + if resources == nil { + t.Fatal("expected resource limits, got nil") + } + if resources.Limits == nil { + t.Fatal("expected limits, got nil") + } + expectedCPU := int64(2.0 * 1e9) + if resources.Limits.NanoCPUs != expectedCPU { + t.Errorf("NanoCPUs = %d, want %d", resources.Limits.NanoCPUs, expectedCPU) + } + expectedMem := int64(memoryBytes) + if resources.Limits.MemoryBytes != expectedMem { + t.Errorf("MemoryBytes = %d, want %d", resources.Limits.MemoryBytes, expectedMem) + } + }, + }, + { + name: "service with OpenAI provider", + opts: &ServiceContainerSpecOptions{ + ServiceSpec: &database.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + Config: map[string]interface{}{ + "llm_provider": "openai", + "llm_model": "gpt-4", + "openai_api_key": "sk-openai-test", + }, + }, + ServiceInstanceID: "db1-mcp-server-host1", + DatabaseID: "db1", + DatabaseName: "testdb", + HostID: "host1", + ServiceName: "db1-mcp-server-host1", + Hostname: "mcp-server-host1", + CohortMemberID: "swarm-node-123", + ServiceImages: &ServiceImages{ + Image: "ghcr.io/pgedge/postgres-mcp:1.0.0", + }, + DatabaseNetworkID: "db1-database", + DatabaseHost: "postgres-instance-1", + DatabasePort: 5432, + }, + wantErr: false, + checkEnv: func(t *testing.T, env []string) { + expectedEnv := []string{ + "PGEDGE_LLM_PROVIDER=openai", + "PGEDGE_LLM_MODEL=gpt-4", + "PGEDGE_OPENAI_API_KEY=sk-openai-test", + } + for _, e := range expectedEnv { + found := false + for _, got := range env { + if got == e { + found = true + break + } + } + if !found { + t.Errorf("missing env var: %s", e) + } + } + }, + }, + { + name: "service with Ollama provider", + opts: &ServiceContainerSpecOptions{ + ServiceSpec: &database.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + Config: map[string]interface{}{ + "llm_provider": "ollama", + "llm_model": "llama2", + "ollama_url": "http://localhost:11434", + }, + }, + ServiceInstanceID: "db1-mcp-server-host1", + DatabaseID: "db1", + DatabaseName: "testdb", + HostID: "host1", + ServiceName: "db1-mcp-server-host1", + Hostname: "mcp-server-host1", + CohortMemberID: "swarm-node-123", + ServiceImages: &ServiceImages{ + Image: "ghcr.io/pgedge/postgres-mcp:1.0.0", + }, + DatabaseNetworkID: "db1-database", + DatabaseHost: "postgres-instance-1", + DatabasePort: 5432, + }, + wantErr: false, + checkEnv: func(t *testing.T, env []string) { + expectedEnv := []string{ + "PGEDGE_LLM_PROVIDER=ollama", + "PGEDGE_LLM_MODEL=llama2", + "PGEDGE_OLLAMA_URL=http://localhost:11434", + } + for _, e := range expectedEnv { + found := false + for _, got := range env { + if got == e { + found = true + break + } + } + if !found { + t.Errorf("missing env var: %s", e) + } + } + }, + }, + { + name: "service without credentials", + opts: &ServiceContainerSpecOptions{ + ServiceSpec: &database.ServiceSpec{ + ServiceID: "mcp-server", + ServiceType: "mcp", + Version: "1.0.0", + Config: map[string]interface{}{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-test", + }, + }, + ServiceInstanceID: "db1-mcp-server-host1", + DatabaseID: "db1", + DatabaseName: "testdb", + HostID: "host1", + ServiceName: "db1-mcp-server-host1", + Hostname: "mcp-server-host1", + CohortMemberID: "swarm-node-123", + ServiceImages: &ServiceImages{ + Image: "ghcr.io/pgedge/postgres-mcp:1.0.0", + }, + Credentials: nil, // No credentials + DatabaseNetworkID: "db1-database", + DatabaseHost: "postgres-instance-1", + DatabasePort: 5432, + }, + wantErr: false, + checkEnv: func(t *testing.T, env []string) { + // Should not have PGUSER or PGPASSWORD + for _, e := range env { + if e == "PGUSER" || e == "PGPASSWORD" { + t.Errorf("unexpected credential env var: %s", e) + } + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ServiceContainerSpec(tt.opts) + if (err != nil) != tt.wantErr { + t.Errorf("ServiceContainerSpec() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + // Run validation checks + if tt.checkLabels != nil { + tt.checkLabels(t, got.TaskTemplate.ContainerSpec.Labels) + } + if tt.checkNetworks != nil { + tt.checkNetworks(t, got.TaskTemplate.Networks) + } + if tt.checkEnv != nil { + tt.checkEnv(t, got.TaskTemplate.ContainerSpec.Env) + } + if tt.checkPlacement != nil { + tt.checkPlacement(t, got.TaskTemplate.Placement) + } + if tt.checkResources != nil { + tt.checkResources(t, got.TaskTemplate.Resources) + } + if tt.checkHealthcheck != nil { + tt.checkHealthcheck(t, got.TaskTemplate.ContainerSpec) + } + if tt.checkPorts != nil { + tt.checkPorts(t, got.EndpointSpec.Ports) + } + + // Check image + if got.TaskTemplate.ContainerSpec.Image != tt.opts.ServiceImages.Image { + t.Errorf("image = %v, want %v", got.TaskTemplate.ContainerSpec.Image, tt.opts.ServiceImages.Image) + } + + // Check service name + if got.Annotations.Name != tt.opts.ServiceName { + t.Errorf("service name = %v, want %v", got.Annotations.Name, tt.opts.ServiceName) + } + + // Check hostname + if got.TaskTemplate.ContainerSpec.Hostname != tt.opts.Hostname { + t.Errorf("hostname = %v, want %v", got.TaskTemplate.ContainerSpec.Hostname, tt.opts.Hostname) + } + }) + } +} + +func TestBuildServiceEnvVars(t *testing.T) { + tests := []struct { + name string + opts *ServiceContainerSpecOptions + expected []string + }{ + { + name: "anthropic provider with credentials", + opts: &ServiceContainerSpecOptions{ + ServiceSpec: &database.ServiceSpec{ + ServiceID: "mcp-server", + Config: map[string]interface{}{ + "llm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5", + "anthropic_api_key": "sk-ant-test", + }, + }, + DatabaseID: "db1", + DatabaseName: "testdb", + DatabaseHost: "postgres-instance-1", + DatabasePort: 5432, + Credentials: &database.ServiceUser{ + Username: "svc_test", + Password: "testpass", + }, + }, + expected: []string{ + "PGHOST=postgres-instance-1", + "PGPORT=5432", + "PGDATABASE=testdb", + "PGSSLMODE=prefer", + "PGEDGE_SERVICE_ID=mcp-server", + "PGEDGE_DATABASE_ID=db1", + "PGUSER=svc_test", + "PGPASSWORD=testpass", + "PGEDGE_LLM_PROVIDER=anthropic", + "PGEDGE_LLM_MODEL=claude-sonnet-4-5", + "PGEDGE_ANTHROPIC_API_KEY=sk-ant-test", + }, + }, + { + name: "openai provider without credentials", + opts: &ServiceContainerSpecOptions{ + ServiceSpec: &database.ServiceSpec{ + ServiceID: "mcp-server", + Config: map[string]interface{}{ + "llm_provider": "openai", + "llm_model": "gpt-4", + "openai_api_key": "sk-openai-test", + }, + }, + DatabaseID: "db1", + DatabaseName: "testdb", + DatabaseHost: "postgres-instance-1", + DatabasePort: 5432, + Credentials: nil, + }, + expected: []string{ + "PGHOST=postgres-instance-1", + "PGPORT=5432", + "PGDATABASE=testdb", + "PGSSLMODE=prefer", + "PGEDGE_SERVICE_ID=mcp-server", + "PGEDGE_DATABASE_ID=db1", + "PGEDGE_LLM_PROVIDER=openai", + "PGEDGE_LLM_MODEL=gpt-4", + "PGEDGE_OPENAI_API_KEY=sk-openai-test", + }, + }, + { + name: "ollama provider", + opts: &ServiceContainerSpecOptions{ + ServiceSpec: &database.ServiceSpec{ + ServiceID: "mcp-server", + Config: map[string]interface{}{ + "llm_provider": "ollama", + "llm_model": "llama2", + "ollama_url": "http://localhost:11434", + }, + }, + DatabaseID: "db1", + DatabaseName: "testdb", + DatabaseHost: "postgres-instance-1", + DatabasePort: 5432, + }, + expected: []string{ + "PGHOST=postgres-instance-1", + "PGPORT=5432", + "PGDATABASE=testdb", + "PGSSLMODE=prefer", + "PGEDGE_SERVICE_ID=mcp-server", + "PGEDGE_DATABASE_ID=db1", + "PGEDGE_LLM_PROVIDER=ollama", + "PGEDGE_LLM_MODEL=llama2", + "PGEDGE_OLLAMA_URL=http://localhost:11434", + }, + }, + { + name: "minimal config without LLM settings", + opts: &ServiceContainerSpecOptions{ + ServiceSpec: &database.ServiceSpec{ + ServiceID: "mcp-server", + Config: map[string]interface{}{}, + }, + DatabaseID: "db1", + DatabaseName: "testdb", + DatabaseHost: "postgres-instance-1", + DatabasePort: 5432, + }, + expected: []string{ + "PGHOST=postgres-instance-1", + "PGPORT=5432", + "PGDATABASE=testdb", + "PGSSLMODE=prefer", + "PGEDGE_SERVICE_ID=mcp-server", + "PGEDGE_DATABASE_ID=db1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildServiceEnvVars(tt.opts) + + if len(got) != len(tt.expected) { + t.Errorf("got %d env vars, want %d", len(got), len(tt.expected)) + } + + for _, e := range tt.expected { + found := false + for _, g := range got { + if g == e { + found = true + break + } + } + if !found { + t.Errorf("missing expected env var: %s", e) + } + } + }) + } +} + +func TestBuildServicePortConfig(t *testing.T) { + tests := []struct { + name string + port *int + wantPortCount int + wantPublishedPort uint32 + }{ + { + name: "nil port - no port published", + port: nil, + wantPortCount: 0, + }, + { + name: "port 0 - random port", + port: intPtr(0), + wantPortCount: 1, + wantPublishedPort: 0, + }, + { + name: "port 8080 - specific port", + port: intPtr(8080), + wantPortCount: 1, + wantPublishedPort: 8080, + }, + { + name: "port 9000 - specific port", + port: intPtr(9000), + wantPortCount: 1, + wantPublishedPort: 9000, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ports := buildServicePortConfig(tt.port) + + if len(ports) != tt.wantPortCount { + t.Fatalf("got %d ports, want %d", len(ports), tt.wantPortCount) + } + + if tt.wantPortCount == 0 { + return // No port config expected + } + + port := ports[0] + if port.Protocol != swarm.PortConfigProtocolTCP { + t.Errorf("protocol = %v, want TCP", port.Protocol) + } + if port.TargetPort != 8080 { + t.Errorf("target port = %d, want 8080", port.TargetPort) + } + if port.PublishedPort != tt.wantPublishedPort { + t.Errorf("published port = %d, want %d", port.PublishedPort, tt.wantPublishedPort) + } + if port.PublishMode != swarm.PortConfigPublishModeHost { + t.Errorf("publish mode = %v, want host", port.PublishMode) + } + if port.Name != "http" { + t.Errorf("port name = %s, want 'http'", port.Name) + } + }) + } +} + +func intPtr(i int) *int { + return &i +} diff --git a/server/internal/orchestrator/swarm/service_user_role.go b/server/internal/orchestrator/swarm/service_user_role.go new file mode 100644 index 00000000..0fdd5cc1 --- /dev/null +++ b/server/internal/orchestrator/swarm/service_user_role.go @@ -0,0 +1,185 @@ +package swarm + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/samber/do" + + "github.com/pgEdge/control-plane/server/internal/certificates" + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/patroni" + "github.com/pgEdge/control-plane/server/internal/resource" +) + +var _ resource.Resource = (*ServiceUserRole)(nil) + +const ResourceTypeServiceUserRole resource.Type = "swarm.service_user_role" + +// sanitizeIdentifier quotes a string for use as a PostgreSQL identifier. +// It doubles any internal double-quotes and wraps the result in double-quotes. +func sanitizeIdentifier(name string) string { + return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` +} + +func ServiceUserRoleIdentifier(serviceInstanceID string) resource.Identifier { + return resource.Identifier{ + ID: serviceInstanceID, + Type: ResourceTypeServiceUserRole, + } +} + +// ServiceUserRole manages the lifecycle of a database user for a service instance. +// +// This resource handles cleanup of database users when service instances are deleted. +// User creation is performed by the CreateServiceUser activity during provisioning, +// but deletion requires infrastructure access and is therefore handled by this resource. +type ServiceUserRole struct { + ServiceInstanceID string `json:"service_instance_id"` + DatabaseID string `json:"database_id"` + DatabaseName string `json:"database_name"` + Username string `json:"username"` + HostID string `json:"host_id"` +} + +func (r *ServiceUserRole) ResourceVersion() string { + return "1" +} + +func (r *ServiceUserRole) DiffIgnore() []string { + return nil +} + +func (r *ServiceUserRole) Identifier() resource.Identifier { + return ServiceUserRoleIdentifier(r.ServiceInstanceID) +} + +func (r *ServiceUserRole) Executor() resource.Executor { + return resource.HostExecutor(r.HostID) +} + +func (r *ServiceUserRole) Dependencies() []resource.Identifier { + // No dependencies - this resource can be created/deleted independently + return nil +} + +func (r *ServiceUserRole) Refresh(ctx context.Context, rc *resource.Context) error { + // Nothing to refresh - user existence is managed by Create/Delete + return nil +} + +func (r *ServiceUserRole) Create(ctx context.Context, rc *resource.Context) error { + // User was already created by the CreateServiceUser activity during provisioning. + // This resource only handles deletion cleanup. + return nil +} + +func (r *ServiceUserRole) Update(ctx context.Context, rc *resource.Context) error { + // Service users don't support updates (no credential rotation in Phase 1) + return nil +} + +func (r *ServiceUserRole) Delete(ctx context.Context, rc *resource.Context) error { + logger, err := do.Invoke[zerolog.Logger](rc.Injector) + if err != nil { + return err + } + logger = logger.With(). + Str("service_instance_id", r.ServiceInstanceID). + Str("database_id", r.DatabaseID). + Str("username", r.Username). + Logger() + logger.Info().Msg("deleting service user from database") + + orch, err := do.Invoke[database.Orchestrator](rc.Injector) + if err != nil { + return err + } + + // Get database service to find an instance to connect to + dbSvc, err := do.Invoke[*database.Service](rc.Injector) + if err != nil { + return err + } + + db, err := dbSvc.GetDatabase(ctx, r.DatabaseID) + if err != nil { + // Database might already be deleted - this is not an error + logger.Info().Msg("database not found, skipping user deletion") + return nil + } + + if len(db.Instances) == 0 { + logger.Info().Msg("database has no instances, skipping user deletion") + return nil + } + + // Connect to primary instance (or any available instance) + var primaryInstanceID string + for _, inst := range db.Instances { + connInfo, err := orch.GetInstanceConnectionInfo(ctx, r.DatabaseID, inst.InstanceID) + if err != nil { + continue + } + + patroniClient := patroni.NewClient(connInfo.PatroniURL(), nil) + primaryID, err := database.GetPrimaryInstanceID(ctx, patroniClient, 10*time.Second) + if err == nil && primaryID != "" { + primaryInstanceID = primaryID + break + } + } + + if primaryInstanceID == "" { + // Fallback: use first available instance + primaryInstanceID = db.Instances[0].InstanceID + logger.Warn().Msg("could not determine primary instance, using first available instance") + } + + // Get connection info for the primary instance + connInfo, err := orch.GetInstanceConnectionInfo(ctx, r.DatabaseID, primaryInstanceID) + if err != nil { + logger.Warn().Err(err).Msg("failed to get instance connection info, skipping user deletion") + return nil + } + + // Get certificate service for TLS authentication + certSvc, err := do.Invoke[*certificates.Service](rc.Injector) + if err != nil { + return fmt.Errorf("failed to get certificate service: %w", err) + } + + // Create TLS config with pgedge user certificates + tlsConfig, err := certSvc.PostgresUserTLS(ctx, primaryInstanceID, connInfo.InstanceHostname, "pgedge") + if err != nil { + logger.Warn().Err(err).Msg("failed to create TLS config, skipping user deletion") + return nil + } + + // Connect to the postgres system database + conn, err := database.ConnectToInstance(ctx, &database.ConnectionOptions{ + DSN: connInfo.AdminDSN("postgres"), + TLS: tlsConfig, + }) + if err != nil { + logger.Warn().Err(err).Msg("failed to connect to database, skipping user deletion") + return nil + } + defer conn.Close(ctx) + + // Drop the user role + // Using IF EXISTS to handle cases where the user was already dropped manually + _, err = conn.Exec(ctx, fmt.Sprintf("DROP ROLE IF EXISTS %s", sanitizeIdentifier(r.Username))) + if err != nil { + logger.Warn().Err(err).Msg("failed to drop user role, continuing anyway") + // Don't fail the deletion if we can't drop the user - this prevents + // the resource from getting stuck in a failed state + return nil + } + + logger.Info().Msg("service user deleted successfully") + return nil +} diff --git a/server/internal/orchestrator/swarm/utils.go b/server/internal/orchestrator/swarm/utils.go index 7769bea0..009d6f1f 100644 --- a/server/internal/orchestrator/swarm/utils.go +++ b/server/internal/orchestrator/swarm/utils.go @@ -19,6 +19,7 @@ import ( var ( ErrNoPostgresContainer = errors.New("no postgres container found") ErrNoPostgresService = errors.New("no postgres service found") + ErrNoServiceContainer = errors.New("no service container found") ) func GetPostgresContainer(ctx context.Context, dockerClient *docker.Docker, instanceID string) (types.Container, error) { @@ -38,6 +39,22 @@ func GetPostgresContainer(ctx context.Context, dockerClient *docker.Docker, inst return matches[0], nil } +func GetServiceContainer(ctx context.Context, dockerClient *docker.Docker, serviceInstanceID string) (types.Container, error) { + matches, err := dockerClient.ContainerList(ctx, container.ListOptions{ + Filters: filters.NewArgs( + filters.Arg("label", fmt.Sprintf("pgedge.service.instance.id=%s", serviceInstanceID)), + filters.Arg("label", "pgedge.component=service"), + ), + }) + if err != nil { + return types.Container{}, fmt.Errorf("failed to list containers: %w", err) + } + if len(matches) == 0 { + return types.Container{}, fmt.Errorf("%w: %q", ErrNoServiceContainer, serviceInstanceID) + } + return matches[0], nil +} + func PostgresContainerExec(ctx context.Context, w io.Writer, dockerClient *docker.Docker, instanceID string, cmd []string) error { container, err := GetPostgresContainer(ctx, dockerClient, instanceID) if err != nil { diff --git a/server/internal/workflows/activities/activities.go b/server/internal/workflows/activities/activities.go index 5564fad6..439b0e26 100644 --- a/server/internal/workflows/activities/activities.go +++ b/server/internal/workflows/activities/activities.go @@ -12,10 +12,11 @@ import ( ) type Activities struct { - Config config.Config - Injector *do.Injector - Orchestrator database.Orchestrator - TaskSvc *task.Service + Config config.Config + Injector *do.Injector + Orchestrator database.Orchestrator + DatabaseService *database.Service + TaskSvc *task.Service } func (a *Activities) Register(work *worker.Worker) error { @@ -24,8 +25,11 @@ func (a *Activities) Register(work *worker.Worker) error { work.RegisterActivity(a.CancelSwitchover), work.RegisterActivity(a.CheckClusterHealth), work.RegisterActivity(a.CreatePgBackRestBackup), + work.RegisterActivity(a.CreateServiceUser), work.RegisterActivity(a.DeleteDbEntities), + work.RegisterActivity(a.GenerateServiceInstanceResources), work.RegisterActivity(a.GetCurrentState), + work.RegisterActivity(a.GetServiceInstanceStatus), work.RegisterActivity(a.GetInstanceResources), work.RegisterActivity(a.GetPrimaryInstance), work.RegisterActivity(a.GetRestoreResources), @@ -40,7 +44,9 @@ func (a *Activities) Register(work *worker.Worker) error { work.RegisterActivity(a.SelectCandidate), work.RegisterActivity(a.StartInstance), work.RegisterActivity(a.StopInstance), + work.RegisterActivity(a.StoreServiceInstance), work.RegisterActivity(a.UpdateDbState), + work.RegisterActivity(a.UpdateServiceInstanceState), work.RegisterActivity(a.UpdateTask), work.RegisterActivity(a.ValidateInstanceSpecs), } diff --git a/server/internal/workflows/activities/create_service_user.go b/server/internal/workflows/activities/create_service_user.go new file mode 100644 index 00000000..c7ecc1e6 --- /dev/null +++ b/server/internal/workflows/activities/create_service_user.go @@ -0,0 +1,216 @@ +package activities + +import ( + "context" + "fmt" + "time" + + "github.com/cschleiden/go-workflows/activity" + "github.com/cschleiden/go-workflows/workflow" + "github.com/samber/do" + + "github.com/pgEdge/control-plane/server/internal/certificates" + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/patroni" + "github.com/pgEdge/control-plane/server/internal/postgres" + "github.com/pgEdge/control-plane/server/internal/utils" +) + +type CreateServiceUserInput struct { + ServiceInstanceID string `json:"service_instance_id"` + DatabaseID string `json:"database_id"` + DatabaseName string `json:"database_name"` + ServiceID string `json:"service_id"` + HostID string `json:"host_id"` +} + +type CreateServiceUserOutput struct { + Credentials *database.ServiceUser `json:"credentials"` +} + +func (a *Activities) ExecuteCreateServiceUser( + ctx workflow.Context, + hostID string, + input *CreateServiceUserInput, +) workflow.Future[*CreateServiceUserOutput] { + options := workflow.ActivityOptions{ + Queue: utils.HostQueue(hostID), + RetryOptions: workflow.RetryOptions{ + MaxAttempts: 1, + }, + } + return workflow.ExecuteActivity[*CreateServiceUserOutput](ctx, options, a.CreateServiceUser, input) +} + +// CreateServiceUser creates dedicated database credentials for a service instance. +// +// # Credential Generation Pattern +// +// Each service instance receives isolated database credentials with read-only access. +// This provides security isolation between service instances and prevents services from +// modifying data. +// +// # Username Generation +// +// Usernames follow the format: "svc_{service_id}_{host_id}" +// Example: For service_id "mcp-server" and host_id "host1", the username is +// "svc_mcp-server_host1". +// +// The username is deterministic: the same service_id + host_id always produces +// the same username. It is truncated to 63 characters for PostgreSQL +// compatibility. +// +// # Password Generation +// +// Passwords are generated by utils.RandomString(32), which reads 32 bytes from +// crypto/rand and base64url-encodes them. The result is a 44-character string +// (256 bits of entropy), making brute-force attacks infeasible. +// +// # Database Permissions +// +// Service users are granted the "pgedge_application_read_only" role, which provides: +// - SELECT privileges on all tables +// - EXECUTE privileges on functions (read-only operations) +// - No INSERT, UPDATE, DELETE, or DDL privileges +// +// This follows the principle of least privilege - services can query data but cannot +// modify it. Any data modification must go through the application layer. +// +// # Credential Lifecycle +// +// 1. Provisioning: Credentials are created during service instance provisioning +// 2. Storage: Passwords are stored in etcd alongside service instance metadata +// 3. Injection: Credentials are injected as environment variables (PGUSER/PGPASSWORD) +// into the service container at startup +// 4. Rotation: Not currently supported (future enhancement) +// 5. Deletion: Credentials are revoked when the service instance is deleted +// +// # Security Considerations +// +// - Credentials are unique per service instance (not shared) +// - Passwords never appear in logs (marked as sensitive) +// - Read-only access prevents data corruption from compromised services +// - Credentials are injected as environment variables into service containers +// +// # Implementation Details +// +// The activity connects to the database's primary instance using admin credentials, +// generates the username and password, then executes CREATE USER and GRANT statements +// via postgres.CreateUserRole(). The generated credentials are returned for storage +// in etcd and injection into the service container. +func (a *Activities) CreateServiceUser(ctx context.Context, input *CreateServiceUserInput) (*CreateServiceUserOutput, error) { + logger := activity.Logger(ctx).With( + "service_instance_id", input.ServiceInstanceID, + "database_id", input.DatabaseID, + ) + logger.Info("creating service user") + + orch, err := do.Invoke[database.Orchestrator](a.Injector) + if err != nil { + return nil, err + } + + // Get database service to find an instance to connect to + dbSvc, err := do.Invoke[*database.Service](a.Injector) + if err != nil { + return nil, err + } + + db, err := dbSvc.GetDatabase(ctx, input.DatabaseID) + if err != nil { + return nil, fmt.Errorf("failed to get database: %w", err) + } + + if len(db.Instances) == 0 { + return nil, fmt.Errorf("database has no instances") + } + + // Connect to any instance (preferably primary, but any will work for user creation) + var primaryInstanceID string + for _, inst := range db.Instances { + connInfo, err := orch.GetInstanceConnectionInfo(ctx, input.DatabaseID, inst.InstanceID) + if err != nil { + continue + } + + patroniClient := patroni.NewClient(connInfo.PatroniURL(), nil) + primaryID, err := database.GetPrimaryInstanceID(ctx, patroniClient, 10*time.Second) + if err == nil && primaryID != "" { + primaryInstanceID = primaryID + break + } + } + + if primaryInstanceID == "" { + // Fallback: use first available instance + primaryInstanceID = db.Instances[0].InstanceID + logger.Warn("could not determine primary instance, using first available instance") + } + + // Get connection info for the primary instance + connInfo, err := orch.GetInstanceConnectionInfo(ctx, input.DatabaseID, primaryInstanceID) + if err != nil { + return nil, fmt.Errorf("failed to get instance connection info: %w", err) + } + + // Get certificate service to create TLS config for pgedge user + // The pg_hba.conf requires SSL certificate-based authentication for admin connections + certSvc, err := do.Invoke[*certificates.Service](a.Injector) + if err != nil { + return nil, fmt.Errorf("failed to get certificate service: %w", err) + } + + // Create TLS config with pgedge user certificates + tlsConfig, err := certSvc.PostgresUserTLS(ctx, primaryInstanceID, connInfo.InstanceHostname, "pgedge") + if err != nil { + return nil, fmt.Errorf("failed to create TLS config: %w", err) + } + + // Connect to the postgres system database + // Note: We connect to "postgres" instead of the user database because: + // 1. CREATE ROLE/ALTER ROLE/GRANT are cluster-level operations that work from any database + // 2. The pg_hba.conf may not allow connections from Control Plane to user databases + // 3. This matches the pattern used by other user management activities + conn, err := database.ConnectToInstance(ctx, &database.ConnectionOptions{ + DSN: connInfo.AdminDSN("postgres"), + TLS: tlsConfig, + }) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + defer conn.Close(ctx) + + // Generate credentials + username := database.GenerateServiceUsername(input.ServiceID, input.HostID) + password, err := utils.RandomString(32) + if err != nil { + return nil, fmt.Errorf("failed to generate password: %w", err) + } + + // Create user role with read-only permissions + statements, err := postgres.CreateUserRole(postgres.UserRoleOptions{ + Name: username, + Password: password, + DBName: input.DatabaseName, + DBOwner: false, + Roles: []string{"pgedge_application_read_only"}, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate create user role statements: %w", err) + } + + // Execute statements + if err := statements.Exec(ctx, conn); err != nil { + return nil, fmt.Errorf("failed to create service user: %w", err) + } + + logger.Info("service user created successfully", "username", username) + + return &CreateServiceUserOutput{ + Credentials: &database.ServiceUser{ + Username: username, + Password: password, + Role: "pgedge_application_read_only", + }, + }, nil +} diff --git a/server/internal/workflows/activities/generate_service_instance_resources.go b/server/internal/workflows/activities/generate_service_instance_resources.go new file mode 100644 index 00000000..be762d28 --- /dev/null +++ b/server/internal/workflows/activities/generate_service_instance_resources.go @@ -0,0 +1,53 @@ +package activities + +import ( + "context" + "fmt" + + "github.com/cschleiden/go-workflows/activity" + "github.com/cschleiden/go-workflows/workflow" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/utils" +) + +type GenerateServiceInstanceResourcesInput struct { + Spec *database.ServiceInstanceSpec `json:"spec"` +} + +type GenerateServiceInstanceResourcesOutput struct { + Resources *database.ServiceInstanceResources `json:"resources"` +} + +func (a *Activities) ExecuteGenerateServiceInstanceResources( + ctx workflow.Context, + input *GenerateServiceInstanceResourcesInput, +) workflow.Future[*GenerateServiceInstanceResourcesOutput] { + options := workflow.ActivityOptions{ + Queue: utils.ManagerQueue(), + RetryOptions: workflow.RetryOptions{ + MaxAttempts: 1, + }, + } + return workflow.ExecuteActivity[*GenerateServiceInstanceResourcesOutput](ctx, options, a.GenerateServiceInstanceResources, input) +} + +func (a *Activities) GenerateServiceInstanceResources( + ctx context.Context, + input *GenerateServiceInstanceResourcesInput, +) (*GenerateServiceInstanceResourcesOutput, error) { + logger := activity.Logger(ctx).With( + "service_instance_id", input.Spec.ServiceInstanceID, + "database_id", input.Spec.DatabaseID, + ) + logger.Debug("generating service instance resources") + + resources, err := a.Orchestrator.GenerateServiceInstanceResources(input.Spec) + if err != nil { + return nil, fmt.Errorf("failed to generate service instance resources: %w", err) + } + + return &GenerateServiceInstanceResourcesOutput{ + Resources: resources, + }, nil +} diff --git a/server/internal/workflows/activities/get_service_instance_status.go b/server/internal/workflows/activities/get_service_instance_status.go new file mode 100644 index 00000000..2f5a3b0c --- /dev/null +++ b/server/internal/workflows/activities/get_service_instance_status.go @@ -0,0 +1,56 @@ +package activities + +import ( + "context" + "fmt" + + "github.com/cschleiden/go-workflows/activity" + "github.com/cschleiden/go-workflows/workflow" + "github.com/samber/do" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/utils" +) + +type GetServiceInstanceStatusInput struct { + ServiceInstanceID string `json:"service_instance_id"` + HostID string `json:"host_id"` +} + +type GetServiceInstanceStatusOutput struct { + Status *database.ServiceInstanceStatus `json:"status"` +} + +func (a *Activities) ExecuteGetServiceInstanceStatus( + ctx workflow.Context, + hostID string, + input *GetServiceInstanceStatusInput, +) workflow.Future[*GetServiceInstanceStatusOutput] { + options := workflow.ActivityOptions{ + Queue: utils.HostQueue(hostID), + RetryOptions: workflow.RetryOptions{ + MaxAttempts: 3, + }, + } + return workflow.ExecuteActivity[*GetServiceInstanceStatusOutput](ctx, options, a.GetServiceInstanceStatus, input) +} + +func (a *Activities) GetServiceInstanceStatus( + ctx context.Context, + input *GetServiceInstanceStatusInput, +) (*GetServiceInstanceStatusOutput, error) { + logger := activity.Logger(ctx).With("service_instance_id", input.ServiceInstanceID) + logger.Debug("getting service instance status") + + orch, err := do.Invoke[database.Orchestrator](a.Injector) + if err != nil { + return nil, err + } + + status, err := orch.GetServiceInstanceStatus(ctx, input.ServiceInstanceID) + if err != nil { + return nil, fmt.Errorf("failed to get service instance status: %w", err) + } + + return &GetServiceInstanceStatusOutput{Status: status}, nil +} diff --git a/server/internal/workflows/activities/provide.go b/server/internal/workflows/activities/provide.go index 69d4e48a..470b28d0 100644 --- a/server/internal/workflows/activities/provide.go +++ b/server/internal/workflows/activities/provide.go @@ -22,16 +22,21 @@ func provideActivities(i *do.Injector) { if err != nil { return nil, err } + dbSvc, err := do.Invoke[*database.Service](i) + if err != nil { + return nil, err + } taskSvc, err := do.Invoke[*task.Service](i) if err != nil { return nil, err } return &Activities{ - Config: cfg, - Injector: i, - Orchestrator: orch, - TaskSvc: taskSvc, + Config: cfg, + Injector: i, + Orchestrator: orch, + DatabaseService: dbSvc, + TaskSvc: taskSvc, }, nil }) } diff --git a/server/internal/workflows/activities/store_service_instance.go b/server/internal/workflows/activities/store_service_instance.go new file mode 100644 index 00000000..fc44e2f2 --- /dev/null +++ b/server/internal/workflows/activities/store_service_instance.go @@ -0,0 +1,57 @@ +package activities + +import ( + "context" + "fmt" + + "github.com/cschleiden/go-workflows/activity" + "github.com/cschleiden/go-workflows/workflow" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/utils" +) + +type StoreServiceInstanceInput struct { + ServiceInstance *database.ServiceInstance `json:"service_instance"` +} + +type StoreServiceInstanceOutput struct{} + +func (a *Activities) ExecuteStoreServiceInstance( + ctx workflow.Context, + input *StoreServiceInstanceInput, +) workflow.Future[*StoreServiceInstanceOutput] { + options := workflow.ActivityOptions{ + Queue: utils.ManagerQueue(), + RetryOptions: workflow.RetryOptions{ + MaxAttempts: 1, + }, + } + return workflow.ExecuteActivity[*StoreServiceInstanceOutput](ctx, options, a.StoreServiceInstance, input) +} + +func (a *Activities) StoreServiceInstance( + ctx context.Context, + input *StoreServiceInstanceInput, +) (*StoreServiceInstanceOutput, error) { + logger := activity.Logger(ctx).With( + "service_instance_id", input.ServiceInstance.ServiceInstanceID, + "database_id", input.ServiceInstance.DatabaseID, + ) + logger.Debug("storing service instance") + + err := a.DatabaseService.UpdateServiceInstance(ctx, &database.ServiceInstanceUpdateOptions{ + ServiceInstanceID: input.ServiceInstance.ServiceInstanceID, + ServiceID: input.ServiceInstance.ServiceID, + DatabaseID: input.ServiceInstance.DatabaseID, + HostID: input.ServiceInstance.HostID, + State: input.ServiceInstance.State, + }) + if err != nil { + return nil, fmt.Errorf("failed to store service instance: %w", err) + } + + logger.Debug("successfully stored service instance") + + return &StoreServiceInstanceOutput{}, nil +} diff --git a/server/internal/workflows/activities/update_service_instance_state.go b/server/internal/workflows/activities/update_service_instance_state.go new file mode 100644 index 00000000..d8a34678 --- /dev/null +++ b/server/internal/workflows/activities/update_service_instance_state.go @@ -0,0 +1,60 @@ +package activities + +import ( + "context" + "fmt" + + "github.com/cschleiden/go-workflows/activity" + "github.com/cschleiden/go-workflows/workflow" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/utils" +) + +type UpdateServiceInstanceStateInput struct { + ServiceInstanceID string `json:"service_instance_id"` + DatabaseID string `json:"database_id,omitempty"` + State database.ServiceInstanceState `json:"state"` + Status *database.ServiceInstanceStatus `json:"status,omitempty"` + Error string `json:"error,omitempty"` +} + +type UpdateServiceInstanceStateOutput struct{} + +func (a *Activities) ExecuteUpdateServiceInstanceState( + ctx workflow.Context, + input *UpdateServiceInstanceStateInput, +) workflow.Future[*UpdateServiceInstanceStateOutput] { + options := workflow.ActivityOptions{ + Queue: utils.ManagerQueue(), + RetryOptions: workflow.RetryOptions{ + MaxAttempts: 1, + }, + } + return workflow.ExecuteActivity[*UpdateServiceInstanceStateOutput](ctx, options, a.UpdateServiceInstanceState, input) +} + +func (a *Activities) UpdateServiceInstanceState( + ctx context.Context, + input *UpdateServiceInstanceStateInput, +) (*UpdateServiceInstanceStateOutput, error) { + logger := activity.Logger(ctx).With( + "service_instance_id", input.ServiceInstanceID, + "state", input.State, + ) + logger.Debug("updating service instance state") + + err := a.DatabaseService.UpdateServiceInstanceState(ctx, input.ServiceInstanceID, &database.ServiceInstanceStateUpdate{ + DatabaseID: input.DatabaseID, + State: input.State, + Status: input.Status, + Error: input.Error, + }) + if err != nil { + return nil, fmt.Errorf("failed to update service instance state: %w", err) + } + + logger.Debug("successfully updated service instance state") + + return &UpdateServiceInstanceStateOutput{}, nil +} diff --git a/server/internal/workflows/provision_services.go b/server/internal/workflows/provision_services.go new file mode 100644 index 00000000..2073c1d2 --- /dev/null +++ b/server/internal/workflows/provision_services.go @@ -0,0 +1,423 @@ +package workflows + +import ( + "fmt" + + "github.com/cschleiden/go-workflows/workflow" + "github.com/google/uuid" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/monitor" + "github.com/pgEdge/control-plane/server/internal/resource" + "github.com/pgEdge/control-plane/server/internal/task" + "github.com/pgEdge/control-plane/server/internal/utils" + "github.com/pgEdge/control-plane/server/internal/workflows/activities" +) + +type ProvisionServicesInput struct { + TaskID uuid.UUID `json:"task_id"` + Spec *database.Spec `json:"spec"` +} + +type ProvisionServicesOutput struct { +} + +func (w *Workflows) ExecuteProvisionServices( + ctx workflow.Context, + input *ProvisionServicesInput, +) workflow.Future[*ProvisionServicesOutput] { + options := workflow.SubWorkflowOptions{ + Queue: utils.HostQueue(w.Config.HostID), + RetryOptions: workflow.RetryOptions{ + MaxAttempts: 1, + }, + } + return workflow.CreateSubWorkflowInstance[*ProvisionServicesOutput](ctx, options, w.ProvisionServices, input) +} + +func (w *Workflows) ProvisionServices(ctx workflow.Context, input *ProvisionServicesInput) (*ProvisionServicesOutput, error) { + logger := workflow.Logger(ctx).With("database_id", input.Spec.DatabaseID) + logger.With("service_count", len(input.Spec.Services)).Info("ProvisionServices workflow started") + + if len(input.Spec.Services) == 0 { + logger.Info("no services to provision - returning early") + return &ProvisionServicesOutput{}, nil + } + + // Log task start + start := workflow.Now(ctx) + err := w.logTaskEvent(ctx, + task.ScopeDatabase, + input.Spec.DatabaseID, + input.TaskID, + task.LogEntry{ + Message: fmt.Sprintf("provisioning %d service(s)", len(input.Spec.Services)), + Fields: map[string]any{ + "service_count": len(input.Spec.Services), + }, + }, + ) + if err != nil { + return nil, err + } + + // Get existing database state + getCurrentInput := &activities.GetCurrentStateInput{ + DatabaseID: input.Spec.DatabaseID, + } + getCurrentOutput, err := w.Activities.ExecuteGetCurrentState(ctx, getCurrentInput).Get(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get current state: %w", err) + } + accumulatedState := getCurrentOutput.State + + // Track prepared service instances for parallel deployment + type serviceInstancePrep struct { + serviceInstanceID string + serviceSpec *database.ServiceSpec + hostID string + resources []*resource.ResourceData + logFields []any // Logger fields for this instance + } + var preparedInstances []serviceInstancePrep + + // Phase 1: Prepare all service instances (create users, generate resources) + // This must be done serially because CreateServiceUser connects to Postgres + for _, serviceSpec := range input.Spec.Services { + logger := logger.With("service_id", serviceSpec.ServiceID) + + // Log service provisioning start + err := w.logTaskEvent(ctx, + task.ScopeDatabase, + input.Spec.DatabaseID, + input.TaskID, + task.LogEntry{ + Message: fmt.Sprintf("provisioning service '%s' on %d host(s)", serviceSpec.ServiceID, len(serviceSpec.HostIDs)), + Fields: map[string]any{ + "service_id": serviceSpec.ServiceID, + "service_type": serviceSpec.ServiceType, + "version": serviceSpec.Version, + "host_count": len(serviceSpec.HostIDs), + }, + }, + ) + if err != nil { + return nil, err + } + + // Prepare each service instance on each host + for _, hostID := range serviceSpec.HostIDs { + serviceInstanceID := database.GenerateServiceInstanceID(input.Spec.DatabaseID, serviceSpec.ServiceID, hostID) + instanceLogger := logger.With("service_instance_id", serviceInstanceID, "host_id", hostID) + + // Store service instance immediately with state="creating" + // This ensures it's visible even if provisioning fails later + storeInitialInput := &activities.StoreServiceInstanceInput{ + ServiceInstance: &database.ServiceInstance{ + ServiceInstanceID: serviceInstanceID, + ServiceID: serviceSpec.ServiceID, + DatabaseID: input.Spec.DatabaseID, + HostID: hostID, + State: database.ServiceInstanceStateCreating, + CreatedAt: workflow.Now(ctx), + UpdatedAt: workflow.Now(ctx), + }, + } + _, err := w.Activities.ExecuteStoreServiceInstance(ctx, storeInitialInput).Get(ctx) + if err != nil { + instanceLogger.With("error", err).Error("failed to store initial service instance") + return nil, fmt.Errorf("failed to store service instance %s: %w", serviceInstanceID, err) + } + + instanceLogger.Info("stored service instance with state=creating") + + // Error handler to mark service instance as failed and continue to next instance + // Can only be used AFTER the service instance is stored above + handleError := func(cause error) { + instanceLogger.With("error", cause).Error("failed to prepare service instance") + + // Mark service instance as failed + updateInstanceInput := &activities.UpdateServiceInstanceStateInput{ + ServiceInstanceID: serviceInstanceID, + DatabaseID: input.Spec.DatabaseID, + State: database.ServiceInstanceStateFailed, + Error: cause.Error(), + } + _, stateErr := w.Activities.ExecuteUpdateServiceInstanceState(ctx, updateInstanceInput).Get(ctx) + if stateErr != nil { + instanceLogger.With("error", stateErr).Warn("failed to update service instance state to failed") + } + } + + // Find any Postgres instance in this database to get connection details + // Services can be on different hosts than database instances, they just need + // database network connectivity. We prefer an instance on the same host for + // lower latency, but any instance will work. + var instanceHostname string + var instancePort int = 5432 // Default Postgres port + var instanceHostID string // Host where the instance is running (needed for CreateServiceUser) + instanceResources := accumulatedState.GetAll(database.ResourceTypeInstance) + + // First try to find an instance on the same host (preferred for latency) + for _, instanceData := range instanceResources { + instance, err := resource.ToResource[*database.InstanceResource](instanceData) + if err != nil { + continue + } + if instance.Spec.DatabaseID == input.Spec.DatabaseID && instance.Spec.HostID == hostID { + instanceHostname = instance.InstanceHostname + instanceHostID = instance.Spec.HostID + if instance.Spec.Port != nil { + instancePort = *instance.Spec.Port + } + break + } + } + + // If no instance on same host, use any instance in the database + if instanceHostname == "" { + for _, instanceData := range instanceResources { + instance, err := resource.ToResource[*database.InstanceResource](instanceData) + if err != nil { + continue + } + if instance.Spec.DatabaseID == input.Spec.DatabaseID { + instanceHostname = instance.InstanceHostname + instanceHostID = instance.Spec.HostID + if instance.Spec.Port != nil { + instancePort = *instance.Spec.Port + } + break + } + } + } + + if instanceHostname == "" { + handleError(fmt.Errorf("no postgres instance found for database %s", input.Spec.DatabaseID)) + continue + } + + // Create database credentials for this service instance + // IMPORTANT: Execute on instanceHostID (where Postgres is running), not hostID (where service will run) + // CreateServiceUser needs to connect to the local Postgres container via Docker + createUserInput := &activities.CreateServiceUserInput{ + DatabaseID: input.Spec.DatabaseID, + DatabaseName: input.Spec.DatabaseName, + ServiceInstanceID: serviceInstanceID, + ServiceID: serviceSpec.ServiceID, + HostID: hostID, + } + createUserOutput, err := w.Activities.ExecuteCreateServiceUser(ctx, instanceHostID, createUserInput).Get(ctx) + if err != nil { + handleError(fmt.Errorf("failed to create service user for instance %s: %w", serviceInstanceID, err)) + continue + } + + instanceLogger.With("username", createUserOutput.Credentials.Username).Info("created service instance credentials") + + // Generate service instance resources + // Note: CohortMemberID is populated by the orchestrator using its swarmNodeID + serviceInstanceSpec := &database.ServiceInstanceSpec{ + ServiceInstanceID: serviceInstanceID, + ServiceSpec: serviceSpec, + DatabaseID: input.Spec.DatabaseID, + DatabaseName: input.Spec.DatabaseName, + HostID: hostID, + ServiceName: database.GenerateServiceName(serviceSpec.ServiceType, input.Spec.DatabaseID, serviceSpec.ServiceID, hostID), + Hostname: database.GenerateServiceHostname(serviceSpec.ServiceID, hostID), + Credentials: createUserOutput.Credentials, + DatabaseNetworkID: database.GenerateDatabaseNetworkID(input.Spec.DatabaseID), + DatabaseHost: instanceHostname, + DatabasePort: instancePort, + Port: serviceSpec.Port, + } + + generateInput := &activities.GenerateServiceInstanceResourcesInput{ + Spec: serviceInstanceSpec, + } + generateOutput, err := w.Activities.ExecuteGenerateServiceInstanceResources(ctx, generateInput).Get(ctx) + if err != nil { + handleError(fmt.Errorf("failed to generate service instance resources: %w", err)) + continue + } + + instanceLogger.With("resource_count", len(generateOutput.Resources.Resources)).Info("generated service instance resources") + + // Add monitor resource to track service instance state transitions + monitorResource := &monitor.ServiceInstanceMonitorResource{ + DatabaseID: input.Spec.DatabaseID, + ServiceInstanceID: serviceInstanceID, + HostID: hostID, + } + monitorResourceData, err := resource.ToResourceData(monitorResource) + if err != nil { + handleError(fmt.Errorf("failed to convert monitor resource to resource data: %w", err)) + continue + } + generateOutput.Resources.Resources = append(generateOutput.Resources.Resources, monitorResourceData) + + instanceLogger.With("resource_count", len(generateOutput.Resources.Resources)).Info("generated service instance resources with monitor") + + // Add to prepared instances for parallel deployment + preparedInstances = append(preparedInstances, serviceInstancePrep{ + serviceInstanceID: serviceInstanceID, + serviceSpec: serviceSpec, + hostID: hostID, + resources: generateOutput.Resources.Resources, + logFields: []any{"service_instance_id", serviceInstanceID, "host_id", hostID}, + }) + } + + } + + // Phase 2 & 3: Deploy all service instances in parallel + if len(preparedInstances) > 0 { + // Accumulate all service instance resources into desired state + serviceDesiredState := resource.NewState() + for _, prep := range preparedInstances { + serviceDesiredState.Add(prep.resources...) + } + + // Plan all service resources together (shares network, etc.) + serviceCurrentState := resource.NewState() + servicePlan, err := serviceCurrentState.Plan(resource.PlanOptions{}, serviceDesiredState) + if err != nil { + return nil, fmt.Errorf("failed to plan service instance resources: %w", err) + } + + // Apply all service resources in parallel (same pattern as database instances) + err = w.applyEvents(ctx, input.Spec.DatabaseID, input.TaskID, serviceCurrentState, servicePlan) + if err != nil { + logger.With("error", err).Warn("some service instances failed to deploy") + + // Check for resource errors and mark service instances as failed + // applyEvents may have aborted before creating monitors, so we must handle state transitions here + for _, prep := range preparedInstances { + // Get the ServiceInstance resource from current state to check for errors + // Use the same identifier format as swarm.ServiceInstanceResourceIdentifier but without import cycle + serviceInstanceIdentifier := resource.Identifier{ + ID: prep.serviceInstanceID, + Type: resource.Type(database.ResourceTypeServiceInstance), + } + serviceInstanceResourceData, found := serviceCurrentState.Get(serviceInstanceIdentifier) + + if found && serviceInstanceResourceData != nil && serviceInstanceResourceData.Error != "" { + // ServiceInstance deployment failed - mark as failed in etcd + updateInput := &activities.UpdateServiceInstanceStateInput{ + ServiceInstanceID: prep.serviceInstanceID, + DatabaseID: input.Spec.DatabaseID, + State: database.ServiceInstanceStateFailed, + Error: serviceInstanceResourceData.Error, + } + + _, updateErr := w.Activities.ExecuteUpdateServiceInstanceState(ctx, updateInput).Get(ctx) + if updateErr != nil { + logger.With("error", updateErr, "service_instance_id", prep.serviceInstanceID). + Error("failed to update service instance state to failed") + } else { + logger.With("service_instance_id", prep.serviceInstanceID). + Info("marked service instance as failed due to deployment error") + } + } + } + } + + // Merge service resources into accumulated state + for _, resourcesByID := range serviceCurrentState.Resources { + for _, res := range resourcesByID { + accumulatedState.Add(res) + } + } + + // Phase 4: Update statuses for all service instances + for _, prep := range preparedInstances { + instanceLogger := logger.With(prep.logFields...) + + // Get service instance status (connection info, ports) + statusInput := &activities.GetServiceInstanceStatusInput{ + ServiceInstanceID: prep.serviceInstanceID, + HostID: prep.hostID, + } + statusOutput, err := w.Activities.ExecuteGetServiceInstanceStatus(ctx, prep.hostID, statusInput).Get(ctx) + if err != nil { + instanceLogger.With("error", err).Warn("failed to get service instance status (monitor will enrich)") + continue + } + + // If status is nil, the container is still starting - leave state as "creating" + // The instance monitor will update it to "running" once the container is ready + if statusOutput.Status == nil { + instanceLogger.Info("service container still starting - status will be populated by monitoring") + continue + } + + // Update service instance state to "running" with connection info + updateInstanceInput := &activities.UpdateServiceInstanceStateInput{ + ServiceInstanceID: prep.serviceInstanceID, + DatabaseID: input.Spec.DatabaseID, + State: database.ServiceInstanceStateRunning, + Status: statusOutput.Status, + } + _, err = w.Activities.ExecuteUpdateServiceInstanceState(ctx, updateInstanceInput).Get(ctx) + if err != nil { + instanceLogger.With("error", err).Error("failed to update service instance state") + continue + } + + instanceLogger.Info("service instance provisioned successfully") + } + + } + + // Log overall service provisioning completion + for _, serviceSpec := range input.Spec.Services { + err = w.logTaskEvent(ctx, + task.ScopeDatabase, + input.Spec.DatabaseID, + input.TaskID, + task.LogEntry{ + Message: fmt.Sprintf("provisioned service '%s' on %d host(s)", serviceSpec.ServiceID, len(serviceSpec.HostIDs)), + Fields: map[string]any{ + "service_id": serviceSpec.ServiceID, + "host_count": len(serviceSpec.HostIDs), + }, + }, + ) + if err != nil { + return nil, err + } + } + + // Persist the complete state with all database and service instance resources + persistInput := &activities.PersistStateInput{ + DatabaseID: input.Spec.DatabaseID, + State: accumulatedState, + } + _, err = w.Activities.ExecutePersistState(ctx, persistInput).Get(ctx) + if err != nil { + logger.With("error", err).Error("failed to persist service instance state") + return nil, fmt.Errorf("failed to persist service instance state: %w", err) + } + + // Log task completion + duration := workflow.Now(ctx).Sub(start) + err = w.logTaskEvent(ctx, + task.ScopeDatabase, + input.Spec.DatabaseID, + input.TaskID, + task.LogEntry{ + Message: fmt.Sprintf("finished provisioning %d service(s) (took %s)", len(input.Spec.Services), duration), + Fields: map[string]any{ + "service_count": len(input.Spec.Services), + "duration_ms": duration.Milliseconds(), + }, + }, + ) + if err != nil { + return nil, err + } + + logger.Info("successfully provisioned all services") + + return &ProvisionServicesOutput{}, nil +} diff --git a/server/internal/workflows/update_database.go b/server/internal/workflows/update_database.go index e1ae9a72..6ed666b7 100644 --- a/server/internal/workflows/update_database.go +++ b/server/internal/workflows/update_database.go @@ -119,6 +119,39 @@ func (w *Workflows) UpdateDatabase(ctx workflow.Context, input *UpdateDatabaseIn return nil, handleError(err) } + // Provision services after database resources are applied + logger.With("service_count_in_spec", len(input.Spec.Services)).Info("checking if we need to provision services") + if len(input.Spec.Services) > 0 { + provisionServicesInput := &ProvisionServicesInput{ + TaskID: input.TaskID, + Spec: input.Spec, + } + + logger.With("service_count", len(input.Spec.Services)).Info("calling ProvisionServices workflow") + + _, err = w.ExecuteProvisionServices(ctx, provisionServicesInput).Get(ctx) + if err != nil { + // Log service provisioning failure but allow database to succeed + // Service instances will be marked as "failed" with error details + logger.With("error", err).Error("failed to provision services - database will be available but services degraded") + + err = w.logTaskEvent(ctx, + task.ScopeDatabase, + input.Spec.DatabaseID, + input.TaskID, + task.LogEntry{ + Message: "service provisioning failed - database available but services unavailable", + Fields: map[string]any{ + "error": err.Error(), + }, + }, + ) + if err != nil { + logger.With("error", err).Warn("failed to log service provisioning error") + } + } + } + updateStateInput := &activities.UpdateDbStateInput{ DatabaseID: input.Spec.DatabaseID, State: database.DatabaseStateAvailable, diff --git a/server/internal/workflows/workflows.go b/server/internal/workflows/workflows.go index 648ecb42..c812f9f9 100644 --- a/server/internal/workflows/workflows.go +++ b/server/internal/workflows/workflows.go @@ -6,12 +6,14 @@ import ( "github.com/cschleiden/go-workflows/worker" "github.com/pgEdge/control-plane/server/internal/config" + "github.com/pgEdge/control-plane/server/internal/database" "github.com/pgEdge/control-plane/server/internal/workflows/activities" ) type Workflows struct { - Config config.Config - Activities *activities.Activities + Config config.Config + Activities *activities.Activities + Orchestrator database.Orchestrator } func (w *Workflows) Register(work *worker.Worker) error { @@ -25,6 +27,7 @@ func (w *Workflows) Register(work *worker.Worker) error { work.RegisterWorkflow(w.PgBackRestRestore), work.RegisterWorkflow(w.PlanRestore), work.RegisterWorkflow(w.PlanUpdate), + work.RegisterWorkflow(w.ProvisionServices), work.RegisterWorkflow(w.RefreshCurrentState), work.RegisterWorkflow(w.RemoveHost), work.RegisterWorkflow(w.RestartInstance), From 4ad49e83b8e676fe1b52bbfc5b5e080dd497f8c2 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Mon, 9 Feb 2026 15:21:08 -0500 Subject: [PATCH 02/12] delint --- server/internal/docker/docker.go | 12 +++++------- .../internal/orchestrator/swarm/service_spec_test.go | 4 ++-- server/internal/workflows/provision_services.go | 4 ++-- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/server/internal/docker/docker.go b/server/internal/docker/docker.go index 2145eeb1..96880514 100644 --- a/server/internal/docker/docker.go +++ b/server/internal/docker/docker.go @@ -428,14 +428,14 @@ func (d *Docker) WaitForService(ctx context.Context, serviceID string, timeout t var taskStates []string for _, t := range tasks { taskStates = append(taskStates, string(t.Status.State)) - if t.Status.State == swarm.TaskStateRunning { + switch t.Status.State { + case swarm.TaskStateRunning: if t.DesiredState == swarm.TaskStateRunning { running++ } else { stopping++ } - } else if t.Status.State == swarm.TaskStateFailed || - t.Status.State == swarm.TaskStateRejected { + case swarm.TaskStateFailed, swarm.TaskStateRejected: failed++ // Capture the error message from the most recent failed task if t.Status.Err != "" { @@ -443,11 +443,9 @@ func (d *Docker) WaitForService(ctx context.Context, serviceID string, timeout t } else if t.Status.Message != "" { lastFailureMsg = t.Status.Message } - } else if t.Status.State == swarm.TaskStatePreparing { + case swarm.TaskStatePreparing: preparing++ - } else if t.Status.State == swarm.TaskStatePending || - t.Status.State == swarm.TaskStateAssigned || - t.Status.State == swarm.TaskStateAccepted { + case swarm.TaskStatePending, swarm.TaskStateAssigned, swarm.TaskStateAccepted: pending++ } } diff --git a/server/internal/orchestrator/swarm/service_spec_test.go b/server/internal/orchestrator/swarm/service_spec_test.go index 920a987e..4cfee2ef 100644 --- a/server/internal/orchestrator/swarm/service_spec_test.go +++ b/server/internal/orchestrator/swarm/service_spec_test.go @@ -375,8 +375,8 @@ func TestServiceContainerSpec(t *testing.T) { } // Check service name - if got.Annotations.Name != tt.opts.ServiceName { - t.Errorf("service name = %v, want %v", got.Annotations.Name, tt.opts.ServiceName) + if got.Name != tt.opts.ServiceName { + t.Errorf("service name = %v, want %v", got.Name, tt.opts.ServiceName) } // Check hostname diff --git a/server/internal/workflows/provision_services.go b/server/internal/workflows/provision_services.go index 2073c1d2..a83f706f 100644 --- a/server/internal/workflows/provision_services.go +++ b/server/internal/workflows/provision_services.go @@ -154,8 +154,8 @@ func (w *Workflows) ProvisionServices(ctx workflow.Context, input *ProvisionServ // database network connectivity. We prefer an instance on the same host for // lower latency, but any instance will work. var instanceHostname string - var instancePort int = 5432 // Default Postgres port - var instanceHostID string // Host where the instance is running (needed for CreateServiceUser) + var instancePort = 5432 // Default Postgres port + var instanceHostID string // Host where the instance is running (needed for CreateServiceUser) instanceResources := accumulatedState.GetAll(database.ResourceTypeInstance) // First try to find an instance on the same host (preferred for latency) From 5bf6d1876f1ba971f817632f2b1222d80f065963 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Mon, 9 Feb 2026 17:13:10 -0500 Subject: [PATCH 03/12] addressing coderabbit "minor" issues --- Makefile | 13 +++-- docs/development/service-credentials.md | 14 +++--- e2e/service_provisioning_test.go | 4 +- server/internal/api/apiv1/validate.go | 48 +++++++++++-------- server/internal/database/service.go | 8 +++- server/internal/docker/docker.go | 2 +- .../orchestrator/swarm/orchestrator.go | 8 +++- .../orchestrator/swarm/service_spec_test.go | 3 +- 8 files changed, 61 insertions(+), 39 deletions(-) diff --git a/Makefile b/Makefile index 0180edcb..1aeb5187 100644 --- a/Makefile +++ b/Makefile @@ -369,11 +369,14 @@ dev-down: .PHONY: dev-teardown dev-teardown: dev-down # remove postgres and supported services - docker service ls -q \ - | xargs -r docker service inspect \ - --format '{{.ID}} {{index .Spec.Labels "pgedge.component"}}' \ - | awk '$$2=="postgres" || $$2=="service" {print $$1}' \ - | xargs -r docker service rm + ids=$$(docker service ls -q); \ + if [ -n "$$ids" ]; then \ + echo "$$ids" \ + | xargs docker service inspect \ + --format '{{.ID}} {{index .Spec.Labels "pgedge.component"}}' \ + | awk '$$2=="postgres" || $$2=="service" {print $$1}' \ + | xargs docker service rm; \ + fi docker network ls \ --filter=scope=swarm \ --format '{{ .Name }}' \ diff --git a/docs/development/service-credentials.md b/docs/development/service-credentials.md index 4480f5c4..0823335f 100644 --- a/docs/development/service-credentials.md +++ b/docs/development/service-credentials.md @@ -197,11 +197,11 @@ The following sections describe common credential-related issues and their solut Verify the following items when a service cannot connect to the database: -1. Confirm the service instance state is "running" via `GET /v1/databases/{id}`. -2. Confirm the database credentials exist in etcd. -3. Confirm the database user exists by running `'SELECT * FROM pg_user WHERE usename LIKE 'svc_%';'`. -4. Confirm network connectivity from the service container to the database. -5. Check the service logs for connection error messages. +1. Verify the service instance state is "running" via `GET /v1/databases/{id}`. +2. Ensure the database credentials exist in etcd. +3. Check that the database user exists by running `SELECT * FROM pg_user WHERE usename LIKE 'svc_%'`. +4. Test network connectivity from the service container to the database. +5. Inspect the service logs for connection error messages. ### Permission Denied Errors @@ -223,8 +223,8 @@ Username collisions are rare because the service instance ID is unique within ea Verify the following items when a collision is suspected: -- Check for duplicate service instance IDs in etcd. -- Run `'SELECT * FROM pg_user WHERE usename = 'svc_';'` to confirm the user exists. +- Verify there are no duplicate service instance IDs in etcd. +- Run `SELECT * FROM pg_user WHERE usename = 'svc_'` to check whether the user exists. ## Future Enhancements diff --git a/e2e/service_provisioning_test.go b/e2e/service_provisioning_test.go index 92a3d5c0..146be39f 100644 --- a/e2e/service_provisioning_test.go +++ b/e2e/service_provisioning_test.go @@ -84,7 +84,7 @@ func TestProvisionMCPService(t *testing.T) { if serviceInstance.State == "creating" { t.Log("Service is still creating, waiting for it to become running...") - maxWait := 2 * time.Minute + maxWait := 5 * time.Minute pollInterval := 5 * time.Second deadline := time.Now().Add(maxWait) @@ -496,7 +496,7 @@ func TestProvisionMCPServiceRecovery(t *testing.T) { if serviceInstance.State != "running" { t.Log("Service is not yet running, waiting...") - maxWait := 2 * time.Minute + maxWait := 5 * time.Minute pollInterval := 5 * time.Second deadline := time.Now().Add(maxWait) diff --git a/server/internal/api/apiv1/validate.go b/server/internal/api/apiv1/validate.go index 7546fa64..2d75430b 100644 --- a/server/internal/api/apiv1/validate.go +++ b/server/internal/api/apiv1/validate.go @@ -293,29 +293,35 @@ func validateMCPServiceConfig(config map[string]any, path []string) []error { } // Validate llm_provider - if provider, ok := config["llm_provider"].(string); ok { - validProviders := []string{"anthropic", "openai", "ollama"} - if !slices.Contains(validProviders, provider) { - err := fmt.Errorf("unsupported llm_provider '%s' (must be one of: %s)", provider, strings.Join(validProviders, ", ")) + if val, exists := config["llm_provider"]; exists { + provider, ok := val.(string) + if !ok { + err := errors.New("llm_provider must be a string") errs = append(errs, newValidationError(err, appendPath(path, mapKeyPath("llm_provider")))) - } - - // Provider-specific API key validation - switch provider { - case "anthropic": - if _, ok := config["anthropic_api_key"]; !ok { - err := errors.New("missing required field 'anthropic_api_key' for anthropic provider") - errs = append(errs, newValidationError(err, path)) + } else { + validProviders := []string{"anthropic", "openai", "ollama"} + if !slices.Contains(validProviders, provider) { + err := fmt.Errorf("unsupported llm_provider '%s' (must be one of: %s)", provider, strings.Join(validProviders, ", ")) + errs = append(errs, newValidationError(err, appendPath(path, mapKeyPath("llm_provider")))) } - case "openai": - if _, ok := config["openai_api_key"]; !ok { - err := errors.New("missing required field 'openai_api_key' for openai provider") - errs = append(errs, newValidationError(err, path)) - } - case "ollama": - if _, ok := config["ollama_url"]; !ok { - err := errors.New("missing required field 'ollama_url' for ollama provider") - errs = append(errs, newValidationError(err, path)) + + // Provider-specific API key validation + switch provider { + case "anthropic": + if _, ok := config["anthropic_api_key"]; !ok { + err := errors.New("missing required field 'anthropic_api_key' for anthropic provider") + errs = append(errs, newValidationError(err, path)) + } + case "openai": + if _, ok := config["openai_api_key"]; !ok { + err := errors.New("missing required field 'openai_api_key' for openai provider") + errs = append(errs, newValidationError(err, path)) + } + case "ollama": + if _, ok := config["ollama_url"]; !ok { + err := errors.New("missing required field 'ollama_url' for ollama provider") + errs = append(errs, newValidationError(err, path)) + } } } } diff --git a/server/internal/database/service.go b/server/internal/database/service.go index 2c6ed473..8b570665 100644 --- a/server/internal/database/service.go +++ b/server/internal/database/service.go @@ -602,6 +602,9 @@ func (s *Service) SetServiceInstanceState( GetByKey(databaseID, serviceInstanceID). Exec(ctx) if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return ErrServiceInstanceNotFound + } return fmt.Errorf("failed to get service instance: %w", err) } stored.State = state @@ -711,6 +714,9 @@ func (s *Service) UpdateServiceInstanceState( GetByKey(update.DatabaseID, serviceInstanceID). Exec(ctx) if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return ErrServiceInstanceNotFound + } return fmt.Errorf("failed to get service instance: %w", err) } databaseID = stored.DatabaseID @@ -732,7 +738,7 @@ func (s *Service) UpdateServiceInstanceState( } } if databaseID == "" { - return fmt.Errorf("service instance %s not found", serviceInstanceID) + return ErrServiceInstanceNotFound } } diff --git a/server/internal/docker/docker.go b/server/internal/docker/docker.go index 96880514..9b53e88e 100644 --- a/server/internal/docker/docker.go +++ b/server/internal/docker/docker.go @@ -393,7 +393,7 @@ func (d *Docker) WaitForService(ctx context.Context, serviceID string, timeout t return fmt.Errorf("failed to inspect service: %w", errTranslate(err)) } if service.Spec.Mode.Replicated == nil { - return fmt.Errorf("WaitForService is only usable for replicated services: %w", err) + return fmt.Errorf("WaitForService is only usable for replicated services") } if service.UpdateStatus != nil && service.UpdateStatus.State != swarm.UpdateStateCompleted { d.logger.Debug(). diff --git a/server/internal/orchestrator/swarm/orchestrator.go b/server/internal/orchestrator/swarm/orchestrator.go index bb6769b7..e2d96bd8 100644 --- a/server/internal/orchestrator/swarm/orchestrator.go +++ b/server/internal/orchestrator/swarm/orchestrator.go @@ -562,13 +562,19 @@ func (o *Orchestrator) GetServiceInstanceStatus(ctx context.Context, serviceInst }) } + // Determine readiness from container state and health info + ready := inspect.State != nil && inspect.State.Running + if ready && inspect.State.Health != nil { + ready = inspect.State.Health.Status == "healthy" + } + return &database.ServiceInstanceStatus{ ContainerID: utils.PointerTo(inspect.ID), ImageVersion: utils.PointerTo(inspect.Config.Image), Hostname: utils.PointerTo(inspect.Config.Hostname), IPv4Address: utils.PointerTo(o.cfg.IPv4Address), Ports: ports, - ServiceReady: utils.PointerTo(true), + ServiceReady: utils.PointerTo(ready), }, nil } diff --git a/server/internal/orchestrator/swarm/service_spec_test.go b/server/internal/orchestrator/swarm/service_spec_test.go index 4cfee2ef..469f1284 100644 --- a/server/internal/orchestrator/swarm/service_spec_test.go +++ b/server/internal/orchestrator/swarm/service_spec_test.go @@ -1,6 +1,7 @@ package swarm import ( + "strings" "testing" "github.com/docker/docker/api/types/swarm" @@ -326,7 +327,7 @@ func TestServiceContainerSpec(t *testing.T) { checkEnv: func(t *testing.T, env []string) { // Should not have PGUSER or PGPASSWORD for _, e := range env { - if e == "PGUSER" || e == "PGPASSWORD" { + if strings.HasPrefix(e, "PGUSER=") || strings.HasPrefix(e, "PGPASSWORD=") { t.Errorf("unexpected credential env var: %s", e) } } From 5f0313ae7fa39604b5e5dfc0548de89ae1f37115 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Mon, 9 Feb 2026 17:54:02 -0500 Subject: [PATCH 04/12] addressing most coderabbit "major" issues --- docs/development/service-credentials.md | 13 ++++++++- server/internal/api/apiv1/convert.go | 21 +++++++++++++-- server/internal/docker/docker.go | 4 +-- server/internal/monitor/service.go | 6 ++--- .../service_instance_monitor_resource.go | 2 +- .../orchestrator/swarm/service_images.go | 10 +++---- .../orchestrator/swarm/service_images_test.go | 27 ++++++++++++++----- .../orchestrator/swarm/service_spec_test.go | 10 +++---- 8 files changed, 67 insertions(+), 26 deletions(-) diff --git a/docs/development/service-credentials.md b/docs/development/service-credentials.md index 0823335f..781766f3 100644 --- a/docs/development/service-credentials.md +++ b/docs/development/service-credentials.md @@ -50,7 +50,18 @@ The username format provides the following benefits: ### PostgreSQL Compatibility PostgreSQL limits identifier length to 63 characters. -The system truncates the username to 63 characters when the combined values exceed that limit. +When the full `svc_{service_id}_{host_id}` username fits within 63 characters, it is used as-is. + +When the combined values exceed 63 characters, the system appends a deterministic 8-character +hex hash (derived from the SHA-256 of the full untruncated username) to a truncated prefix: + +```text +Short name format: svc_{service_id}_{host_id} +Long name format: svc_{first 50 chars of service_id_host_id}_{8-hex-hash} +``` + +The hash suffix guarantees uniqueness even when two long inputs share a common prefix, because +different full usernames produce different SHA-256 digests. ## Password Generation diff --git a/server/internal/api/apiv1/convert.go b/server/internal/api/apiv1/convert.go index f3b3867e..e1fd249f 100644 --- a/server/internal/api/apiv1/convert.go +++ b/server/internal/api/apiv1/convert.go @@ -19,6 +19,24 @@ import ( "github.com/pgEdge/control-plane/server/internal/utils" ) +// isSensitiveConfigKey returns true if the given config key name likely +// contains a secret value that should not be returned in API responses. +func isSensitiveConfigKey(key string) bool { + k := strings.ToLower(key) + patterns := []string{ + "password", "secret", "token", + "api_key", "apikey", "api-key", + "credential", "private_key", "private-key", + "access_key", "access-key", + } + for _, p := range patterns { + if strings.Contains(k, p) { + return true + } + } + return false +} + func hostToAPI(h *host.Host) *api.Host { components := make(map[string]*api.ComponentStatus, len(h.Status.Components)) for name, status := range h.Status.Components { @@ -196,8 +214,7 @@ func serviceSpecToAPI(svc *database.ServiceSpec) *api.ServiceSpec { if svc.Config != nil { filteredConfig = make(map[string]any, len(svc.Config)) for k, v := range svc.Config { - kLower := strings.ToLower(k) - if strings.Contains(kLower, "api_key") || strings.Contains(kLower, "secret") || strings.Contains(kLower, "password") { + if isSensitiveConfigKey(k) { continue } filteredConfig[k] = v diff --git a/server/internal/docker/docker.go b/server/internal/docker/docker.go index 9b53e88e..2236476b 100644 --- a/server/internal/docker/docker.go +++ b/server/internal/docker/docker.go @@ -462,8 +462,8 @@ func (d *Docker) WaitForService(ctx context.Context, serviceID string, timeout t Strs("task_states", taskStates). Msg("checking service task status") - // If we have failed tasks and no running tasks, the service won't start - if failed > 0 && running == 0 { + // If we have failed tasks and no running or transitional tasks, the service won't start + if failed > 0 && running == 0 && preparing == 0 && pending == 0 { if lastFailureMsg != "" { return fmt.Errorf("service tasks failed: %s", lastFailureMsg) } diff --git a/server/internal/monitor/service.go b/server/internal/monitor/service.go index 31e9c131..126ff86c 100644 --- a/server/internal/monitor/service.go +++ b/server/internal/monitor/service.go @@ -167,7 +167,7 @@ func (s *Service) addInstanceMonitor(databaseID, instanceID, dbName string) { s.instances[instanceID] = mon } -func (s *Service) CreateServiceInstanceMonitor(ctx context.Context, databaseID, serviceInstanceID, hostID string) error { +func (s *Service) CreateServiceInstanceMonitor(ctx context.Context, databaseID, serviceInstanceID string) error { if s.HasServiceInstanceMonitor(serviceInstanceID) { err := s.DeleteServiceInstanceMonitor(ctx, serviceInstanceID) if err != nil { @@ -176,7 +176,7 @@ func (s *Service) CreateServiceInstanceMonitor(ctx context.Context, databaseID, } err := s.store.ServiceInstanceMonitor.Put(&StoredServiceInstanceMonitor{ - HostID: hostID, + HostID: s.cfg.HostID, DatabaseID: databaseID, ServiceInstanceID: serviceInstanceID, }).Exec(ctx) @@ -184,7 +184,7 @@ func (s *Service) CreateServiceInstanceMonitor(ctx context.Context, databaseID, return fmt.Errorf("failed to persist service instance monitor: %w", err) } - s.addServiceInstanceMonitor(databaseID, serviceInstanceID, hostID) + s.addServiceInstanceMonitor(databaseID, serviceInstanceID, s.cfg.HostID) return nil } diff --git a/server/internal/monitor/service_instance_monitor_resource.go b/server/internal/monitor/service_instance_monitor_resource.go index 11526d35..2f8215e4 100644 --- a/server/internal/monitor/service_instance_monitor_resource.go +++ b/server/internal/monitor/service_instance_monitor_resource.go @@ -69,7 +69,7 @@ func (m *ServiceInstanceMonitorResource) Create(ctx context.Context, rc *resourc return err } - err = service.CreateServiceInstanceMonitor(ctx, m.DatabaseID, m.ServiceInstanceID, m.HostID) + err = service.CreateServiceInstanceMonitor(ctx, m.DatabaseID, m.ServiceInstanceID) if err != nil { return fmt.Errorf("failed to create service instance monitor: %w", err) } diff --git a/server/internal/orchestrator/swarm/service_images.go b/server/internal/orchestrator/swarm/service_images.go index f03f6efc..cbb43971 100644 --- a/server/internal/orchestrator/swarm/service_images.go +++ b/server/internal/orchestrator/swarm/service_images.go @@ -23,10 +23,7 @@ func NewServiceVersions(cfg config.Config) *ServiceVersions { } // MCP service versions - // TODO: there is no "1.0.0" image yet - the latest is something like "1.0.0-beta3" - versions.addServiceImage("mcp", "1.0.0", &ServiceImages{ - Image: serviceImageTag(cfg, "postgres-mcp:1.0.0"), - }) + // TODO: Register semver versions when official releases are published. versions.addServiceImage("mcp", "latest", &ServiceImages{ Image: serviceImageTag(cfg, "postgres-mcp:latest"), }) @@ -84,6 +81,9 @@ func serviceImageTag(cfg config.Config, imageRef string) string { } } - // Prepend repository host + // Prepend repository host if configured + if cfg.DockerSwarm.ImageRepositoryHost == "" { + return imageRef + } return fmt.Sprintf("%s/%s", cfg.DockerSwarm.ImageRepositoryHost, imageRef) } diff --git a/server/internal/orchestrator/swarm/service_images_test.go b/server/internal/orchestrator/swarm/service_images_test.go index 27483f0e..7185cea9 100644 --- a/server/internal/orchestrator/swarm/service_images_test.go +++ b/server/internal/orchestrator/swarm/service_images_test.go @@ -22,15 +22,22 @@ func TestGetServiceImage(t *testing.T) { wantErr bool }{ { - name: "valid mcp 1.0.0", + name: "valid mcp latest", serviceType: "mcp", - version: "1.0.0", - want: "ghcr.io/pgedge/postgres-mcp:1.0.0", + version: "latest", + want: "ghcr.io/pgedge/postgres-mcp:latest", wantErr: false, }, { name: "unsupported service type", serviceType: "unknown", + version: "latest", + want: "", + wantErr: true, + }, + { + name: "unregistered version", + serviceType: "mcp", version: "1.0.0", want: "", wantErr: true, @@ -75,7 +82,7 @@ func TestSupportedServiceVersions(t *testing.T) { { name: "mcp service has versions", serviceType: "mcp", - wantLen: 2, // "1.0.0" and "latest" + wantLen: 1, // "latest" wantErr: false, }, { @@ -108,10 +115,16 @@ func TestServiceImageTag(t *testing.T) { want string }{ { - name: "image without registry", - imageRef: "pgedge/postgres-mcp:1.0.0", + name: "bare image name", + imageRef: "postgres-mcp:latest", repoHost: "ghcr.io/pgedge", - want: "ghcr.io/pgedge/pgedge/postgres-mcp:1.0.0", + want: "ghcr.io/pgedge/postgres-mcp:latest", + }, + { + name: "empty repository host", + imageRef: "postgres-mcp:latest", + repoHost: "", + want: "postgres-mcp:latest", }, { name: "image with registry", diff --git a/server/internal/orchestrator/swarm/service_spec_test.go b/server/internal/orchestrator/swarm/service_spec_test.go index 469f1284..924fffe6 100644 --- a/server/internal/orchestrator/swarm/service_spec_test.go +++ b/server/internal/orchestrator/swarm/service_spec_test.go @@ -47,7 +47,7 @@ func TestServiceContainerSpec(t *testing.T) { Hostname: "mcp-server-host1", CohortMemberID: "swarm-node-123", ServiceImages: &ServiceImages{ - Image: "ghcr.io/pgedge/postgres-mcp:1.0.0", + Image: "ghcr.io/pgedge/postgres-mcp:latest", }, Credentials: &database.ServiceUser{ Username: "svc_db1mcp", @@ -175,7 +175,7 @@ func TestServiceContainerSpec(t *testing.T) { Hostname: "mcp-server-host1", CohortMemberID: "swarm-node-123", ServiceImages: &ServiceImages{ - Image: "ghcr.io/pgedge/postgres-mcp:1.0.0", + Image: "ghcr.io/pgedge/postgres-mcp:latest", }, DatabaseNetworkID: "db1-database", DatabaseHost: "postgres-instance-1", @@ -220,7 +220,7 @@ func TestServiceContainerSpec(t *testing.T) { Hostname: "mcp-server-host1", CohortMemberID: "swarm-node-123", ServiceImages: &ServiceImages{ - Image: "ghcr.io/pgedge/postgres-mcp:1.0.0", + Image: "ghcr.io/pgedge/postgres-mcp:latest", }, DatabaseNetworkID: "db1-database", DatabaseHost: "postgres-instance-1", @@ -268,7 +268,7 @@ func TestServiceContainerSpec(t *testing.T) { Hostname: "mcp-server-host1", CohortMemberID: "swarm-node-123", ServiceImages: &ServiceImages{ - Image: "ghcr.io/pgedge/postgres-mcp:1.0.0", + Image: "ghcr.io/pgedge/postgres-mcp:latest", }, DatabaseNetworkID: "db1-database", DatabaseHost: "postgres-instance-1", @@ -316,7 +316,7 @@ func TestServiceContainerSpec(t *testing.T) { Hostname: "mcp-server-host1", CohortMemberID: "swarm-node-123", ServiceImages: &ServiceImages{ - Image: "ghcr.io/pgedge/postgres-mcp:1.0.0", + Image: "ghcr.io/pgedge/postgres-mcp:latest", }, Credentials: nil, // No credentials DatabaseNetworkID: "db1-database", From 5aa99306a3c561f0f995fa534be02e830101e484 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Mon, 9 Feb 2026 18:33:12 -0500 Subject: [PATCH 05/12] GetDatabase treats ErrDatabaseNotFound as a non-fatal transient error to allow retries --- server/internal/orchestrator/swarm/service_user_role.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/internal/orchestrator/swarm/service_user_role.go b/server/internal/orchestrator/swarm/service_user_role.go index 0fdd5cc1..b06609ad 100644 --- a/server/internal/orchestrator/swarm/service_user_role.go +++ b/server/internal/orchestrator/swarm/service_user_role.go @@ -2,6 +2,7 @@ package swarm import ( "context" + "errors" "fmt" "strings" "time" @@ -107,9 +108,11 @@ func (r *ServiceUserRole) Delete(ctx context.Context, rc *resource.Context) erro db, err := dbSvc.GetDatabase(ctx, r.DatabaseID) if err != nil { - // Database might already be deleted - this is not an error - logger.Info().Msg("database not found, skipping user deletion") - return nil + if errors.Is(err, database.ErrDatabaseNotFound) { + logger.Info().Msg("database not found, skipping user deletion") + return nil + } + return fmt.Errorf("failed to get database: %w", err) } if len(db.Instances) == 0 { From 31dcfad32a06b93a1515563d41054d93b3606416 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Thu, 12 Feb 2026 16:41:29 -0500 Subject: [PATCH 06/12] fix: address PR feedback for service provisioning - Remove container_port from required fields in ServicePort API type - Sanitize hyphens to underscores in generated Postgres role names - Switch MustInvoke to Invoke in docker provider for consistency - Use globally unique service name for container hostname to avoid DNS collisions on shared bridge network --- api/apiv1/design/instance.go | 2 +- api/apiv1/gen/control_plane/service.go | 6 +-- api/apiv1/gen/control_plane/views/view.go | 3 -- .../control_plane/client/encode_decode.go | 2 +- .../control_plane/server/encode_decode.go | 2 +- .../gen/http/control_plane/server/types.go | 2 +- api/apiv1/gen/http/openapi.json | 3 +- api/apiv1/gen/http/openapi.yaml | 1 - api/apiv1/gen/http/openapi3.json | 3 +- api/apiv1/gen/http/openapi3.yaml | 1 - docs/development/service-credentials.md | 6 +-- server/internal/api/apiv1/convert.go | 2 +- server/internal/database/service_instance.go | 11 +++-- .../database/service_instance_test.go | 45 +++---------------- server/internal/docker/provide.go | 5 ++- .../internal/workflows/provision_services.go | 2 +- 16 files changed, 30 insertions(+), 66 deletions(-) diff --git a/api/apiv1/design/instance.go b/api/apiv1/design/instance.go index 825d699a..6bed1699 100644 --- a/api/apiv1/design/instance.go +++ b/api/apiv1/design/instance.go @@ -207,7 +207,7 @@ var PortMapping = g.Type("PortMapping", func() { g.Example(8080) }) - g.Required("name", "container_port") + g.Required("name") }) var HealthCheckResult = g.Type("HealthCheckResult", func() { diff --git a/api/apiv1/gen/control_plane/service.go b/api/apiv1/gen/control_plane/service.go index 22524854..f4dafacb 100644 --- a/api/apiv1/gen/control_plane/service.go +++ b/api/apiv1/gen/control_plane/service.go @@ -767,7 +767,7 @@ type PortMapping struct { // The name of the port (e.g., 'http', 'web-client'). Name string // The port number inside the container. - ContainerPort int + ContainerPort *int // The port number on the host (if port-forwarded). HostPort *int } @@ -1703,7 +1703,7 @@ func transformControlplaneviewsPortMappingViewToPortMapping(v *controlplaneviews } res := &PortMapping{ Name: *v.Name, - ContainerPort: *v.ContainerPort, + ContainerPort: v.ContainerPort, HostPort: v.HostPort, } @@ -2206,7 +2206,7 @@ func transformPortMappingToControlplaneviewsPortMappingView(v *PortMapping) *con } res := &controlplaneviews.PortMappingView{ Name: &v.Name, - ContainerPort: &v.ContainerPort, + ContainerPort: v.ContainerPort, HostPort: v.HostPort, } diff --git a/api/apiv1/gen/control_plane/views/view.go b/api/apiv1/gen/control_plane/views/view.go index 1af649a9..430a2a73 100644 --- a/api/apiv1/gen/control_plane/views/view.go +++ b/api/apiv1/gen/control_plane/views/view.go @@ -1104,9 +1104,6 @@ func ValidatePortMappingView(result *PortMappingView) (err error) { if result.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "result")) } - if result.ContainerPort == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("container_port", "result")) - } if result.ContainerPort != nil { if *result.ContainerPort < 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("result.container_port", *result.ContainerPort, 1, true)) diff --git a/api/apiv1/gen/http/control_plane/client/encode_decode.go b/api/apiv1/gen/http/control_plane/client/encode_decode.go index 0197ff67..055fb394 100644 --- a/api/apiv1/gen/http/control_plane/client/encode_decode.go +++ b/api/apiv1/gen/http/control_plane/client/encode_decode.go @@ -5836,7 +5836,7 @@ func unmarshalPortMappingResponseBodyToControlplanePortMapping(v *PortMappingRes } res := &controlplane.PortMapping{ Name: *v.Name, - ContainerPort: *v.ContainerPort, + ContainerPort: v.ContainerPort, HostPort: v.HostPort, } diff --git a/api/apiv1/gen/http/control_plane/server/encode_decode.go b/api/apiv1/gen/http/control_plane/server/encode_decode.go index e529aced..c11dce0d 100644 --- a/api/apiv1/gen/http/control_plane/server/encode_decode.go +++ b/api/apiv1/gen/http/control_plane/server/encode_decode.go @@ -4798,7 +4798,7 @@ func marshalControlplaneviewsPortMappingViewToPortMappingResponseBody(v *control } res := &PortMappingResponseBody{ Name: *v.Name, - ContainerPort: *v.ContainerPort, + ContainerPort: v.ContainerPort, HostPort: v.HostPort, } diff --git a/api/apiv1/gen/http/control_plane/server/types.go b/api/apiv1/gen/http/control_plane/server/types.go index b7d0b338..9e1d280b 100644 --- a/api/apiv1/gen/http/control_plane/server/types.go +++ b/api/apiv1/gen/http/control_plane/server/types.go @@ -1900,7 +1900,7 @@ type PortMappingResponseBody struct { // The name of the port (e.g., 'http', 'web-client'). Name string `form:"name" json:"name" xml:"name"` // The port number inside the container. - ContainerPort int `form:"container_port" json:"container_port" xml:"container_port"` + ContainerPort *int `form:"container_port,omitempty" json:"container_port,omitempty" xml:"container_port,omitempty"` // The port number on the host (if port-forwarded). HostPort *int `form:"host_port,omitempty" json:"host_port,omitempty" xml:"host_port,omitempty"` } diff --git a/api/apiv1/gen/http/openapi.json b/api/apiv1/gen/http/openapi.json index 446862cc..1db95624 100644 --- a/api/apiv1/gen/http/openapi.json +++ b/api/apiv1/gen/http/openapi.json @@ -7003,8 +7003,7 @@ "name": "web-client" }, "required": [ - "name", - "container_port" + "name" ] }, "RemoveHostResponse": { diff --git a/api/apiv1/gen/http/openapi.yaml b/api/apiv1/gen/http/openapi.yaml index ff7bfda2..536ab466 100644 --- a/api/apiv1/gen/http/openapi.yaml +++ b/api/apiv1/gen/http/openapi.yaml @@ -4999,7 +4999,6 @@ definitions: name: web-client required: - name - - container_port RemoveHostResponse: title: RemoveHostResponse type: object diff --git a/api/apiv1/gen/http/openapi3.json b/api/apiv1/gen/http/openapi3.json index a2c4b5e2..a7426f74 100644 --- a/api/apiv1/gen/http/openapi3.json +++ b/api/apiv1/gen/http/openapi3.json @@ -15538,8 +15538,7 @@ "name": "web-client" }, "required": [ - "name", - "container_port" + "name" ] }, "RemoveHostResponse": { diff --git a/api/apiv1/gen/http/openapi3.yaml b/api/apiv1/gen/http/openapi3.yaml index 65918e4b..25a6083b 100644 --- a/api/apiv1/gen/http/openapi3.yaml +++ b/api/apiv1/gen/http/openapi3.yaml @@ -10959,7 +10959,6 @@ components: name: web-client required: - name - - container_port RemoveHostResponse: type: object properties: diff --git a/docs/development/service-credentials.md b/docs/development/service-credentials.md index 781766f3..1f78fff6 100644 --- a/docs/development/service-credentials.md +++ b/docs/development/service-credentials.md @@ -38,7 +38,7 @@ Format: svc_{service_id}_{host_id} Example: Service ID: "mcp-server" Host ID: "host1" - Generated Username: "svc_mcp-server_host1" + Generated Username: "svc_mcp_server_host1" ``` The username format provides the following benefits: @@ -113,7 +113,7 @@ In the following example, the credentials appear within the service instance rec { "service_instance_id": "...", "credentials": { - "username": "svc_mcp-server_host1", + "username": "svc_mcp_server_host1", "password": "", "role": "pgedge_application_read_only" } @@ -129,7 +129,7 @@ The system injects credentials as environment variables into service containers In the following example, the container receives standard PostgreSQL connection variables: ```bash -PGUSER=svc_mcp-server_host1 +PGUSER=svc_mcp_server_host1 PGPASSWORD=<44-char-base64url-password> PGHOST=postgres-instance-hostname PGPORT=5432 diff --git a/server/internal/api/apiv1/convert.go b/server/internal/api/apiv1/convert.go index e1fd249f..9d50ada7 100644 --- a/server/internal/api/apiv1/convert.go +++ b/server/internal/api/apiv1/convert.go @@ -266,7 +266,7 @@ func databaseSpecToAPI(d *database.Spec) *api.DatabaseSpec { func portMappingToAPI(pm database.PortMapping) *api.PortMapping { return &api.PortMapping{ Name: pm.Name, - ContainerPort: pm.ContainerPort, + ContainerPort: &pm.ContainerPort, HostPort: pm.HostPort, } } diff --git a/server/internal/database/service_instance.go b/server/internal/database/service_instance.go index 6bf941e3..3e7db636 100644 --- a/server/internal/database/service_instance.go +++ b/server/internal/database/service_instance.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "strings" "time" ) @@ -124,6 +125,10 @@ type ServiceUser struct { // Short name format: svc_{service_id}_{host_id} // Long name format: svc_{first 50 chars of service_id_host_id}_{8-hex-hash} func GenerateServiceUsername(serviceID, hostID string) string { + // Sanitize hyphens to underscores for PostgreSQL compatibility. + // Hyphens in identifiers require double-quoting in SQL. + serviceID = strings.ReplaceAll(serviceID, "-", "_") + hostID = strings.ReplaceAll(hostID, "-", "_") username := fmt.Sprintf("svc_%s_%s", serviceID, hostID) if len(username) <= 63 { @@ -155,12 +160,6 @@ func GenerateServiceName(serviceType, databaseID, serviceID, hostID string) stri return fmt.Sprintf("%s-%s-%s-%s", serviceType, databaseID, serviceID, hostID) } -// GenerateServiceHostname creates a container hostname for a service instance. -// Format: {service_id}-{host_id} -func GenerateServiceHostname(serviceID, hostID string) string { - return fmt.Sprintf("%s-%s", serviceID, hostID) -} - // GenerateDatabaseNetworkID creates the overlay network ID for a database. // Format: {database_id} func GenerateDatabaseNetworkID(databaseID string) string { diff --git a/server/internal/database/service_instance_test.go b/server/internal/database/service_instance_test.go index b5c2a43e..039d9b0b 100644 --- a/server/internal/database/service_instance_test.go +++ b/server/internal/database/service_instance_test.go @@ -15,25 +15,25 @@ func TestGenerateServiceUsername(t *testing.T) { name: "standard service instance", serviceID: "mcp-server", hostID: "host1", - want: "svc_mcp-server_host1", + want: "svc_mcp_server_host1", }, { name: "multiple services on same database - service 1", serviceID: "appmcp-1", hostID: "host1", - want: "svc_appmcp-1_host1", + want: "svc_appmcp_1_host1", }, { name: "multiple services on same database - service 2", serviceID: "appmcp-2", hostID: "host1", - want: "svc_appmcp-2_host1", + want: "svc_appmcp_2_host1", }, { name: "service with multi-part service ID", serviceID: "my-mcp-service", hostID: "host2", - want: "svc_my-mcp-service_host2", + want: "svc_my_mcp_service_host2", }, { name: "simple service and host IDs", @@ -45,19 +45,19 @@ func TestGenerateServiceUsername(t *testing.T) { name: "long service ID uses hash suffix", serviceID: "very-long-service-name-that-exceeds-postgres-limit-significantly", hostID: "host1", - want: "svc_very-long-service-name-that-exceeds-postgres-limit_175de8cf", + want: "svc_very_long_service_name_that_exceeds_postgres_limit_27b9b83d", }, { name: "long names with shared prefix produce different usernames (case A)", serviceID: "very-long-service-name-that-exceeds-postgres-limit-AAA", hostID: "host1", - want: "svc_very-long-service-name-that-exceeds-postgres-limit_860c8613", + want: "svc_very_long_service_name_that_exceeds_postgres_limit_1fe3f2fe", }, { name: "long names with shared prefix produce different usernames (case B)", serviceID: "very-long-service-name-that-exceeds-postgres-limit-BBB", hostID: "host1", - want: "svc_very-long-service-name-that-exceeds-postgres-limit_c9cb0bb2", + want: "svc_very_long_service_name_that_exceeds_postgres_limit_abca469b", }, } @@ -152,37 +152,6 @@ func TestGenerateServiceName(t *testing.T) { } } -func TestGenerateServiceHostname(t *testing.T) { - tests := []struct { - name string - serviceID string - hostID string - want string - }{ - { - name: "standard service instance", - serviceID: "mcp-server", - hostID: "host1", - want: "mcp-server-host1", - }, - { - name: "simple identifiers", - serviceID: "svc", - hostID: "h1", - want: "svc-h1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := GenerateServiceHostname(tt.serviceID, tt.hostID) - if got != tt.want { - t.Errorf("GenerateServiceHostname() = %v, want %v", got, tt.want) - } - }) - } -} - func TestGenerateDatabaseNetworkID(t *testing.T) { tests := []struct { name string diff --git a/server/internal/docker/provide.go b/server/internal/docker/provide.go index 3c4dd85c..b1b34668 100644 --- a/server/internal/docker/provide.go +++ b/server/internal/docker/provide.go @@ -7,7 +7,10 @@ import ( func Provide(i *do.Injector) { do.Provide(i, func(i *do.Injector) (*Docker, error) { - logger := do.MustInvoke[zerolog.Logger](i) + logger, err := do.Invoke[zerolog.Logger](i) + if err != nil { + return nil, err + } cli, err := NewDocker(logger) if err != nil { return nil, err diff --git a/server/internal/workflows/provision_services.go b/server/internal/workflows/provision_services.go index a83f706f..109bf3cd 100644 --- a/server/internal/workflows/provision_services.go +++ b/server/internal/workflows/provision_services.go @@ -224,7 +224,7 @@ func (w *Workflows) ProvisionServices(ctx workflow.Context, input *ProvisionServ DatabaseName: input.Spec.DatabaseName, HostID: hostID, ServiceName: database.GenerateServiceName(serviceSpec.ServiceType, input.Spec.DatabaseID, serviceSpec.ServiceID, hostID), - Hostname: database.GenerateServiceHostname(serviceSpec.ServiceID, hostID), + Hostname: database.GenerateServiceName(serviceSpec.ServiceType, input.Spec.DatabaseID, serviceSpec.ServiceID, hostID), Credentials: createUserOutput.Credentials, DatabaseNetworkID: database.GenerateDatabaseNetworkID(input.Spec.DatabaseID), DatabaseHost: instanceHostname, From d7d8d2c635c56019d1801b8930af85ed20775017 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Thu, 12 Feb 2026 17:27:04 -0500 Subject: [PATCH 07/12] fix: e2e compilation error --- e2e/service_provisioning_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/service_provisioning_test.go b/e2e/service_provisioning_test.go index 146be39f..fe711a68 100644 --- a/e2e/service_provisioning_test.go +++ b/e2e/service_provisioning_test.go @@ -125,13 +125,13 @@ func TestProvisionMCPService(t *testing.T) { if len(serviceInstance.Status.Ports) > 0 { t.Logf("Service has %d ports configured", len(serviceInstance.Status.Ports)) for _, port := range serviceInstance.Status.Ports { - t.Logf(" - %s: container_port=%d", port.Name, port.ContainerPort) + t.Logf(" - %s: container_port=%v", port.Name, port.ContainerPort) } // Verify HTTP port (8080) is exposed foundHTTPPort := false for _, port := range serviceInstance.Status.Ports { - if port.Name == "http" && port.ContainerPort == 8080 { + if port.Name == "http" && port.ContainerPort != nil && *port.ContainerPort == 8080 { foundHTTPPort = true break } From d823909ee272ea18852e22a961e3a7f5934c34a7 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Fri, 13 Feb 2026 13:08:24 -0500 Subject: [PATCH 08/12] fix: address version compatibility between PG/Spock and services Keep independent versioning for PG/Spock images and service images, but introduce optional version constraints so we can enforce supported combinations of images know to work together. --- server/internal/database/service_instance.go | 3 + server/internal/host/versions.go | 40 ++++ server/internal/host/versions_test.go | 160 +++++++++++++++ .../orchestrator/swarm/orchestrator.go | 16 +- .../orchestrator/swarm/service_images.go | 65 ++++-- .../orchestrator/swarm/service_images_test.go | 186 +++++++++++++++++- .../swarm/service_instance_spec.go | 4 +- .../orchestrator/swarm/service_spec.go | 6 +- .../orchestrator/swarm/service_spec_test.go | 24 +-- .../internal/workflows/provision_services.go | 10 +- 10 files changed, 470 insertions(+), 44 deletions(-) diff --git a/server/internal/database/service_instance.go b/server/internal/database/service_instance.go index 3e7db636..f38deb25 100644 --- a/server/internal/database/service_instance.go +++ b/server/internal/database/service_instance.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "time" + + "github.com/pgEdge/control-plane/server/internal/host" ) type ServiceInstanceState string @@ -170,6 +172,7 @@ func GenerateDatabaseNetworkID(databaseID string) string { type ServiceInstanceSpec struct { ServiceInstanceID string ServiceSpec *ServiceSpec + PgEdgeVersion *host.PgEdgeVersion // Database version, used for compatibility validation DatabaseID string DatabaseName string HostID string diff --git a/server/internal/host/versions.go b/server/internal/host/versions.go index f2009337..21c61617 100644 --- a/server/internal/host/versions.go +++ b/server/internal/host/versions.go @@ -13,6 +13,38 @@ import ( "github.com/pgEdge/control-plane/server/internal/ds" ) +// VersionConstraint defines an optional minimum and/or maximum version bound. +// A nil Min or Max means no restriction on that end of the range. +type VersionConstraint struct { + Min *Version `json:"min,omitempty"` + Max *Version `json:"max,omitempty"` +} + +// IsSatisfied returns true if v falls within the constraint's bounds. +func (c *VersionConstraint) IsSatisfied(v *Version) bool { + if c.Min != nil && c.Min.Compare(v) > 0 { + return false + } + if c.Max != nil && c.Max.Compare(v) < 0 { + return false + } + return true +} + +func (c *VersionConstraint) String() string { + var parts []string + if c.Min != nil { + parts = append(parts, fmt.Sprintf(">= %s", c.Min)) + } + if c.Max != nil { + parts = append(parts, fmt.Sprintf("<= %s", c.Max)) + } + if len(parts) == 0 { + return "any" + } + return strings.Join(parts, " and ") +} + var _ encoding.TextMarshaler = (*Version)(nil) var _ encoding.TextUnmarshaler = (*Version)(nil) @@ -88,6 +120,14 @@ func (v *Version) Compare(other *Version) int { var semverRegexp = regexp.MustCompile(`^\d+(.\d+){0,2}$`) +func MustParseVersion(s string) *Version { + v, err := ParseVersion(s) + if err != nil { + panic(err) + } + return v +} + func ParseVersion(s string) (*Version, error) { if !semverRegexp.MatchString(s) { return nil, fmt.Errorf("invalid version format: %q", s) diff --git a/server/internal/host/versions_test.go b/server/internal/host/versions_test.go index 11581570..ff26ede9 100644 --- a/server/internal/host/versions_test.go +++ b/server/internal/host/versions_test.go @@ -10,6 +10,29 @@ import ( "github.com/stretchr/testify/require" ) +func TestMustParseVersion(t *testing.T) { + t.Run("valid version", func(t *testing.T) { + v := host.MustParseVersion("17.6") + assert.Equal(t, &host.Version{Components: []uint64{17, 6}}, v) + }) + + t.Run("valid semver", func(t *testing.T) { + v := host.MustParseVersion("4.0.0") + assert.Equal(t, &host.Version{Components: []uint64{4, 0, 0}}, v) + }) + + t.Run("single component", func(t *testing.T) { + v := host.MustParseVersion("14") + assert.Equal(t, &host.Version{Components: []uint64{14}}, v) + }) + + t.Run("invalid version panics", func(t *testing.T) { + assert.Panics(t, func() { + host.MustParseVersion("invalid") + }) + }) +} + func TestParseVersion(t *testing.T) { for _, tc := range []struct { input string @@ -294,6 +317,143 @@ func TestPgEdgeVersion(t *testing.T) { }) } +func TestVersionConstraint_IsSatisfied(t *testing.T) { + v := func(s string) *host.Version { + v, err := host.ParseVersion(s) + require.NoError(t, err) + return v + } + + for _, tc := range []struct { + name string + constraint *host.VersionConstraint + version *host.Version + expected bool + }{ + { + name: "nil min and max is always satisfied", + constraint: &host.VersionConstraint{}, + version: v("5.0.0"), + expected: true, + }, + { + name: "min only - satisfied", + constraint: &host.VersionConstraint{Min: v("16")}, + version: v("17"), + expected: true, + }, + { + name: "min only - exactly at min", + constraint: &host.VersionConstraint{Min: v("17")}, + version: v("17"), + expected: true, + }, + { + name: "min only - below min", + constraint: &host.VersionConstraint{Min: v("17")}, + version: v("16"), + expected: false, + }, + { + name: "max only - satisfied", + constraint: &host.VersionConstraint{Max: v("18")}, + version: v("17"), + expected: true, + }, + { + name: "max only - exactly at max", + constraint: &host.VersionConstraint{Max: v("17")}, + version: v("17"), + expected: true, + }, + { + name: "max only - above max", + constraint: &host.VersionConstraint{Max: v("17")}, + version: v("18"), + expected: false, + }, + { + name: "range - within bounds", + constraint: &host.VersionConstraint{Min: v("16"), Max: v("18")}, + version: v("17"), + expected: true, + }, + { + name: "range - at min boundary", + constraint: &host.VersionConstraint{Min: v("16"), Max: v("18")}, + version: v("16"), + expected: true, + }, + { + name: "range - at max boundary", + constraint: &host.VersionConstraint{Min: v("16"), Max: v("18")}, + version: v("18"), + expected: true, + }, + { + name: "range - below min", + constraint: &host.VersionConstraint{Min: v("16"), Max: v("18")}, + version: v("15"), + expected: false, + }, + { + name: "range - above max", + constraint: &host.VersionConstraint{Min: v("16"), Max: v("18")}, + version: v("19"), + expected: false, + }, + { + name: "semver min and max", + constraint: &host.VersionConstraint{Min: v("4.0.0"), Max: v("5.0.0")}, + version: v("4.10.0"), + expected: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, tc.constraint.IsSatisfied(tc.version)) + }) + } +} + +func TestVersionConstraint_String(t *testing.T) { + v := func(s string) *host.Version { + v, err := host.ParseVersion(s) + require.NoError(t, err) + return v + } + + for _, tc := range []struct { + name string + constraint *host.VersionConstraint + expected string + }{ + { + name: "no constraints", + constraint: &host.VersionConstraint{}, + expected: "any", + }, + { + name: "min only", + constraint: &host.VersionConstraint{Min: v("16")}, + expected: ">= 16", + }, + { + name: "max only", + constraint: &host.VersionConstraint{Max: v("18")}, + expected: "<= 18", + }, + { + name: "min and max", + constraint: &host.VersionConstraint{Min: v("16"), Max: v("18")}, + expected: ">= 16 and <= 18", + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, tc.constraint.String()) + }) + } +} + func TestGreatestCommonDefaultVersion(t *testing.T) { for _, tc := range []struct { name string diff --git a/server/internal/orchestrator/swarm/orchestrator.go b/server/internal/orchestrator/swarm/orchestrator.go index e2d96bd8..c1e93331 100644 --- a/server/internal/orchestrator/swarm/orchestrator.go +++ b/server/internal/orchestrator/swarm/orchestrator.go @@ -385,14 +385,20 @@ func (o *Orchestrator) GenerateInstanceRestoreResources(spec *database.InstanceS func (o *Orchestrator) GenerateServiceInstanceResources(spec *database.ServiceInstanceSpec) (*database.ServiceInstanceResources, error) { // Get service image based on service type and version - imageStr, err := o.serviceVersions.GetServiceImage(spec.ServiceSpec.ServiceType, spec.ServiceSpec.Version) + serviceImage, err := o.serviceVersions.GetServiceImage(spec.ServiceSpec.ServiceType, spec.ServiceSpec.Version) if err != nil { return nil, fmt.Errorf("failed to get service image: %w", err) } - // Create ServiceImages struct - images := &ServiceImages{ - Image: imageStr, + // Validate compatibility with database version + if spec.PgEdgeVersion != nil { + if err := serviceImage.ValidateCompatibility( + spec.PgEdgeVersion.PostgresVersion, + spec.PgEdgeVersion.SpockVersion, + ); err != nil { + return nil, fmt.Errorf("service %q version %q is not compatible with this database: %w", + spec.ServiceSpec.ServiceType, spec.ServiceSpec.Version, err) + } } // Database network (shared with postgres instances) @@ -422,7 +428,7 @@ func (o *Orchestrator) GenerateServiceInstanceResources(spec *database.ServiceIn ServiceName: spec.ServiceName, Hostname: spec.Hostname, CohortMemberID: o.swarmNodeID, // Use orchestrator's swarm node ID (same as Postgres instances) - ServiceImages: images, + ServiceImage: serviceImage, Credentials: spec.Credentials, DatabaseNetworkID: databaseNetwork.Name, DatabaseHost: spec.DatabaseHost, diff --git a/server/internal/orchestrator/swarm/service_images.go b/server/internal/orchestrator/swarm/service_images.go index cbb43971..db171194 100644 --- a/server/internal/orchestrator/swarm/service_images.go +++ b/server/internal/orchestrator/swarm/service_images.go @@ -5,52 +5,89 @@ import ( "strings" "github.com/pgEdge/control-plane/server/internal/config" + "github.com/pgEdge/control-plane/server/internal/host" ) -type ServiceImages struct { - Image string +// ServiceImage describes a container image for a service type+version, along +// with optional version constraints that restrict which Postgres and Spock +// versions the service is compatible with. +type ServiceImage struct { + Tag string `json:"tag"` + PostgresConstraint *host.VersionConstraint `json:"postgres_constraint,omitempty"` + SpockConstraint *host.VersionConstraint `json:"spock_constraint,omitempty"` +} + +// ValidateCompatibility checks that the given Postgres and Spock versions +// satisfy this image's version constraints. Returns nil if compatible. +func (s *ServiceImage) ValidateCompatibility(postgres, spock *host.Version) error { + if s.PostgresConstraint != nil && !s.PostgresConstraint.IsSatisfied(postgres) { + return fmt.Errorf("postgres version %s does not satisfy constraint %s", + postgres, s.PostgresConstraint) + } + if s.SpockConstraint != nil && !s.SpockConstraint.IsSatisfied(spock) { + return fmt.Errorf("spock version %s does not satisfy constraint %s", + spock, s.SpockConstraint) + } + return nil } type ServiceVersions struct { cfg config.Config - images map[string]map[string]*ServiceImages + images map[string]map[string]*ServiceImage } func NewServiceVersions(cfg config.Config) *ServiceVersions { versions := &ServiceVersions{ cfg: cfg, - images: make(map[string]map[string]*ServiceImages), + images: make(map[string]map[string]*ServiceImage), } // MCP service versions // TODO: Register semver versions when official releases are published. - versions.addServiceImage("mcp", "latest", &ServiceImages{ - Image: serviceImageTag(cfg, "postgres-mcp:latest"), + versions.addServiceImage("mcp", "latest", &ServiceImage{ + Tag: serviceImageTag(cfg, "postgres-mcp:latest"), + // No constraints — MCP works with all PG/Spock versions. }) + // Example of a service image with version constraints (nil = no restriction): + // + // acme-service:1.0.0 requires PG 14-17 and Spock >= 4.0.0 + // + // versions.addServiceImage("acme", "1.0.0", &ServiceImage{ + // Tag: serviceImageTag(cfg, "acme-service:1.0.0"), + // PostgresConstraint: &host.VersionConstraint{ + // Min: host.MustParseVersion("14"), + // Max: host.MustParseVersion("17"), + // }, + // SpockConstraint: &host.VersionConstraint{ + // Min: host.MustParseVersion("4.0.0"), + // }, + // }) + return versions } -func (sv *ServiceVersions) addServiceImage(serviceType string, version string, images *ServiceImages) { +func (sv *ServiceVersions) addServiceImage(serviceType string, version string, image *ServiceImage) { if _, ok := sv.images[serviceType]; !ok { - sv.images[serviceType] = make(map[string]*ServiceImages) + sv.images[serviceType] = make(map[string]*ServiceImage) } - sv.images[serviceType][version] = images + sv.images[serviceType][version] = image } -func (sv *ServiceVersions) GetServiceImage(serviceType string, version string) (string, error) { +// GetServiceImage returns the full ServiceImage for the given service type and version. +func (sv *ServiceVersions) GetServiceImage(serviceType string, version string) (*ServiceImage, error) { versionMap, ok := sv.images[serviceType] if !ok { - return "", fmt.Errorf("unsupported service type %q", serviceType) + return nil, fmt.Errorf("unsupported service type %q", serviceType) } - images, ok := versionMap[version] + image, ok := versionMap[version] if !ok { - return "", fmt.Errorf("unsupported version %q for service type %q", version, serviceType) + return nil, fmt.Errorf("unsupported version %q for service type %q", version, serviceType) } - return images.Image, nil + return image, nil } func (sv *ServiceVersions) SupportedServiceVersions(serviceType string) ([]string, error) { diff --git a/server/internal/orchestrator/swarm/service_images_test.go b/server/internal/orchestrator/swarm/service_images_test.go index 7185cea9..c8d0b25b 100644 --- a/server/internal/orchestrator/swarm/service_images_test.go +++ b/server/internal/orchestrator/swarm/service_images_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/pgEdge/control-plane/server/internal/config" + "github.com/pgEdge/control-plane/server/internal/host" ) func TestGetServiceImage(t *testing.T) { @@ -18,35 +19,42 @@ func TestGetServiceImage(t *testing.T) { name string serviceType string version string - want string + wantTag string wantErr bool }{ { name: "valid mcp latest", serviceType: "mcp", version: "latest", - want: "ghcr.io/pgedge/postgres-mcp:latest", + wantTag: "ghcr.io/pgedge/postgres-mcp:latest", + wantErr: false, + }, + { + name: "valid pganalyze 1.0.0", + serviceType: "pganalyze", + version: "1.0.0", + wantTag: "ghcr.io/pgedge/pganalyze-collector:1.0.0", wantErr: false, }, { name: "unsupported service type", serviceType: "unknown", version: "latest", - want: "", + wantTag: "", wantErr: true, }, { name: "unregistered version", serviceType: "mcp", version: "1.0.0", - want: "", + wantTag: "", wantErr: true, }, { name: "unsupported version", serviceType: "mcp", version: "99.99.99", - want: "", + wantTag: "", wantErr: true, }, } @@ -58,8 +66,14 @@ func TestGetServiceImage(t *testing.T) { t.Errorf("GetServiceImage() error = %v, wantErr %v", err, tt.wantErr) return } - if got != tt.want { - t.Errorf("GetServiceImage() = %v, want %v", got, tt.want) + if tt.wantErr { + if got != nil { + t.Errorf("GetServiceImage() = %v, want nil", got) + } + return + } + if got.Tag != tt.wantTag { + t.Errorf("GetServiceImage().Tag = %v, want %v", got.Tag, tt.wantTag) } }) } @@ -85,6 +99,12 @@ func TestSupportedServiceVersions(t *testing.T) { wantLen: 1, // "latest" wantErr: false, }, + { + name: "pganalyze service has versions", + serviceType: "pganalyze", + wantLen: 1, // "1.0.0" + wantErr: false, + }, { name: "unsupported service type", serviceType: "unknown", @@ -154,3 +174,155 @@ func TestServiceImageTag(t *testing.T) { }) } } + +func TestGetServiceImage_ConstraintsPopulated(t *testing.T) { + cfg := config.Config{ + DockerSwarm: config.DockerSwarm{ + ImageRepositoryHost: "ghcr.io/pgedge", + }, + } + sv := NewServiceVersions(cfg) + + t.Run("mcp has no constraints", func(t *testing.T) { + img, err := sv.GetServiceImage("mcp", "latest") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if img.PostgresConstraint != nil { + t.Error("expected nil PostgresConstraint for mcp") + } + if img.SpockConstraint != nil { + t.Error("expected nil SpockConstraint for mcp") + } + }) + + t.Run("pganalyze has constraints", func(t *testing.T) { + img, err := sv.GetServiceImage("pganalyze", "1.0.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if img.PostgresConstraint == nil { + t.Fatal("expected non-nil PostgresConstraint for pganalyze") + } + if img.PostgresConstraint.Min == nil || img.PostgresConstraint.Max == nil { + t.Fatal("expected both Min and Max on PostgresConstraint") + } + if img.SpockConstraint == nil { + t.Fatal("expected non-nil SpockConstraint for pganalyze") + } + if img.SpockConstraint.Min == nil { + t.Fatal("expected Min on SpockConstraint") + } + }) + + t.Run("pganalyze compatible with pg16 spock5", func(t *testing.T) { + img, _ := sv.GetServiceImage("pganalyze", "1.0.0") + err := img.ValidateCompatibility(mustVersion(t, "16"), mustVersion(t, "5.0.0")) + if err != nil { + t.Errorf("expected compatibility, got: %v", err) + } + }) + + t.Run("pganalyze incompatible with pg18", func(t *testing.T) { + img, _ := sv.GetServiceImage("pganalyze", "1.0.0") + err := img.ValidateCompatibility(mustVersion(t, "18"), mustVersion(t, "5.0.0")) + if err == nil { + t.Error("expected incompatibility error for pg18") + } + }) + + t.Run("pganalyze incompatible with pg13", func(t *testing.T) { + img, _ := sv.GetServiceImage("pganalyze", "1.0.0") + err := img.ValidateCompatibility(mustVersion(t, "13"), mustVersion(t, "5.0.0")) + if err == nil { + t.Error("expected incompatibility error for pg13") + } + }) + + t.Run("pganalyze incompatible with spock3", func(t *testing.T) { + img, _ := sv.GetServiceImage("pganalyze", "1.0.0") + err := img.ValidateCompatibility(mustVersion(t, "16"), mustVersion(t, "3.0.0")) + if err == nil { + t.Error("expected incompatibility error for spock 3.0.0") + } + }) +} + +func mustVersion(t *testing.T, s string) *host.Version { + t.Helper() + v, err := host.ParseVersion(s) + if err != nil { + t.Fatalf("failed to parse version %q: %v", s, err) + } + return v +} + +func TestValidateCompatibility(t *testing.T) { + tests := []struct { + name string + image *ServiceImage + postgres *host.Version + spock *host.Version + wantErr bool + }{ + { + name: "no constraints - always passes", + image: &ServiceImage{ + Tag: "test:latest", + }, + postgres: mustVersion(t, "17"), + spock: mustVersion(t, "5.0.0"), + wantErr: false, + }, + { + name: "postgres constraint satisfied", + image: &ServiceImage{ + Tag: "test:latest", + PostgresConstraint: &host.VersionConstraint{Min: mustVersion(t, "16")}, + }, + postgres: mustVersion(t, "17"), + spock: mustVersion(t, "5.0.0"), + wantErr: false, + }, + { + name: "postgres constraint not satisfied", + image: &ServiceImage{ + Tag: "test:latest", + PostgresConstraint: &host.VersionConstraint{Min: mustVersion(t, "18")}, + }, + postgres: mustVersion(t, "17"), + spock: mustVersion(t, "5.0.0"), + wantErr: true, + }, + { + name: "spock constraint not satisfied", + image: &ServiceImage{ + Tag: "test:latest", + SpockConstraint: &host.VersionConstraint{Max: mustVersion(t, "4.0.0")}, + }, + postgres: mustVersion(t, "17"), + spock: mustVersion(t, "5.0.0"), + wantErr: true, + }, + { + name: "both constraints satisfied", + image: &ServiceImage{ + Tag: "test:latest", + PostgresConstraint: &host.VersionConstraint{Min: mustVersion(t, "16"), Max: mustVersion(t, "18")}, + SpockConstraint: &host.VersionConstraint{Min: mustVersion(t, "4.0.0"), Max: mustVersion(t, "6.0.0")}, + }, + postgres: mustVersion(t, "17"), + spock: mustVersion(t, "5.0.0"), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.image.ValidateCompatibility(tt.postgres, tt.spock) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateCompatibility() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/server/internal/orchestrator/swarm/service_instance_spec.go b/server/internal/orchestrator/swarm/service_instance_spec.go index 6d23a923..69370e12 100644 --- a/server/internal/orchestrator/swarm/service_instance_spec.go +++ b/server/internal/orchestrator/swarm/service_instance_spec.go @@ -30,7 +30,7 @@ type ServiceInstanceSpecResource struct { ServiceName string `json:"service_name"` Hostname string `json:"hostname"` CohortMemberID string `json:"cohort_member_id"` - ServiceImages *ServiceImages `json:"service_images"` + ServiceImage *ServiceImage `json:"service_image"` Credentials *database.ServiceUser `json:"credentials"` DatabaseNetworkID string `json:"database_network_id"` DatabaseHost string `json:"database_host"` // Postgres instance hostname @@ -84,7 +84,7 @@ func (s *ServiceInstanceSpecResource) Refresh(ctx context.Context, rc *resource. ServiceName: s.ServiceName, Hostname: s.Hostname, CohortMemberID: s.CohortMemberID, - ServiceImages: s.ServiceImages, + ServiceImage: s.ServiceImage, Credentials: s.Credentials, DatabaseNetworkID: network.NetworkID, DatabaseHost: s.DatabaseHost, diff --git a/server/internal/orchestrator/swarm/service_spec.go b/server/internal/orchestrator/swarm/service_spec.go index 032871c6..c32f6ee6 100644 --- a/server/internal/orchestrator/swarm/service_spec.go +++ b/server/internal/orchestrator/swarm/service_spec.go @@ -21,7 +21,7 @@ type ServiceContainerSpecOptions struct { ServiceName string Hostname string CohortMemberID string - ServiceImages *ServiceImages + ServiceImage *ServiceImage Credentials *database.ServiceUser DatabaseNetworkID string // Database connection info @@ -61,8 +61,8 @@ func ServiceContainerSpec(opts *ServiceContainerSpecOptions) (swarm.ServiceSpec, // Build environment variables for database connection and LLM config env := buildServiceEnvVars(opts) - // Get container image (already resolved in ServiceImages) - image := opts.ServiceImages.Image + // Get container image (already resolved in ServiceImage) + image := opts.ServiceImage.Tag // Build port configuration (expose 8080 for HTTP API) ports := buildServicePortConfig(opts.Port) diff --git a/server/internal/orchestrator/swarm/service_spec_test.go b/server/internal/orchestrator/swarm/service_spec_test.go index 924fffe6..95260bd6 100644 --- a/server/internal/orchestrator/swarm/service_spec_test.go +++ b/server/internal/orchestrator/swarm/service_spec_test.go @@ -46,8 +46,8 @@ func TestServiceContainerSpec(t *testing.T) { ServiceName: "db1-mcp-server-host1", Hostname: "mcp-server-host1", CohortMemberID: "swarm-node-123", - ServiceImages: &ServiceImages{ - Image: "ghcr.io/pgedge/postgres-mcp:latest", + ServiceImage: &ServiceImage{ + Tag: "ghcr.io/pgedge/postgres-mcp:latest", }, Credentials: &database.ServiceUser{ Username: "svc_db1mcp", @@ -174,8 +174,8 @@ func TestServiceContainerSpec(t *testing.T) { ServiceName: "db1-mcp-server-host1", Hostname: "mcp-server-host1", CohortMemberID: "swarm-node-123", - ServiceImages: &ServiceImages{ - Image: "ghcr.io/pgedge/postgres-mcp:latest", + ServiceImage: &ServiceImage{ + Tag: "ghcr.io/pgedge/postgres-mcp:latest", }, DatabaseNetworkID: "db1-database", DatabaseHost: "postgres-instance-1", @@ -219,8 +219,8 @@ func TestServiceContainerSpec(t *testing.T) { ServiceName: "db1-mcp-server-host1", Hostname: "mcp-server-host1", CohortMemberID: "swarm-node-123", - ServiceImages: &ServiceImages{ - Image: "ghcr.io/pgedge/postgres-mcp:latest", + ServiceImage: &ServiceImage{ + Tag: "ghcr.io/pgedge/postgres-mcp:latest", }, DatabaseNetworkID: "db1-database", DatabaseHost: "postgres-instance-1", @@ -267,8 +267,8 @@ func TestServiceContainerSpec(t *testing.T) { ServiceName: "db1-mcp-server-host1", Hostname: "mcp-server-host1", CohortMemberID: "swarm-node-123", - ServiceImages: &ServiceImages{ - Image: "ghcr.io/pgedge/postgres-mcp:latest", + ServiceImage: &ServiceImage{ + Tag: "ghcr.io/pgedge/postgres-mcp:latest", }, DatabaseNetworkID: "db1-database", DatabaseHost: "postgres-instance-1", @@ -315,8 +315,8 @@ func TestServiceContainerSpec(t *testing.T) { ServiceName: "db1-mcp-server-host1", Hostname: "mcp-server-host1", CohortMemberID: "swarm-node-123", - ServiceImages: &ServiceImages{ - Image: "ghcr.io/pgedge/postgres-mcp:latest", + ServiceImage: &ServiceImage{ + Tag: "ghcr.io/pgedge/postgres-mcp:latest", }, Credentials: nil, // No credentials DatabaseNetworkID: "db1-database", @@ -371,8 +371,8 @@ func TestServiceContainerSpec(t *testing.T) { } // Check image - if got.TaskTemplate.ContainerSpec.Image != tt.opts.ServiceImages.Image { - t.Errorf("image = %v, want %v", got.TaskTemplate.ContainerSpec.Image, tt.opts.ServiceImages.Image) + if got.TaskTemplate.ContainerSpec.Image != tt.opts.ServiceImage.Tag { + t.Errorf("image = %v, want %v", got.TaskTemplate.ContainerSpec.Image, tt.opts.ServiceImage.Tag) } // Check service name diff --git a/server/internal/workflows/provision_services.go b/server/internal/workflows/provision_services.go index 109bf3cd..df99d3e7 100644 --- a/server/internal/workflows/provision_services.go +++ b/server/internal/workflows/provision_services.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/host" "github.com/pgEdge/control-plane/server/internal/monitor" "github.com/pgEdge/control-plane/server/internal/resource" "github.com/pgEdge/control-plane/server/internal/task" @@ -44,9 +45,15 @@ func (w *Workflows) ProvisionServices(ctx workflow.Context, input *ProvisionServ return &ProvisionServicesOutput{}, nil } + // Parse database version for service compatibility validation + pgEdgeVersion, err := host.NewPgEdgeVersion(input.Spec.PostgresVersion, input.Spec.SpockVersion) + if err != nil { + return nil, fmt.Errorf("failed to parse pgedge version: %w", err) + } + // Log task start start := workflow.Now(ctx) - err := w.logTaskEvent(ctx, + err = w.logTaskEvent(ctx, task.ScopeDatabase, input.Spec.DatabaseID, input.TaskID, @@ -220,6 +227,7 @@ func (w *Workflows) ProvisionServices(ctx workflow.Context, input *ProvisionServ serviceInstanceSpec := &database.ServiceInstanceSpec{ ServiceInstanceID: serviceInstanceID, ServiceSpec: serviceSpec, + PgEdgeVersion: pgEdgeVersion, DatabaseID: input.Spec.DatabaseID, DatabaseName: input.Spec.DatabaseName, HostID: hostID, From f43ef69c7cdc2b376b20493f9a940d8f522912ef Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Fri, 13 Feb 2026 13:33:59 -0500 Subject: [PATCH 09/12] fix: revert test cases that were added accidentally --- .../orchestrator/swarm/service_images_test.go | 63 ------------------- 1 file changed, 63 deletions(-) diff --git a/server/internal/orchestrator/swarm/service_images_test.go b/server/internal/orchestrator/swarm/service_images_test.go index c8d0b25b..17e35041 100644 --- a/server/internal/orchestrator/swarm/service_images_test.go +++ b/server/internal/orchestrator/swarm/service_images_test.go @@ -29,13 +29,6 @@ func TestGetServiceImage(t *testing.T) { wantTag: "ghcr.io/pgedge/postgres-mcp:latest", wantErr: false, }, - { - name: "valid pganalyze 1.0.0", - serviceType: "pganalyze", - version: "1.0.0", - wantTag: "ghcr.io/pgedge/pganalyze-collector:1.0.0", - wantErr: false, - }, { name: "unsupported service type", serviceType: "unknown", @@ -99,12 +92,6 @@ func TestSupportedServiceVersions(t *testing.T) { wantLen: 1, // "latest" wantErr: false, }, - { - name: "pganalyze service has versions", - serviceType: "pganalyze", - wantLen: 1, // "1.0.0" - wantErr: false, - }, { name: "unsupported service type", serviceType: "unknown", @@ -196,56 +183,6 @@ func TestGetServiceImage_ConstraintsPopulated(t *testing.T) { } }) - t.Run("pganalyze has constraints", func(t *testing.T) { - img, err := sv.GetServiceImage("pganalyze", "1.0.0") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if img.PostgresConstraint == nil { - t.Fatal("expected non-nil PostgresConstraint for pganalyze") - } - if img.PostgresConstraint.Min == nil || img.PostgresConstraint.Max == nil { - t.Fatal("expected both Min and Max on PostgresConstraint") - } - if img.SpockConstraint == nil { - t.Fatal("expected non-nil SpockConstraint for pganalyze") - } - if img.SpockConstraint.Min == nil { - t.Fatal("expected Min on SpockConstraint") - } - }) - - t.Run("pganalyze compatible with pg16 spock5", func(t *testing.T) { - img, _ := sv.GetServiceImage("pganalyze", "1.0.0") - err := img.ValidateCompatibility(mustVersion(t, "16"), mustVersion(t, "5.0.0")) - if err != nil { - t.Errorf("expected compatibility, got: %v", err) - } - }) - - t.Run("pganalyze incompatible with pg18", func(t *testing.T) { - img, _ := sv.GetServiceImage("pganalyze", "1.0.0") - err := img.ValidateCompatibility(mustVersion(t, "18"), mustVersion(t, "5.0.0")) - if err == nil { - t.Error("expected incompatibility error for pg18") - } - }) - - t.Run("pganalyze incompatible with pg13", func(t *testing.T) { - img, _ := sv.GetServiceImage("pganalyze", "1.0.0") - err := img.ValidateCompatibility(mustVersion(t, "13"), mustVersion(t, "5.0.0")) - if err == nil { - t.Error("expected incompatibility error for pg13") - } - }) - - t.Run("pganalyze incompatible with spock3", func(t *testing.T) { - img, _ := sv.GetServiceImage("pganalyze", "1.0.0") - err := img.ValidateCompatibility(mustVersion(t, "16"), mustVersion(t, "3.0.0")) - if err == nil { - t.Error("expected incompatibility error for spock 3.0.0") - } - }) } func mustVersion(t *testing.T, s string) *host.Version { From 7a4f53233c84f0c33ecfc6bed92c278357f58eb4 Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Fri, 13 Feb 2026 13:36:15 -0500 Subject: [PATCH 10/12] fix: remove redundant service monitor cleanup this is already cleaned up by ServiceInstanceMonitorResource.Delete() --- .../internal/orchestrator/swarm/service_instance.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/server/internal/orchestrator/swarm/service_instance.go b/server/internal/orchestrator/swarm/service_instance.go index d39a24cc..da01d0ab 100644 --- a/server/internal/orchestrator/swarm/service_instance.go +++ b/server/internal/orchestrator/swarm/service_instance.go @@ -13,7 +13,6 @@ import ( "github.com/pgEdge/control-plane/server/internal/database" "github.com/pgEdge/control-plane/server/internal/docker" - "github.com/pgEdge/control-plane/server/internal/monitor" "github.com/pgEdge/control-plane/server/internal/resource" "github.com/pgEdge/control-plane/server/internal/utils" ) @@ -206,18 +205,6 @@ func (s *ServiceInstanceResource) Delete(ctx context.Context, rc *resource.Conte } } - // Remove service instance monitor - // Note: This is best-effort cleanup. If the monitor doesn't exist or deletion fails, - // we still proceed with service instance deletion. Monitors will be cleaned up on restart. - if monitorSvc, err := do.Invoke[*monitor.Service](rc.Injector); err == nil { - if err := monitorSvc.DeleteServiceInstanceMonitor(ctx, s.ServiceInstanceID); err != nil { - if logger, logErr := do.Invoke[zerolog.Logger](rc.Injector); logErr == nil { - logger.Warn().Err(err).Str("service_instance_id", s.ServiceInstanceID).Msg("failed to delete service instance monitor during cleanup") - } - // Continue - not critical for deletion - } - } - // Remove service instance from etcd storage svc, err := do.Invoke[*database.Service](rc.Injector) if err != nil { From f040615166902163a01c78a610ef079e4fdf9bdd Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Fri, 13 Feb 2026 15:18:51 -0500 Subject: [PATCH 11/12] refactor: move service instance naming to swarm package with host ID hashing --- server/internal/database/service_instance.go | 8 ---- .../database/service_instance_test.go | 37 ------------------- .../orchestrator/swarm/orchestrator.go | 19 ++++++++-- .../internal/workflows/provision_services.go | 2 - 4 files changed, 16 insertions(+), 50 deletions(-) diff --git a/server/internal/database/service_instance.go b/server/internal/database/service_instance.go index f38deb25..0ef33026 100644 --- a/server/internal/database/service_instance.go +++ b/server/internal/database/service_instance.go @@ -156,12 +156,6 @@ func GenerateServiceInstanceID(databaseID, serviceID, hostID string) string { return fmt.Sprintf("%s-%s-%s", databaseID, serviceID, hostID) } -// GenerateServiceName creates a Docker Swarm service name for a service instance. -// Format: {service_type}-{database_id}-{service_id}-{host_id} -func GenerateServiceName(serviceType, databaseID, serviceID, hostID string) string { - return fmt.Sprintf("%s-%s-%s-%s", serviceType, databaseID, serviceID, hostID) -} - // GenerateDatabaseNetworkID creates the overlay network ID for a database. // Format: {database_id} func GenerateDatabaseNetworkID(databaseID string) string { @@ -176,8 +170,6 @@ type ServiceInstanceSpec struct { DatabaseID string DatabaseName string HostID string - ServiceName string - Hostname string CohortMemberID string Credentials *ServiceUser DatabaseNetworkID string diff --git a/server/internal/database/service_instance_test.go b/server/internal/database/service_instance_test.go index 039d9b0b..f5458588 100644 --- a/server/internal/database/service_instance_test.go +++ b/server/internal/database/service_instance_test.go @@ -115,43 +115,6 @@ func TestGenerateServiceInstanceID(t *testing.T) { } } -func TestGenerateServiceName(t *testing.T) { - tests := []struct { - name string - serviceType string - databaseID string - serviceID string - hostID string - want string - }{ - { - name: "mcp service", - serviceType: "mcp", - databaseID: "db1", - serviceID: "mcp-server", - hostID: "host1", - want: "mcp-db1-mcp-server-host1", - }, - { - name: "simple identifiers", - serviceType: "svc", - databaseID: "db", - serviceID: "s1", - hostID: "h1", - want: "svc-db-s1-h1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := GenerateServiceName(tt.serviceType, tt.databaseID, tt.serviceID, tt.hostID) - if got != tt.want { - t.Errorf("GenerateServiceName() = %v, want %v", got, tt.want) - } - }) - } -} - func TestGenerateDatabaseNetworkID(t *testing.T) { tests := []struct { name string diff --git a/server/internal/orchestrator/swarm/orchestrator.go b/server/internal/orchestrator/swarm/orchestrator.go index c1e93331..6aaba966 100644 --- a/server/internal/orchestrator/swarm/orchestrator.go +++ b/server/internal/orchestrator/swarm/orchestrator.go @@ -3,10 +3,12 @@ package swarm import ( "bytes" "context" + "crypto/sha1" "errors" "fmt" "io" "maps" + "math/big" "net/netip" "path/filepath" "slices" @@ -153,6 +155,16 @@ func (o *Orchestrator) GenerateInstanceResources(spec *database.InstanceSpec) (* return resources, nil } +// ServiceInstanceName generates a Docker Swarm service name for a service instance. +// It follows the same host ID hashing convention used for Postgres instance IDs +// (see database.InstanceIDFor), producing shorter, more readable names when host +// IDs are UUIDs. +func ServiceInstanceName(serviceType, databaseID, serviceID, hostID string) string { + hash := sha1.Sum([]byte(hostID)) + base36 := new(big.Int).SetBytes(hash[:]).Text(36) + return fmt.Sprintf("%s-%s-%s-%s", serviceType, databaseID, serviceID, base36[:8]) +} + func (o *Orchestrator) instanceResources(spec *database.InstanceSpec) (*database.InstanceResource, []resource.Resource, error) { images, err := o.versions.GetImages(spec.PgEdgeVersion) if err != nil { @@ -419,14 +431,15 @@ func (o *Orchestrator) GenerateServiceInstanceResources(spec *database.ServiceIn } // Service instance spec resource + serviceName := ServiceInstanceName(spec.ServiceSpec.ServiceType, spec.DatabaseID, spec.ServiceSpec.ServiceID, spec.HostID) serviceInstanceSpec := &ServiceInstanceSpecResource{ ServiceInstanceID: spec.ServiceInstanceID, ServiceSpec: spec.ServiceSpec, DatabaseID: spec.DatabaseID, DatabaseName: spec.DatabaseName, HostID: spec.HostID, - ServiceName: spec.ServiceName, - Hostname: spec.Hostname, + ServiceName: serviceName, + Hostname: serviceName, CohortMemberID: o.swarmNodeID, // Use orchestrator's swarm node ID (same as Postgres instances) ServiceImage: serviceImage, Credentials: spec.Credentials, @@ -440,7 +453,7 @@ func (o *Orchestrator) GenerateServiceInstanceResources(spec *database.ServiceIn serviceInstance := &ServiceInstanceResource{ ServiceInstanceID: spec.ServiceInstanceID, DatabaseID: spec.DatabaseID, - ServiceName: spec.ServiceName, + ServiceName: serviceName, } orchestratorResources := []resource.Resource{ diff --git a/server/internal/workflows/provision_services.go b/server/internal/workflows/provision_services.go index df99d3e7..36835b69 100644 --- a/server/internal/workflows/provision_services.go +++ b/server/internal/workflows/provision_services.go @@ -231,8 +231,6 @@ func (w *Workflows) ProvisionServices(ctx workflow.Context, input *ProvisionServ DatabaseID: input.Spec.DatabaseID, DatabaseName: input.Spec.DatabaseName, HostID: hostID, - ServiceName: database.GenerateServiceName(serviceSpec.ServiceType, input.Spec.DatabaseID, serviceSpec.ServiceID, hostID), - Hostname: database.GenerateServiceName(serviceSpec.ServiceType, input.Spec.DatabaseID, serviceSpec.ServiceID, hostID), Credentials: createUserOutput.Credentials, DatabaseNetworkID: database.GenerateDatabaseNetworkID(input.Spec.DatabaseID), DatabaseHost: instanceHostname, From 998e94ef510f43b59092ea3644a1e0c3f0f221fb Mon Sep 17 00:00:00 2001 From: rshoemaker Date: Fri, 13 Feb 2026 15:26:05 -0500 Subject: [PATCH 12/12] test: service instance naming test --- .../orchestrator/swarm/orchestrator_test.go | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 server/internal/orchestrator/swarm/orchestrator_test.go diff --git a/server/internal/orchestrator/swarm/orchestrator_test.go b/server/internal/orchestrator/swarm/orchestrator_test.go new file mode 100644 index 00000000..901db254 --- /dev/null +++ b/server/internal/orchestrator/swarm/orchestrator_test.go @@ -0,0 +1,65 @@ +package swarm + +import ( + "fmt" + "strings" + "testing" +) + +func TestServiceInstanceName(t *testing.T) { + tests := []struct { + name string + serviceType string + databaseID string + serviceID string + hostID string + }{ + { + name: "short host ID", + serviceType: "mcp", + databaseID: "my-db", + serviceID: "mcp-server", + hostID: "host1", + }, + { + name: "UUID host ID", + serviceType: "mcp", + databaseID: "my-db", + serviceID: "mcp-server", + hostID: "dbf5779c-492a-11f0-b11a-1b8cb15693a8", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ServiceInstanceName(tt.serviceType, tt.databaseID, tt.serviceID, tt.hostID) + + // Verify format: {serviceType}-{databaseID}-{serviceID}-{8charHash} + prefix := fmt.Sprintf("%s-%s-%s-", tt.serviceType, tt.databaseID, tt.serviceID) + if !strings.HasPrefix(got, prefix) { + t.Errorf("ServiceInstanceName() = %q, want prefix %q", got, prefix) + } + + // Verify the suffix is exactly 8 characters (base36 hash) + suffix := strings.TrimPrefix(got, prefix) + if len(suffix) != 8 { + t.Errorf("ServiceInstanceName() hash suffix = %q (len %d), want 8 chars", suffix, len(suffix)) + } + + // Verify deterministic + got2 := ServiceInstanceName(tt.serviceType, tt.databaseID, tt.serviceID, tt.hostID) + if got != got2 { + t.Errorf("ServiceInstanceName() not deterministic: %q != %q", got, got2) + } + }) + } + + // Verify different host IDs produce different names + t.Run("different hosts produce different names", func(t *testing.T) { + name1 := ServiceInstanceName("mcp", "db1", "svc1", "host-a") + name2 := ServiceInstanceName("mcp", "db1", "svc1", "host-b") + if name1 == name2 { + t.Errorf("different host IDs should produce different names, both got %q", name1) + } + }) +}