From 96f7073835905cb19833db2cbf9fca9211944326 Mon Sep 17 00:00:00 2001 From: Siva Date: Thu, 15 Jan 2026 01:04:53 +0530 Subject: [PATCH 1/2] feat(etcd): expose etcd_mode in host API and add cluster tests --- api/apiv1/design/host.go | 8 + api/apiv1/gen/control_plane/service.go | 2 + .../control_plane/client/encode_decode.go | 1 + .../gen/http/control_plane/client/types.go | 5 + .../control_plane/server/encode_decode.go | 1 + .../gen/http/control_plane/server/types.go | 5 + api/apiv1/gen/http/openapi.json | 21 +++ api/apiv1/gen/http/openapi.yaml | 19 +++ api/apiv1/gen/http/openapi3.json | 28 ++++ api/apiv1/gen/http/openapi3.yaml | 26 +++ changes/unreleased/Added-20260115-010344.yaml | 3 + clustertest/cluster_test.go | 9 ++ clustertest/etcd_mode_change_test.go | 150 ++++++++++++++++++ clustertest/host_test.go | 135 ++++++++++++++++ server/internal/api/apiv1/convert.go | 1 + server/internal/host/host.go | 3 + server/internal/host/host_store.go | 1 + server/internal/host/service.go | 1 + 18 files changed, 419 insertions(+) create mode 100644 changes/unreleased/Added-20260115-010344.yaml create mode 100644 clustertest/etcd_mode_change_test.go diff --git a/api/apiv1/design/host.go b/api/apiv1/design/host.go index 7a5a351d..ac880e8e 100644 --- a/api/apiv1/design/host.go +++ b/api/apiv1/design/host.go @@ -120,6 +120,11 @@ var Host = g.Type("Host", func() { g.Attribute("supported_pgedge_versions", g.ArrayOf(PgEdgeVersion), func() { g.Description("The PgEdge versions supported by this host.") }) + g.Attribute("etcd_mode", g.String, func() { + g.Description("The etcd mode for this host.") + g.Enum("server", "client") + g.Example("server") + }) g.Required( "id", @@ -150,6 +155,7 @@ var HostsArrayExample = []map[string]any{ "memory": "16GB", "orchestrator": "swarm", "data_dir": "/data", + "etcd_mode": "server", "status": map[string]any{ "components": map[string]any{}, "state": "healthy", @@ -188,6 +194,7 @@ var HostsArrayExample = []map[string]any{ "memory": "16GB", "orchestrator": "swarm", "data_dir": "/data", + "etcd_mode": "server", "status": map[string]any{ "components": map[string]any{}, "state": "healthy", @@ -226,6 +233,7 @@ var HostsArrayExample = []map[string]any{ "memory": "16GB", "orchestrator": "swarm", "data_dir": "/data", + "etcd_mode": "client", "status": map[string]any{ "components": map[string]any{}, "state": "healthy", diff --git a/api/apiv1/gen/control_plane/service.go b/api/apiv1/gen/control_plane/service.go index c8f4aad9..ccdeebe6 100644 --- a/api/apiv1/gen/control_plane/service.go +++ b/api/apiv1/gen/control_plane/service.go @@ -531,6 +531,8 @@ type Host struct { DefaultPgedgeVersion *PgEdgeVersion // The PgEdge versions supported by this host. SupportedPgedgeVersions []*PgEdgeVersion + // The etcd mode for this host. + EtcdMode *string } type HostCohort struct { 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 36f1eb64..580c65ec 100644 --- a/api/apiv1/gen/http/control_plane/client/encode_decode.go +++ b/api/apiv1/gen/http/control_plane/client/encode_decode.go @@ -3500,6 +3500,7 @@ func unmarshalHostResponseBodyToControlplaneHost(v *HostResponseBody) *controlpl Ipv4Address: *v.Ipv4Address, Cpus: v.Cpus, Memory: v.Memory, + EtcdMode: v.EtcdMode, } if v.Cohort != nil { res.Cohort = unmarshalHostCohortResponseBodyToControlplaneHostCohort(v.Cohort) diff --git a/api/apiv1/gen/http/control_plane/client/types.go b/api/apiv1/gen/http/control_plane/client/types.go index 47cb14c3..f8475de8 100644 --- a/api/apiv1/gen/http/control_plane/client/types.go +++ b/api/apiv1/gen/http/control_plane/client/types.go @@ -170,6 +170,8 @@ type GetHostResponseBody struct { DefaultPgedgeVersion *PgEdgeVersionResponseBody `form:"default_pgedge_version,omitempty" json:"default_pgedge_version,omitempty" xml:"default_pgedge_version,omitempty"` // The PgEdge versions supported by this host. SupportedPgedgeVersions []*PgEdgeVersionResponseBody `form:"supported_pgedge_versions,omitempty" json:"supported_pgedge_versions,omitempty" xml:"supported_pgedge_versions,omitempty"` + // The etcd mode for this host. + EtcdMode *string `form:"etcd_mode,omitempty" json:"etcd_mode,omitempty" xml:"etcd_mode,omitempty"` } // RemoveHostResponseBody is the type of the "control-plane" service @@ -1419,6 +1421,8 @@ type HostResponseBody struct { DefaultPgedgeVersion *PgEdgeVersionResponseBody `form:"default_pgedge_version,omitempty" json:"default_pgedge_version,omitempty" xml:"default_pgedge_version,omitempty"` // The PgEdge versions supported by this host. SupportedPgedgeVersions []*PgEdgeVersionResponseBody `form:"supported_pgedge_versions,omitempty" json:"supported_pgedge_versions,omitempty" xml:"supported_pgedge_versions,omitempty"` + // The etcd mode for this host. + EtcdMode *string `form:"etcd_mode,omitempty" json:"etcd_mode,omitempty" xml:"etcd_mode,omitempty"` } // HostCohortResponseBody is used to define fields on response body types. @@ -2792,6 +2796,7 @@ func NewGetHostHostOK(body *GetHostResponseBody) *controlplane.Host { Ipv4Address: *body.Ipv4Address, Cpus: body.Cpus, Memory: body.Memory, + EtcdMode: body.EtcdMode, } if body.Cohort != nil { v.Cohort = unmarshalHostCohortResponseBodyToControlplaneHostCohort(body.Cohort) 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 0dc1d302..c61f7c67 100644 --- a/api/apiv1/gen/http/control_plane/server/encode_decode.go +++ b/api/apiv1/gen/http/control_plane/server/encode_decode.go @@ -2909,6 +2909,7 @@ func marshalControlplaneHostToHostResponseBody(v *controlplane.Host) *HostRespon Ipv4Address: v.Ipv4Address, Cpus: v.Cpus, Memory: v.Memory, + EtcdMode: v.EtcdMode, } if v.Cohort != nil { res.Cohort = marshalControlplaneHostCohortToHostCohortResponseBody(v.Cohort) diff --git a/api/apiv1/gen/http/control_plane/server/types.go b/api/apiv1/gen/http/control_plane/server/types.go index 33263c9d..2658a98f 100644 --- a/api/apiv1/gen/http/control_plane/server/types.go +++ b/api/apiv1/gen/http/control_plane/server/types.go @@ -170,6 +170,8 @@ type GetHostResponseBody struct { DefaultPgedgeVersion *PgEdgeVersionResponseBody `form:"default_pgedge_version,omitempty" json:"default_pgedge_version,omitempty" xml:"default_pgedge_version,omitempty"` // The PgEdge versions supported by this host. SupportedPgedgeVersions []*PgEdgeVersionResponseBody `form:"supported_pgedge_versions,omitempty" json:"supported_pgedge_versions,omitempty" xml:"supported_pgedge_versions,omitempty"` + // The etcd mode for this host. + EtcdMode *string `form:"etcd_mode,omitempty" json:"etcd_mode,omitempty" xml:"etcd_mode,omitempty"` } // RemoveHostResponseBody is the type of the "control-plane" service @@ -1419,6 +1421,8 @@ type HostResponseBody struct { DefaultPgedgeVersion *PgEdgeVersionResponseBody `form:"default_pgedge_version,omitempty" json:"default_pgedge_version,omitempty" xml:"default_pgedge_version,omitempty"` // The PgEdge versions supported by this host. SupportedPgedgeVersions []*PgEdgeVersionResponseBody `form:"supported_pgedge_versions,omitempty" json:"supported_pgedge_versions,omitempty" xml:"supported_pgedge_versions,omitempty"` + // The etcd mode for this host. + EtcdMode *string `form:"etcd_mode,omitempty" json:"etcd_mode,omitempty" xml:"etcd_mode,omitempty"` } // HostCohortResponseBody is used to define fields on response body types. @@ -2522,6 +2526,7 @@ func NewGetHostResponseBody(res *controlplane.Host) *GetHostResponseBody { Ipv4Address: res.Ipv4Address, Cpus: res.Cpus, Memory: res.Memory, + EtcdMode: res.EtcdMode, } if res.Cohort != nil { body.Cohort = marshalControlplaneHostCohortToHostCohortResponseBody(res.Cohort) diff --git a/api/apiv1/gen/http/openapi.json b/api/apiv1/gen/http/openapi.json index 237f2051..98c24e4e 100644 --- a/api/apiv1/gen/http/openapi.json +++ b/api/apiv1/gen/http/openapi.json @@ -2280,6 +2280,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-0123456789abcdef.ec2.internal", "id": "us-east-1", "ipv4_address": "10.24.34.2", @@ -2318,6 +2319,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-058731542fee493f.ec2.internal", "id": "ap-south-1", "ipv4_address": "10.24.35.2", @@ -2356,6 +2358,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "client", "hostname": "i-494027b7b53f6a23.ec2.internal", "id": "eu-central-1", "ipv4_address": "10.24.36.2", @@ -2409,6 +2412,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-0123456789abcdef.ec2.internal", "id": "us-east-1", "ipv4_address": "10.24.34.2", @@ -2447,6 +2451,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-058731542fee493f.ec2.internal", "id": "ap-south-1", "ipv4_address": "10.24.35.2", @@ -2485,6 +2490,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "client", "hostname": "i-494027b7b53f6a23.ec2.internal", "id": "eu-central-1", "ipv4_address": "10.24.36.2", @@ -5185,6 +5191,15 @@ "default_pgedge_version": { "$ref": "#/definitions/PgEdgeVersion" }, + "etcd_mode": { + "type": "string", + "description": "The etcd mode for this host.", + "example": "server", + "enum": [ + "server", + "client" + ] + }, "hostname": { "type": "string", "description": "The hostname of this host.", @@ -5246,6 +5261,7 @@ "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", @@ -6056,6 +6072,7 @@ "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", @@ -6116,6 +6133,7 @@ "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", @@ -6182,6 +6200,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-0123456789abcdef.ec2.internal", "id": "us-east-1", "ipv4_address": "10.24.34.2", @@ -6220,6 +6239,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-058731542fee493f.ec2.internal", "id": "ap-south-1", "ipv4_address": "10.24.35.2", @@ -6258,6 +6278,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "client", "hostname": "i-494027b7b53f6a23.ec2.internal", "id": "eu-central-1", "ipv4_address": "10.24.36.2", diff --git a/api/apiv1/gen/http/openapi.yaml b/api/apiv1/gen/http/openapi.yaml index 5b6118a5..c44b037b 100644 --- a/api/apiv1/gen/http/openapi.yaml +++ b/api/apiv1/gen/http/openapi.yaml @@ -1624,6 +1624,7 @@ definitions: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-0123456789abcdef.ec2.internal id: us-east-1 ipv4_address: 10.24.34.2 @@ -1650,6 +1651,7 @@ definitions: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-058731542fee493f.ec2.internal id: ap-south-1 ipv4_address: 10.24.35.2 @@ -1676,6 +1678,7 @@ definitions: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: client hostname: i-494027b7b53f6a23.ec2.internal id: eu-central-1 ipv4_address: 10.24.36.2 @@ -1712,6 +1715,7 @@ definitions: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-0123456789abcdef.ec2.internal id: us-east-1 ipv4_address: 10.24.34.2 @@ -1738,6 +1742,7 @@ definitions: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-058731542fee493f.ec2.internal id: ap-south-1 ipv4_address: 10.24.35.2 @@ -1764,6 +1769,7 @@ definitions: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: client hostname: i-494027b7b53f6a23.ec2.internal id: eu-central-1 ipv4_address: 10.24.36.2 @@ -3721,6 +3727,13 @@ definitions: example: /data default_pgedge_version: $ref: '#/definitions/PgEdgeVersion' + etcd_mode: + type: string + description: The etcd mode for this host. + example: server + enum: + - server + - client hostname: type: string description: The hostname of this host. @@ -3766,6 +3779,7 @@ definitions: 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 @@ -4369,6 +4383,7 @@ definitions: 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 @@ -4408,6 +4423,7 @@ definitions: 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 @@ -4450,6 +4466,7 @@ definitions: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-0123456789abcdef.ec2.internal id: us-east-1 ipv4_address: 10.24.34.2 @@ -4476,6 +4493,7 @@ definitions: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-058731542fee493f.ec2.internal id: ap-south-1 ipv4_address: 10.24.35.2 @@ -4502,6 +4520,7 @@ definitions: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: client hostname: i-494027b7b53f6a23.ec2.internal id: eu-central-1 ipv4_address: 10.24.36.2 diff --git a/api/apiv1/gen/http/openapi3.json b/api/apiv1/gen/http/openapi3.json index 6ef9cce3..2cf537d6 100644 --- a/api/apiv1/gen/http/openapi3.json +++ b/api/apiv1/gen/http/openapi3.json @@ -42,6 +42,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-0123456789abcdef.ec2.internal", "id": "us-east-1", "ipv4_address": "10.24.34.2", @@ -80,6 +81,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-058731542fee493f.ec2.internal", "id": "ap-south-1", "ipv4_address": "10.24.35.2", @@ -118,6 +120,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "client", "hostname": "i-494027b7b53f6a23.ec2.internal", "id": "eu-central-1", "ipv4_address": "10.24.36.2", @@ -3642,6 +3645,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-0123456789abcdef.ec2.internal", "id": "us-east-1", "ipv4_address": "10.24.34.2", @@ -3680,6 +3684,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-058731542fee493f.ec2.internal", "id": "ap-south-1", "ipv4_address": "10.24.35.2", @@ -3718,6 +3723,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "client", "hostname": "i-494027b7b53f6a23.ec2.internal", "id": "eu-central-1", "ipv4_address": "10.24.36.2", @@ -3966,6 +3972,7 @@ "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", @@ -4617,6 +4624,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-0123456789abcdef.ec2.internal", "id": "us-east-1", "ipv4_address": "10.24.34.2", @@ -4655,6 +4663,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-058731542fee493f.ec2.internal", "id": "ap-south-1", "ipv4_address": "10.24.35.2", @@ -4693,6 +4702,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "client", "hostname": "i-494027b7b53f6a23.ec2.internal", "id": "eu-central-1", "ipv4_address": "10.24.36.2", @@ -4746,6 +4756,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-0123456789abcdef.ec2.internal", "id": "us-east-1", "ipv4_address": "10.24.34.2", @@ -4784,6 +4795,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "server", "hostname": "i-058731542fee493f.ec2.internal", "id": "ap-south-1", "ipv4_address": "10.24.35.2", @@ -4822,6 +4834,7 @@ "postgres_version": "17.6", "spock_version": "5" }, + "etcd_mode": "client", "hostname": "i-494027b7b53f6a23.ec2.internal", "id": "eu-central-1", "ipv4_address": "10.24.36.2", @@ -12691,6 +12704,15 @@ "default_pgedge_version": { "$ref": "#/components/schemas/PgEdgeVersion" }, + "etcd_mode": { + "type": "string", + "description": "The etcd mode for this host.", + "example": "server", + "enum": [ + "server", + "client" + ] + }, "hostname": { "type": "string", "description": "The hostname of this host.", @@ -12756,6 +12778,7 @@ "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", @@ -13606,6 +13629,7 @@ "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", @@ -13662,6 +13686,7 @@ "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", @@ -13724,6 +13749,7 @@ "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", @@ -13780,6 +13806,7 @@ "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", @@ -13836,6 +13863,7 @@ "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", diff --git a/api/apiv1/gen/http/openapi3.yaml b/api/apiv1/gen/http/openapi3.yaml index ce37b867..8635ee4d 100644 --- a/api/apiv1/gen/http/openapi3.yaml +++ b/api/apiv1/gen/http/openapi3.yaml @@ -32,6 +32,7 @@ paths: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-0123456789abcdef.ec2.internal id: us-east-1 ipv4_address: 10.24.34.2 @@ -58,6 +59,7 @@ paths: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-058731542fee493f.ec2.internal id: ap-south-1 ipv4_address: 10.24.35.2 @@ -84,6 +86,7 @@ paths: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: client hostname: i-494027b7b53f6a23.ec2.internal id: eu-central-1 ipv4_address: 10.24.36.2 @@ -2423,6 +2426,7 @@ paths: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-0123456789abcdef.ec2.internal id: us-east-1 ipv4_address: 10.24.34.2 @@ -2449,6 +2453,7 @@ paths: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-058731542fee493f.ec2.internal id: ap-south-1 ipv4_address: 10.24.35.2 @@ -2475,6 +2480,7 @@ paths: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: client hostname: i-494027b7b53f6a23.ec2.internal id: eu-central-1 ipv4_address: 10.24.36.2 @@ -2642,6 +2648,7 @@ paths: 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 @@ -3129,6 +3136,7 @@ components: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-0123456789abcdef.ec2.internal id: us-east-1 ipv4_address: 10.24.34.2 @@ -3155,6 +3163,7 @@ components: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-058731542fee493f.ec2.internal id: ap-south-1 ipv4_address: 10.24.35.2 @@ -3181,6 +3190,7 @@ components: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: client hostname: i-494027b7b53f6a23.ec2.internal id: eu-central-1 ipv4_address: 10.24.36.2 @@ -3217,6 +3227,7 @@ components: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-0123456789abcdef.ec2.internal id: us-east-1 ipv4_address: 10.24.34.2 @@ -3243,6 +3254,7 @@ components: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: server hostname: i-058731542fee493f.ec2.internal id: ap-south-1 ipv4_address: 10.24.35.2 @@ -3269,6 +3281,7 @@ components: default_pgedge_version: postgres_version: "17.6" spock_version: "5" + etcd_mode: client hostname: i-494027b7b53f6a23.ec2.internal id: eu-central-1 ipv4_address: 10.24.36.2 @@ -8959,6 +8972,13 @@ components: example: /data default_pgedge_version: $ref: '#/components/schemas/PgEdgeVersion' + etcd_mode: + type: string + description: The etcd mode for this host. + example: server + enum: + - server + - client hostname: type: string description: The hostname of this host. @@ -9006,6 +9026,7 @@ components: 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 @@ -9636,6 +9657,7 @@ components: 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 @@ -9673,6 +9695,7 @@ components: 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 @@ -9713,6 +9736,7 @@ components: 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 @@ -9750,6 +9774,7 @@ components: 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 @@ -9787,6 +9812,7 @@ components: 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 diff --git a/changes/unreleased/Added-20260115-010344.yaml b/changes/unreleased/Added-20260115-010344.yaml new file mode 100644 index 00000000..6e1d4e28 --- /dev/null +++ b/changes/unreleased/Added-20260115-010344.yaml @@ -0,0 +1,3 @@ +kind: Added +body: Added etcd_mode to the Host API response and cluster-level tests for etcd reconfiguration. +time: 2026-01-15T01:03:44.757533+05:30 diff --git a/clustertest/cluster_test.go b/clustertest/cluster_test.go index d829292c..09ccf552 100644 --- a/clustertest/cluster_test.go +++ b/clustertest/cluster_test.go @@ -108,6 +108,15 @@ func (c *Cluster) Remove(t testing.TB, hostID string) { c.client = hostsClient(t, c.hosts) } +// RefreshClient recreates the client with updated host configurations. +// This is useful after a host has been recreated with new settings (e.g., port change). +func (c *Cluster) RefreshClient(t testing.TB) { + t.Helper() + + tLogf(t, "refreshing client for cluster %s", c.id) + c.client = hostsClient(t, c.hosts) +} + func hostsClient(t testing.TB, hosts map[string]*Host) client.Client { t.Helper() diff --git a/clustertest/etcd_mode_change_test.go b/clustertest/etcd_mode_change_test.go new file mode 100644 index 00000000..26d05de6 --- /dev/null +++ b/clustertest/etcd_mode_change_test.go @@ -0,0 +1,150 @@ +//go:build cluster_test + +package clustertest + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPromoteClientToServer(t *testing.T) { + + tLog(t, "initializing cluster with 3 etcd servers and 1 etcd client") + + cluster := NewCluster(t, ClusterConfig{ + Hosts: []HostConfig{ + {ID: "host-1"}, + {ID: "host-2"}, + {ID: "host-3"}, + {ID: "host-4", EtcdMode: EtcdModeClient}, + }, + }) + cluster.Init(t) + + cluster.AssertHealthy(t) + + tLog(t, "verifying initial cluster has 4 healthy hosts") + initialHostCount := countHealthyHosts(t, cluster) + require.Equal(t, 4, initialHostCount, "should have 4 healthy hosts initially") + + host4 := cluster.Host("host-4") + initialMode := host4.GetEtcdMode(t, cluster.Client()) + require.Equal(t, "client", initialMode, "host-4 should initially be in client mode") + + tLog(t, "promoting host-4 from client to server mode") + + host4.RecreateWithMode(t, EtcdModeServer) + + cluster.RefreshClient(t) + + waitForHostsHealthy(t, cluster, 4, 2*time.Minute) + + tLog(t, "verifying cluster health after promotion") + cluster.AssertHealthy(t) + + tLog(t, "verifying host-4 PGEDGE_ETCD_MODE changed to server") + finalMode := host4.GetEtcdMode(t, cluster.Client()) + assert.Equal(t, "server", finalMode, "host-4 should be in server mode after promotion") + + tLog(t, "verifying all 4 hosts remain healthy after promotion") + finalHostCount := countHealthyHosts(t, cluster) + assert.Equal(t, 4, finalHostCount, "should have 4 healthy hosts after promotion") +} + +func TestDemoteServerToClient(t *testing.T) { + + tLog(t, "initializing cluster with 4 etcd servers") + + cluster := NewCluster(t, ClusterConfig{ + Hosts: []HostConfig{ + {ID: "host-1"}, + {ID: "host-2"}, + {ID: "host-3"}, + {ID: "host-4"}, + }, + }) + cluster.Init(t) + cluster.AssertHealthy(t) + + tLog(t, "verifying initial cluster has 4 healthy hosts") + initialHostCount := countHealthyHosts(t, cluster) + require.Equal(t, 4, initialHostCount, "should have 4 healthy hosts initially") + + host4 := cluster.Host("host-4") + initialMode := host4.GetEtcdMode(t, cluster.Client()) + require.Equal(t, "server", initialMode, "host-4 should initially be in server mode") + + tLog(t, "demoting host-4 from server to client mode") + + host4.RecreateWithMode(t, EtcdModeClient) + + cluster.RefreshClient(t) + + waitForHostsHealthy(t, cluster, 4, 2*time.Minute) + + tLog(t, "verifying cluster health after demotion") + cluster.AssertHealthy(t) + + tLog(t, "verifying host-4 PGEDGE_ETCD_MODE changed to client") + finalMode := host4.GetEtcdMode(t, cluster.Client()) + assert.Equal(t, "client", finalMode, "host-4 should be in client mode after demotion") + + tLog(t, "verifying all 4 hosts remain healthy after demotion") + finalHostCount := countHealthyHosts(t, cluster) + assert.Equal(t, 4, finalHostCount, "should have 4 healthy hosts after demotion") +} + +func countHealthyHosts(t testing.TB, cluster *Cluster) int { + t.Helper() + + resp, err := cluster.Client().ListHosts(t.Context()) + require.NoError(t, err, "should be able to list hosts") + require.NotNil(t, resp, "list hosts response should not be nil") + + healthyCount := 0 + for _, host := range resp.Hosts { + if host.Status.State == "healthy" { + healthyCount++ + } + } + + tLogf(t, "counted %d healthy hosts in cluster", healthyCount) + return healthyCount +} + +func waitForHostsHealthy(t testing.TB, cluster *Cluster, expectedCount int, timeout time.Duration) { + t.Helper() + + tLogf(t, "waiting for %d hosts to become healthy (timeout: %v)", expectedCount, timeout) + + deadline := time.Now().Add(timeout) + for { + resp, err := cluster.Client().ListHosts(t.Context()) + if err == nil && resp != nil { + healthyCount := 0 + for _, host := range resp.Hosts { + if host.Status.State == "healthy" { + healthyCount++ + } + } + + if healthyCount == expectedCount { + tLogf(t, "all %d hosts are healthy", expectedCount) + return + } + + tLogf(t, "currently %d/%d hosts healthy, waiting...", healthyCount, expectedCount) + } else if err != nil { + tLogf(t, "error checking hosts (will retry): %v", err) + } + + if time.Now().After(deadline) { + t.Fatalf("timeout waiting for %d healthy hosts", expectedCount) + } + + time.Sleep(5 * time.Second) + } +} diff --git a/clustertest/host_test.go b/clustertest/host_test.go index 67ffbde7..1e642ba0 100644 --- a/clustertest/host_test.go +++ b/clustertest/host_test.go @@ -40,6 +40,7 @@ type HostConfig struct { type Host struct { id string port int + dataDir string container testcontainers.Container } @@ -71,6 +72,7 @@ func NewHost(t testing.TB, config HostConfig) *Host { env["PGEDGE_ETCD_MODE"] = "client" case EtcdModeServer: ports = allocatePorts(t, 3) + env["PGEDGE_ETCD_MODE"] = "server" env["PGEDGE_ETCD_SERVER__PEER_PORT"] = strconv.Itoa(ports[1]) env["PGEDGE_ETCD_SERVER__CLIENT_PORT"] = strconv.Itoa(ports[2]) default: @@ -143,6 +145,7 @@ func NewHost(t testing.TB, config HostConfig) *Host { return &Host{ id: id, port: ports[0], + dataDir: dataDir, container: container, } } @@ -170,6 +173,138 @@ func (h *Host) ClientConfig() client.ServerConfig { }) } +// GetEtcdMode retrieves the etcd mode for this host from the API. +// It accepts an optional client parameter. If nil, it creates a client from the host's config. +// When querying a host that was just recreated and may not be initialized yet, +// pass a cluster client that can reach other initialized hosts. +func (h *Host) GetEtcdMode(t testing.TB, cli client.Client) string { + t.Helper() + + var err error + if cli == nil { + cli, err = client.NewMultiServerClient(h.ClientConfig()) + require.NoError(t, err) + } + + resp, err := cli.ListHosts(t.Context()) + require.NoError(t, err) + + for _, host := range resp.Hosts { + if string(host.ID) == h.id { + if host.EtcdMode == nil { + return "" + } + return *host.EtcdMode + } + } + + t.Fatalf("host %s not found in API response", h.id) + return "" +} + +// RecreateWithMode stops the current container and recreates it with a new etcd mode. +// This simulates changing the PGEDGE_ETCD_MODE environment variable and restarting. +func (h *Host) RecreateWithMode(t testing.TB, newMode EtcdMode) { + t.Helper() + + tLogf(t, "recreating host %s with etcd mode %s", h.id, newMode) + + // Stop the current container + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := h.container.Terminate(ctx) + require.NoError(t, err) + + // Reuse the original data directory to preserve cluster state + dataDir := h.dataDir + + // Build the new environment + env := map[string]string{ + "PGEDGE_HOST_ID": h.id, + "PGEDGE_DATA_DIR": dataDir, + } + + var ports []int + + switch newMode { + case EtcdModeClient: + ports = allocatePorts(t, 1) + env["PGEDGE_ETCD_MODE"] = "client" + case EtcdModeServer: + ports = allocatePorts(t, 3) + env["PGEDGE_ETCD_MODE"] = "server" + env["PGEDGE_ETCD_SERVER__PEER_PORT"] = strconv.Itoa(ports[1]) + env["PGEDGE_ETCD_SERVER__CLIENT_PORT"] = strconv.Itoa(ports[2]) + default: + t.Fatalf("unrecognized etcd mode '%s'", newMode) + } + + env["PGEDGE_HTTP__PORT"] = strconv.Itoa(ports[0]) + + req := testcontainers.ContainerRequest{ + AlwaysPullImage: true, + Image: testConfig.imageTag, + Env: env, + Cmd: []string{"run"}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.NetworkMode = "host" + hc.Mounts = []mount.Mount{ + { + Type: mount.TypeBind, + Source: dataDir, + Target: dataDir, + }, + { + Type: mount.TypeBind, + Source: "/var/run/docker.sock", + Target: "/var/run/docker.sock", + }, + } + }, + WaitingFor: wait.ForHTTP("/v1/version"). + WithPort(nat.Port(fmt.Sprintf("%d/tcp", ports[0]))). + WithStartupTimeout(60 * time.Second), + } + + tLogf(t, "starting host %s with new mode %s", h.id, newMode) + + newContainer, err := testcontainers.GenericContainer( + t.Context(), + testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }, + ) + require.NoError(t, err) + + // Update the host's container reference and port + h.container = newContainer + h.port = ports[0] + + // Register cleanup for the new container + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + + if t.Failed() { + logs, err := containerLogs(cleanupCtx, t, newContainer) + if err != nil { + tLogf(t, "failed to extract container logs: %s", err) + } else { + tLogf(t, "host %s logs: %s", h.id, logs) + } + } + + if testConfig.skipCleanup { + tLogf(t, "skipping cleanup for %s container %s", h.id, newContainer.GetContainerID()[:12]) + return + } + + newContainer.Terminate(cleanupCtx) + }) +} + func containerLogs(ctx context.Context, t testing.TB, container testcontainers.Container) (string, error) { t.Helper() diff --git a/server/internal/api/apiv1/convert.go b/server/internal/api/apiv1/convert.go index cd441fad..5e415290 100644 --- a/server/internal/api/apiv1/convert.go +++ b/server/internal/api/apiv1/convert.go @@ -52,6 +52,7 @@ func hostToAPI(h *host.Host) *api.Host { Memory: utils.NillablePointerTo(humanizeBytes(h.MemBytes)), Cohort: cohort, ID: api.Identifier(h.ID), + EtcdMode: utils.NillablePointerTo(string(h.EtcdMode)), DefaultPgedgeVersion: &api.PgEdgeVersion{ PostgresVersion: h.DefaultPgEdgeVersion.PostgresVersion.String(), SpockVersion: h.DefaultPgEdgeVersion.SpockVersion.String(), diff --git a/server/internal/host/host.go b/server/internal/host/host.go index 7c545f45..17c5ab48 100644 --- a/server/internal/host/host.go +++ b/server/internal/host/host.go @@ -44,6 +44,7 @@ type Host struct { IPv4Address string CPUs int MemBytes uint64 + EtcdMode config.EtcdMode Status *HostStatus DefaultPgEdgeVersion *PgEdgeVersion SupportedPgEdgeVersions []*PgEdgeVersion @@ -95,6 +96,7 @@ func fromStorage(host *StoredHost, status *StoredHostStatus) (*Host, error) { IPv4Address: host.IPv4Address, CPUs: host.CPUs, MemBytes: host.MemBytes, + EtcdMode: host.EtcdMode, SupportedPgEdgeVersions: host.SupportedPgEdgeVersions, DefaultPgEdgeVersion: host.DefaultPgEdgeVersion, Status: &HostStatus{ @@ -143,6 +145,7 @@ func toStorage(host *Host) *StoredHost { IPv4Address: host.IPv4Address, CPUs: host.CPUs, MemBytes: host.MemBytes, + EtcdMode: host.EtcdMode, DefaultPgEdgeVersion: host.DefaultPgEdgeVersion, SupportedPgEdgeVersions: host.SupportedPgEdgeVersions, } diff --git a/server/internal/host/host_store.go b/server/internal/host/host_store.go index ae0a66fc..46a8f617 100644 --- a/server/internal/host/host_store.go +++ b/server/internal/host/host_store.go @@ -23,6 +23,7 @@ type StoredHost struct { IPv4Address string `json:"ipv4_address"` CPUs int `json:"cpus"` MemBytes uint64 `json:"mem_bytes"` + EtcdMode config.EtcdMode `json:"etcd_mode"` DefaultPgEdgeVersion *PgEdgeVersion `json:"default_version"` SupportedPgEdgeVersions []*PgEdgeVersion `json:"supported_versions"` } diff --git a/server/internal/host/service.go b/server/internal/host/service.go index 29454e0a..8bdf1e79 100644 --- a/server/internal/host/service.go +++ b/server/internal/host/service.go @@ -44,6 +44,7 @@ func (s *Service) UpdateHost(ctx context.Context) error { DataDir: s.cfg.DataDir, Hostname: s.cfg.Hostname, IPv4Address: s.cfg.IPv4Address, + EtcdMode: s.cfg.EtcdMode, // CPUs: resources.CPUs, // MemBytes: resources.MemBytes, // UpdatedAt: time.Now(), From 324ee0c61ae1db9263dc507c3adc89249c2948bd Mon Sep 17 00:00:00 2001 From: Siva Date: Thu, 15 Jan 2026 18:07:15 +0530 Subject: [PATCH 2/2] addressing AI review comments --- clustertest/host_test.go | 49 +++++++++++++--------------------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/clustertest/host_test.go b/clustertest/host_test.go index 1e642ba0..c559f31f 100644 --- a/clustertest/host_test.go +++ b/clustertest/host_test.go @@ -111,7 +111,7 @@ func NewHost(t testing.TB, config HostConfig) *Host { tLogf(t, "creating host %s", id) - container, err := testcontainers.GenericContainer( + ctr, err := testcontainers.GenericContainer( t.Context(), testcontainers.GenericContainerRequest{ ContainerRequest: req, @@ -120,13 +120,20 @@ func NewHost(t testing.TB, config HostConfig) *Host { ) require.NoError(t, err) + h := &Host{ + id: id, + port: ports[0], + dataDir: dataDir, + container: ctr, + } + t.Cleanup(func() { // Use a new context for cleanup operations since t.Context is canceled. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if t.Failed() { - logs, err := containerLogs(ctx, t, container) + logs, err := containerLogs(ctx, t, h.container) if err != nil { tLogf(t, "failed to extract container logs: %s", err) } else { @@ -135,19 +142,14 @@ func NewHost(t testing.TB, config HostConfig) *Host { } if testConfig.skipCleanup { - tLogf(t, "skipping cleanup for %s container %s", id, container.GetContainerID()[:12]) + tLogf(t, "skipping cleanup for %s container %s", id, h.container.GetContainerID()[:12]) return } - container.Terminate(ctx) + h.container.Terminate(ctx) }) - return &Host{ - id: id, - port: ports[0], - dataDir: dataDir, - container: container, - } + return h } func (h *Host) Stop(t testing.TB) { @@ -204,6 +206,7 @@ func (h *Host) GetEtcdMode(t testing.TB, cli client.Client) string { // RecreateWithMode stops the current container and recreates it with a new etcd mode. // This simulates changing the PGEDGE_ETCD_MODE environment variable and restarting. +// The new container will be cleaned up by the original cleanup registered in NewHost. func (h *Host) RecreateWithMode(t testing.TB, newMode EtcdMode) { t.Helper() @@ -278,31 +281,11 @@ func (h *Host) RecreateWithMode(t testing.TB, newMode EtcdMode) { ) require.NoError(t, err) - // Update the host's container reference and port + // Update the host's container reference and port. + // The cleanup registered in NewHost will terminate h.container, + // which now points to the new container. h.container = newContainer h.port = ports[0] - - // Register cleanup for the new container - t.Cleanup(func() { - cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cleanupCancel() - - if t.Failed() { - logs, err := containerLogs(cleanupCtx, t, newContainer) - if err != nil { - tLogf(t, "failed to extract container logs: %s", err) - } else { - tLogf(t, "host %s logs: %s", h.id, logs) - } - } - - if testConfig.skipCleanup { - tLogf(t, "skipping cleanup for %s container %s", h.id, newContainer.GetContainerID()[:12]) - return - } - - newContainer.Terminate(cleanupCtx) - }) } func containerLogs(ctx context.Context, t testing.TB, container testcontainers.Container) (string, error) {