diff --git a/docs/docs/guides/parent-gcp-gke-autopilot.md b/docs/docs/guides/parent-gcp-gke-autopilot.md index 005dd360..fa9d6427 100644 --- a/docs/docs/guides/parent-gcp-gke-autopilot.md +++ b/docs/docs/guides/parent-gcp-gke-autopilot.md @@ -207,6 +207,12 @@ resources: externalEgressIp: enabled: true # Enables CloudNAT with static IP # existing: "projects/my-project/regions/europe-west3/addresses/my-static-ip" # Optional: use existing IP + # Cloud NAT port tuning (all optional; defaults: 64 min ports, EIM on, DPA off). + # Raise these when pods open many concurrent outbound connections and you see + # source-port exhaustion (dropped SYNs / dial i/o timeouts): + # minPortsPerVm: 1024 + # dynamicPortAllocation: true # requires endpointIndependentMapping: false + # endpointIndependentMapping: false ``` ### **What Private VPC Does** diff --git a/docs/schemas/gcp/gkeautopilotresource.json b/docs/schemas/gcp/gkeautopilotresource.json index 463784db..5a8eba20 100644 --- a/docs/schemas/gcp/gkeautopilotresource.json +++ b/docs/schemas/gcp/gkeautopilotresource.json @@ -266,11 +266,23 @@ "externalEgressIp": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "dynamicPortAllocation": { + "type": "boolean" + }, "enabled": { "type": "boolean" }, + "endpointIndependentMapping": { + "type": "boolean" + }, "existing": { "type": "string" + }, + "maxPortsPerVm": { + "type": "integer" + }, + "minPortsPerVm": { + "type": "integer" } }, "required": [ diff --git a/docs/schemas/gcp/postgresgcpcloudsqlconfig.json b/docs/schemas/gcp/postgresgcpcloudsqlconfig.json index 7d0d0f37..d93f8ade 100644 --- a/docs/schemas/gcp/postgresgcpcloudsqlconfig.json +++ b/docs/schemas/gcp/postgresgcpcloudsqlconfig.json @@ -64,9 +64,15 @@ "pointInTimeRecoveryEnabled": { "type": "boolean" }, + "privateNetwork": { + "type": "string" + }, "project": { "type": "string" }, + "publicIpEnabled": { + "type": "boolean" + }, "queryInsightsEnabled": { "type": "boolean" }, diff --git a/go.mod b/go.mod index c44a7e12..ba769ace 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/disgoorg/snowflake/v2 v2.0.3 github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.19.0 - github.com/go-delve/delve v1.26.3 + github.com/go-delve/delve v1.27.0 github.com/go-git/go-billy/v5 v5.9.0 github.com/go-git/go-git/v5 v5.19.1 github.com/golangci/golangci-lint v1.64.8 @@ -61,9 +61,9 @@ require ( gocloud.dev v0.46.0 golang.org/x/crypto v0.53.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.21.0 + golang.org/x/sync v0.22.0 golang.org/x/term v0.44.0 - golang.org/x/text v0.38.0 + golang.org/x/text v0.40.0 google.golang.org/api v0.284.0 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 @@ -464,12 +464,12 @@ require ( golang.org/x/arch v0.11.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect + golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/tools v0.47.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect diff --git a/go.sum b/go.sum index 97383b3e..c0cdee58 100644 --- a/go.sum +++ b/go.sum @@ -358,8 +358,8 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w= github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w= -github.com/go-delve/delve v1.26.3 h1:uCWPnLLYmVRXLt0yhw305sCi5lQLHzYB2fZ0FB3KLUI= -github.com/go-delve/delve v1.26.3/go.mod h1:Ua/k2AAu4cLrUXGSRVH1b2Nzq2aCK188b9EYlAojlz4= +github.com/go-delve/delve v1.27.0 h1:i66Einw/sQhm0hlbjLNUNxrwCmKdTcIqpyHVqSWAbd0= +github.com/go-delve/delve v1.27.0/go.mod h1:l6Xb1ype6VEKoKZCaOg7mGN5B8Zi3Sn+K7qs5Pqqc8k= github.com/go-delve/liner v1.2.3-0.20231231155935-4726ab1d7f62 h1:IGtvsNyIuRjl04XAOFGACozgUD7A82UffYxZt4DWbvA= github.com/go-delve/liner v1.2.3-0.20231231155935-4726ab1d7f62/go.mod h1:biJCRbqp51wS+I92HMqn5H8/A0PAhxn2vyOT+JqhiGI= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= @@ -1094,8 +1094,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -1113,8 +1113,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1126,8 +1126,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1160,8 +1160,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= -golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -1183,8 +1183,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1209,8 +1209,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= diff --git a/pkg/clouds/gcloud/config_validation_test.go b/pkg/clouds/gcloud/config_validation_test.go new file mode 100644 index 00000000..bdc7c303 --- /dev/null +++ b/pkg/clouds/gcloud/config_validation_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) Simple Container + +package gcloud + +import ( + "testing" + + . "github.com/onsi/gomega" + "github.com/samber/lo" +) + +// Cloud NAT port tuning is validated against the EFFECTIVE values (defaults +// applied) and GCP's real bounds, so misconfigurations fail here instead of late +// at pulumi up. +func TestExternalEgressIpConfig_NatTuningValidate(t *testing.T) { + tests := []struct { + name string + cfg ExternalEgressIpConfig + errSubstr string + }{ + {name: "valid dynamic port allocation", cfg: ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(1024), MaxPortsPerVm: lo.ToPtr(8192), DynamicPortAllocation: lo.ToPtr(true), EndpointIndependentMapping: lo.ToPtr(false)}}, + {name: "valid raised min without dpa", cfg: ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(1024)}}, + {name: "min below floor", cfg: ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(16)}, errSubstr: "minPortsPerVm must be between"}, + {name: "min above ceiling", cfg: ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(70000)}, errSubstr: "minPortsPerVm must be between"}, + {name: "max below floor", cfg: ExternalEgressIpConfig{Enabled: true, MaxPortsPerVm: lo.ToPtr(16)}, errSubstr: "maxPortsPerVm must be between"}, + {name: "max above ceiling", cfg: ExternalEgressIpConfig{Enabled: true, MaxPortsPerVm: lo.ToPtr(70000)}, errSubstr: "maxPortsPerVm must be between"}, + {name: "lone max below default min", cfg: ExternalEgressIpConfig{Enabled: true, MaxPortsPerVm: lo.ToPtr(48)}, errSubstr: "must be >= minPortsPerVm"}, + {name: "explicit max below min", cfg: ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(4096), MaxPortsPerVm: lo.ToPtr(1024)}, errSubstr: "must be >= minPortsPerVm"}, + {name: "dpa requires eim off when unset", cfg: ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true)}, errSubstr: "endpointIndependentMapping: false"}, + {name: "dpa requires eim off when explicitly true", cfg: ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true), EndpointIndependentMapping: lo.ToPtr(true)}, errSubstr: "endpointIndependentMapping: false"}, + {name: "dpa min not power of two", cfg: ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true), EndpointIndependentMapping: lo.ToPtr(false), MinPortsPerVm: lo.ToPtr(1000)}, errSubstr: "minPortsPerVm must be a power of two"}, + {name: "dpa max not power of two", cfg: ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true), EndpointIndependentMapping: lo.ToPtr(false), MaxPortsPerVm: lo.ToPtr(3000)}, errSubstr: "maxPortsPerVm must be a power of two"}, + {name: "dpa max equals min", cfg: ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true), EndpointIndependentMapping: lo.ToPtr(false), MinPortsPerVm: lo.ToPtr(1024), MaxPortsPerVm: lo.ToPtr(1024)}, errSubstr: "must be > minPortsPerVm"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + RegisterTestingT(t) + err := tc.cfg.Validate() + if tc.errSubstr == "" { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(tc.errSubstr)) + } + }) + } +} + +func TestPostgresGcpCloudsqlConfig_Validate(t *testing.T) { + tests := []struct { + name string + cfg PostgresGcpCloudsqlConfig + errSubstr string + }{ + {name: "empty is valid", cfg: PostgresGcpCloudsqlConfig{}}, + {name: "availabilityType invalid", cfg: PostgresGcpCloudsqlConfig{AvailabilityType: lo.ToPtr("HA")}, errSubstr: "availabilityType must be"}, + {name: "availabilityType regional ok", cfg: PostgresGcpCloudsqlConfig{AvailabilityType: lo.ToPtr("REGIONAL")}}, + {name: "privateNetwork bad format", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("my-vpc")}, errSubstr: "privateNetwork must be a full"}, + {name: "privateNetwork ok", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc")}}, + {name: "public off without network", cfg: PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(false)}, errSubstr: "requires privateNetwork"}, + {name: "public off with empty network", cfg: PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(false), PrivateNetwork: lo.ToPtr("")}, errSubstr: "requires privateNetwork"}, + {name: "public off with network ok", cfg: PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(false), PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc")}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + RegisterTestingT(t) + err := tc.cfg.Validate() + if tc.errSubstr == "" { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(tc.errSubstr)) + } + }) + } +} + +func TestPostgresGcpCloudsqlConfig_ProxyAndNetworkHelpers(t *testing.T) { + RegisterTestingT(t) + Expect((&PostgresGcpCloudsqlConfig{}).HasPrivateNetwork()).To(BeFalse()) + Expect((&PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("")}).HasPrivateNetwork()).To(BeFalse()) + Expect((&PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc")}).HasPrivateNetwork()).To(BeTrue()) + + // Proxy stays on the public endpoint until the public IP is disabled. + Expect((&PostgresGcpCloudsqlConfig{}).UsesPrivateIpProxy()).To(BeFalse()) + Expect((&PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(true)}).UsesPrivateIpProxy()).To(BeFalse()) + Expect((&PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc")}).UsesPrivateIpProxy()).To(BeFalse()) + Expect((&PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(false)}).UsesPrivateIpProxy()).To(BeTrue()) +} diff --git a/pkg/clouds/gcloud/gke_autopilot.go b/pkg/clouds/gcloud/gke_autopilot.go index 2897cd4d..f78f3294 100644 --- a/pkg/clouds/gcloud/gke_autopilot.go +++ b/pkg/clouds/gcloud/gke_autopilot.go @@ -19,6 +19,15 @@ const ( TemplateTypeGkeAutopilot = "gcp-gke-autopilot" ) +// Cloud NAT port-allocation defaults and bounds. Defaults preserve prior +// behaviour; the floor/ceiling match GCP's accepted range for ports per VM. +const ( + DefaultMinPortsPerVm = 64 + DefaultMaxPortsPerVm = 65536 + PortsPerVmFloor = 32 + PortsPerVmCeiling = 65536 +) + type GkeAutopilotResource struct { Credentials `json:",inline" yaml:",inline"` GkeMinVersion string `json:"gkeMinVersion" yaml:"gkeMinVersion"` @@ -48,6 +57,23 @@ type Timeouts struct { type ExternalEgressIpConfig struct { Enabled bool `json:"enabled" yaml:"enabled"` Existing string `json:"existing,omitempty" yaml:"existing,omitempty"` + + // Cloud NAT port/mapping tuning. All optional; when unset the previous + // defaults are preserved (64 min ports, endpoint-independent mapping on, + // dynamic port allocation off), so existing clusters are unaffected until + // they opt in. Raising the port budget and enabling dynamic port + // allocation avoids source-port exhaustion for pods that open many + // concurrent outbound connections (dropped SYNs surface downstream as + // dial i/o timeouts). + MinPortsPerVm *int `json:"minPortsPerVm,omitempty" yaml:"minPortsPerVm,omitempty"` + MaxPortsPerVm *int `json:"maxPortsPerVm,omitempty" yaml:"maxPortsPerVm,omitempty"` + // DynamicPortAllocation lets a VM scale its NAT ports between min and max + // on demand. GCP requires endpoint-independent mapping to be off when it is + // enabled, and both port bounds to be powers of two. + DynamicPortAllocation *bool `json:"dynamicPortAllocation,omitempty" yaml:"dynamicPortAllocation,omitempty"` + // EndpointIndependentMapping toggles NAT EIM (default true). Must be false + // to use dynamic port allocation. + EndpointIndependentMapping *bool `json:"endpointIndependentMapping,omitempty" yaml:"endpointIndependentMapping,omitempty"` } type GkeAutopilotTemplate struct { @@ -194,5 +220,47 @@ func (c *ExternalEgressIpConfig) Validate() error { } } + // Validate the effective port bounds (applying the same defaults as + // resolveNatPortSettings) so a lone maxPortsPerVm below the default minimum + // is caught here rather than failing late at the GCP API. + minPorts, maxPorts := DefaultMinPortsPerVm, DefaultMaxPortsPerVm + if c.MinPortsPerVm != nil { + minPorts = *c.MinPortsPerVm + if minPorts < PortsPerVmFloor || minPorts > PortsPerVmCeiling { + return errors.Errorf("minPortsPerVm must be between %d and %d, got %d", PortsPerVmFloor, PortsPerVmCeiling, minPorts) + } + } + if c.MaxPortsPerVm != nil { + maxPorts = *c.MaxPortsPerVm + if maxPorts < PortsPerVmFloor || maxPorts > PortsPerVmCeiling { + return errors.Errorf("maxPortsPerVm must be between %d and %d, got %d", PortsPerVmFloor, PortsPerVmCeiling, maxPorts) + } + } + if maxPorts < minPorts { + return errors.Errorf("effective maxPortsPerVm (%d) must be >= minPortsPerVm (%d)", maxPorts, minPorts) + } + + if c.DynamicPortAllocation != nil && *c.DynamicPortAllocation { + // GCP rejects dynamic port allocation together with endpoint-independent + // mapping, needs both bounds to be powers of two, and needs a real range + // (max strictly greater than min). + if c.EndpointIndependentMapping == nil || *c.EndpointIndependentMapping { + return errors.New("dynamicPortAllocation requires endpointIndependentMapping: false") + } + if !isPowerOfTwo(minPorts) { + return errors.Errorf("minPortsPerVm must be a power of two when dynamicPortAllocation is enabled, got %d", minPorts) + } + if !isPowerOfTwo(maxPorts) { + return errors.Errorf("maxPortsPerVm must be a power of two when dynamicPortAllocation is enabled, got %d", maxPorts) + } + if maxPorts <= minPorts { + return errors.Errorf("effective maxPortsPerVm (%d) must be > minPortsPerVm (%d) when dynamicPortAllocation is enabled", maxPorts, minPorts) + } + } + return nil } + +func isPowerOfTwo(n int) bool { + return n > 0 && n&(n-1) == 0 +} diff --git a/pkg/clouds/gcloud/postgres.go b/pkg/clouds/gcloud/postgres.go index 9b8fd215..2a16d53d 100644 --- a/pkg/clouds/gcloud/postgres.go +++ b/pkg/clouds/gcloud/postgres.go @@ -3,7 +3,13 @@ package gcloud -import "github.com/simple-container-com/api/pkg/api" +import ( + "strings" + + "github.com/pkg/errors" + + "github.com/simple-container-com/api/pkg/api" +) const ResourceTypePostgresGcpCloudsql = "gcp-cloudsql-postgres" @@ -34,6 +40,20 @@ type PostgresGcpCloudsqlConfig struct { AvailabilityType *string `json:"availabilityType,omitempty" yaml:"availabilityType,omitempty"` // ZONAL or REGIONAL // SSL RequireSsl *bool `json:"requireSsl,omitempty" yaml:"requireSsl,omitempty"` + // PrivateNetwork: when set to a VPC network resource path + // (projects/{project}/global/networks/{vpc}) the instance is given a + // private IP on that network. Requires Private Services Access (a + // servicenetworking peering range) to already exist on the VPC. Adding it + // while the public IP stays on is a no-cutover step: the instance gains a + // private IP but the in-cluster proxy keeps using the public endpoint until + // publicIpEnabled is set false (see UsesPrivateIpProxy). + PrivateNetwork *string `json:"privateNetwork,omitempty" yaml:"privateNetwork,omitempty"` + // PublicIpEnabled toggles the instance's public IPv4 address (default true + // to preserve existing authorized networks). Setting it false requires + // privateNetwork and switches the in-cluster cloud-sql-proxy to dial the + // private IP (--private-ip). Do this only once the private path is verified + // reachable, since the proxy has no public fallback. + PublicIpEnabled *bool `json:"publicIpEnabled,omitempty" yaml:"publicIpEnabled,omitempty"` // Resource adoption fields Adopt bool `json:"adopt,omitempty" yaml:"adopt,omitempty"` InstanceName string `json:"instanceName,omitempty" yaml:"instanceName,omitempty"` @@ -46,6 +66,36 @@ type ProvisionRuntimeConfig struct { ResourceName string `json:"resourceName" yaml:"resourceName"` // allows to run init db users jobs on kube jobs (must reference resource name where we can obtain kubeconfig from, e.g. gke-autopilot-cluster) } +// HasPrivateNetwork reports whether a non-empty private VPC network is configured. +func (c *PostgresGcpCloudsqlConfig) HasPrivateNetwork() bool { + return c.PrivateNetwork != nil && *c.PrivateNetwork != "" +} + +// UsesPrivateIpProxy reports whether the in-cluster cloud-sql-proxy should dial +// the instance's private IP. The proxy switches to private only once the public +// IP is disabled, so adding privateNetwork while the public IP stays on does not +// touch the proxy. +func (c *PostgresGcpCloudsqlConfig) UsesPrivateIpProxy() bool { + return c.PublicIpEnabled != nil && !*c.PublicIpEnabled +} + +// Validate checks Cloud SQL config invariants that would otherwise surface as +// opaque failures at the GCP API. +func (c *PostgresGcpCloudsqlConfig) Validate() error { + if c.AvailabilityType != nil && *c.AvailabilityType != "ZONAL" && *c.AvailabilityType != "REGIONAL" { + return errors.Errorf("availabilityType must be ZONAL or REGIONAL, got %q", *c.AvailabilityType) + } + if c.PrivateNetwork != nil && *c.PrivateNetwork != "" && !strings.HasPrefix(*c.PrivateNetwork, "projects/") { + return errors.Errorf("privateNetwork must be a full VPC network path like 'projects/{project}/global/networks/{vpc}', got %q", *c.PrivateNetwork) + } + // Disabling the public IP without a private network would leave the instance + // unreachable by the cloud-sql-proxy. + if c.PublicIpEnabled != nil && !*c.PublicIpEnabled && !c.HasPrivateNetwork() { + return errors.New("publicIpEnabled: false requires privateNetwork to be set") + } + return nil +} + func PostgresqlGcpCloudsqlReadConfig(config *api.Config) (api.Config, error) { return api.ConvertConfig(config, &PostgresGcpCloudsqlConfig{}) } diff --git a/pkg/clouds/pulumi/gcp/cloudsql_proxy.go b/pkg/clouds/pulumi/gcp/cloudsql_proxy.go index c62502e4..2f14b397 100644 --- a/pkg/clouds/pulumi/gcp/cloudsql_proxy.go +++ b/pkg/clouds/pulumi/gcp/cloudsql_proxy.go @@ -81,6 +81,9 @@ type CloudSQLProxyArgs struct { KubeProvider *sdkK8s.Provider Metadata *metav1.ObjectMetaArgs TimeoutSec int + // PrivateIp makes the proxy dial the instance's private IP (--private-ip) + // instead of the public endpoint. + PrivateIp bool } type CloudSQLProxy struct { @@ -112,7 +115,7 @@ func NewCloudsqlProxy(ctx *sdk.Context, args CloudSQLProxyArgs, opts ...sdk.Reso return nil, err } - proxyContainer := cloudsqlProxyContainer(sqlProxySecret, args.DBInstance, args.TimeoutSec) + proxyContainer := cloudsqlProxyContainer(sqlProxySecret, args.DBInstance, args.PrivateIp, args.TimeoutSec) return &CloudSQLProxy{ ProxyContainer: proxyContainer, @@ -127,28 +130,33 @@ func NewCloudsqlProxy(ctx *sdk.Context, args CloudSQLProxyArgs, opts ...sdk.Reso // backs the startup probe that gates the app containers. const cloudSQLProxyHealthPort = 9090 -func cloudsqlProxyContainer(credsSecret *v1.Secret, dbInstance PostgresDBInstanceArgs, timeout int) sdk.Output { +func cloudsqlProxyContainer(credsSecret *v1.Secret, dbInstance PostgresDBInstanceArgs, privateIp bool, timeout int) sdk.Output { return sdk.All(credsSecret.Metadata.Name(), dbInstance.Project, dbInstance.Region, dbInstance.InstanceName).ApplyT(func(all []interface{}) v1.ContainerArgs { secretName := all[0].(*string) project := all[1].(string) region := all[2].(string) instanceName := all[3].(string) - return cloudsqlProxyContainerArgs(lo.FromPtr(secretName), project, region, instanceName, timeout) + return cloudsqlProxyContainerArgs(lo.FromPtr(secretName), project, region, instanceName, privateIp, timeout) }).(v1.ContainerOutput) } // cloudsqlProxyCommandArgs returns the proxy entrypoint. timeout == 0 is the long-lived // runtime proxy (with its health server enabled); timeout > 0 is the init-Job proxy, // shell-wrapped to self-kill after `timeout`s so a RestartPolicy: Never Job can complete. -func cloudsqlProxyCommandArgs(project, region, instanceName string, timeout int) (string, []string) { +func cloudsqlProxyCommandArgs(project, region, instanceName string, privateIp bool, timeout int) (string, []string) { command := "/cloud-sql-proxy" args := []string{ "--address", "0.0.0.0", "--structured-logs", + } + if privateIp { + args = append(args, "--private-ip") + } + args = append(args, "--credentials-file=/var/run/secrets/cloudsql/credentials.json", fmt.Sprintf("%s:%s:%s", project, region, instanceName), - } + ) if timeout > 0 { return "sh", []string{ @@ -180,8 +188,8 @@ func cloudsqlProxyCommandArgs(project, region, instanceName string, timeout int) // timeout == 0 yields a native sidecar (RestartPolicy: Always + startup probe) so the app // containers don't start before the proxy is listening. timeout > 0 (init-Job) stays an // ordinary terminating container -- it must NOT be a native sidecar or the Job would hang. -func cloudsqlProxyContainerArgs(secretName, project, region, instanceName string, timeout int) v1.ContainerArgs { - command, args := cloudsqlProxyCommandArgs(project, region, instanceName, timeout) +func cloudsqlProxyContainerArgs(secretName, project, region, instanceName string, privateIp bool, timeout int) v1.ContainerArgs { + command, args := cloudsqlProxyCommandArgs(project, region, instanceName, privateIp, timeout) container := v1.ContainerArgs{ Name: sdk.String("cloudsql-proxy"), diff --git a/pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go b/pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go index f2874ef6..7a7b8580 100644 --- a/pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go +++ b/pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go @@ -19,7 +19,7 @@ import ( // probe can gate the app containers -- otherwise the app dials localhost:5432 before the // proxy is listening and logs connection-refused on every pod (re)start. func TestCloudsqlProxyCommandArgs_RuntimeEnablesHealthCheck(t *testing.T) { - cmd, args := cloudsqlProxyCommandArgs("proj", "europe-north1", "inst", 0) + cmd, args := cloudsqlProxyCommandArgs("proj", "europe-north1", "inst", false, 0) assert.Equal(t, "/cloud-sql-proxy", cmd, "runtime proxy runs the binary directly (no shell wrapper)") assert.Contains(t, args, "--health-check", "runtime proxy must expose its health server for the startup probe") @@ -36,10 +36,41 @@ func TestCloudsqlProxyCommandArgs_RuntimeEnablesHealthCheck(t *testing.T) { "credentials flag must match the mounted secret path") } +// --private-ip must be emitted only when requested, for both the runtime sidecar and the +// init-Job proxy, so a private-IP-only instance is reachable while the default (public) +// path is unchanged. +func TestCloudsqlProxyCommandArgs_PrivateIp(t *testing.T) { + _, pub := cloudsqlProxyCommandArgs("proj", "reg", "inst", false, 0) + assert.NotContains(t, pub, "--private-ip", "default (public) path must not pass --private-ip") + + _, priv := cloudsqlProxyCommandArgs("proj", "reg", "inst", true, 0) + assert.Contains(t, priv, "--private-ip", "private path must pass --private-ip") + assert.Contains(t, priv, "proj:reg:inst", "instance connection name must be preserved") + + // init-Job proxy (timeout>0) is shell-wrapped; the flag must land inside the script. + _, initArgs := cloudsqlProxyCommandArgs("proj", "reg", "inst", true, 30) + require.GreaterOrEqual(t, len(initArgs), 2) + assert.Contains(t, initArgs[1], "--private-ip", "init-Job proxy must also dial the private IP") +} + +// The container-args builder must thread privateIp into the rendered Args — a bug +// dropping it in the delegation to cloudsqlProxyCommandArgs would pass the +// command-args test above but ship a public-dialing sidecar. +func TestCloudsqlProxyContainerArgs_PrivateIpThreaded(t *testing.T) { + c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", true, 0) + args, ok := c.Args.(sdk.StringArray) + require.True(t, ok, "Args should be a pulumi.StringArray") + assert.Contains(t, args, sdk.StringInput(sdk.String("--private-ip")), "container args must carry --private-ip when privateIp=true") + + cNoPriv := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", false, 0) + argsNoPriv := cNoPriv.Args.(sdk.StringArray) + assert.NotContains(t, argsNoPriv, sdk.StringInput(sdk.String("--private-ip"))) +} + // The init-Job proxy runs in a RestartPolicy: Never pod; it must self-terminate or the // Job never completes. It must stay shell-wrapped and must NOT enable the health server. func TestCloudsqlProxyCommandArgs_InitJobSelfKills(t *testing.T) { - cmd, args := cloudsqlProxyCommandArgs("proj", "europe-north1", "inst", 30) + cmd, args := cloudsqlProxyCommandArgs("proj", "europe-north1", "inst", false, 30) assert.Equal(t, "sh", cmd, "init-Job proxy must be shell-wrapped so it can self-terminate") require.GreaterOrEqual(t, len(args), 2) @@ -56,7 +87,7 @@ func TestCloudsqlProxyCommandArgs_InitJobSelfKills(t *testing.T) { // startup/readiness/liveness probes. This is what eliminates the startup race and lets a // hung-but-alive proxy self-heal. func TestCloudsqlProxyContainerArgs_RuntimeIsNativeSidecar(t *testing.T) { - c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", 0) + c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", false, 0) assert.Equal(t, sdk.String("Always"), c.RestartPolicy, "runtime proxy must be a native sidecar (init container, RestartPolicy: Always)") @@ -69,7 +100,7 @@ func TestCloudsqlProxyContainerArgs_RuntimeIsNativeSidecar(t *testing.T) { // Init-Job proxy must NOT be a native sidecar: RestartPolicy: Always on a Job's container // would keep the Job from ever completing, and it serves no health endpoints. func TestCloudsqlProxyContainerArgs_InitJobIsNotSidecar(t *testing.T) { - c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", 30) + c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", false, 30) assert.Nil(t, c.RestartPolicy, "init-Job proxy must not carry RestartPolicy: Always -- it would hang the Job") @@ -84,7 +115,7 @@ func TestCloudsqlProxyContainerArgs_InitJobIsNotSidecar(t *testing.T) { // Init then restarts it. The agreement assertions (probe Port == declared port Name) are // what actually defend the named-port linkage the whole sidecar depends on. func TestCloudsqlProxyContainerArgs_RuntimeProbeWiring(t *testing.T) { - c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", 0) + c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", false, 0) ports := c.Ports.(v1.ContainerPortArray) require.Len(t, ports, 1) @@ -113,7 +144,7 @@ func TestCloudsqlProxyContainerArgs_RuntimeProbeWiring(t *testing.T) { // secret name (the credential Volume that compute_proc.go appends derives from the same // Metadata.Name(), so a drift breaks `--credentials-file` auth). func TestCloudsqlProxyContainerArgs_MountsCredentialSecret(t *testing.T) { - c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", 0) + c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", false, 0) mounts := c.VolumeMounts.(v1.VolumeMountArray) require.Len(t, mounts, 1) @@ -145,7 +176,7 @@ func TestAttachCloudsqlProxyAsNativeSidecar_LandsInInitContainers(t *testing.T) // gates pod readiness, KEP-753). Generous timeout + bigger CPU request keep // transient starvation from dropping the whole pod out of rotation. func TestCloudsqlProxyContainerArgs_ProbesTolerantToStarvation(t *testing.T) { - c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", 0) + c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", false, 0) rp := c.ReadinessProbe.(*v1.ProbeArgs) assert.Equal(t, sdk.IntPtr(10), rp.TimeoutSeconds) diff --git a/pkg/clouds/pulumi/gcp/compute_proc.go b/pkg/clouds/pulumi/gcp/compute_proc.go index 170f8a30..0c3c0fe2 100644 --- a/pkg/clouds/pulumi/gcp/compute_proc.go +++ b/pkg/clouds/pulumi/gcp/compute_proc.go @@ -284,6 +284,7 @@ func createCloudsqlProxy(ctx *sdk.Context, params appendParams, namespaceOutput GcpProvider: params.gcpProvider, KubeProvider: params.kubeProvider, Metadata: cloudsqlProxyMetaFromOutput(namespaceOutput, cloudsqlProxyName, params), + PrivateIp: params.config.UsesPrivateIpProxy(), }) if err != nil { return nil, err @@ -379,6 +380,7 @@ func createUserForDatabase(ctx *sdk.Context, userName, dbName string, params app KubeProvider: params.kubeProvider, TimeoutSec: MaxInitSQLTimeSec, Metadata: cloudsqlProxyMetaFromOutput(namespaceOutput, cloudsqlProxyName, params), + PrivateIp: params.config.UsesPrivateIpProxy(), }, sdk.DependsOn([]sdk.Resource{sc})) if err != nil { return errors.Wrapf(err, "failed to init cloudsql proxy") diff --git a/pkg/clouds/pulumi/gcp/egress_nat_test.go b/pkg/clouds/pulumi/gcp/egress_nat_test.go new file mode 100644 index 00000000..e5f2dcb2 --- /dev/null +++ b/pkg/clouds/pulumi/gcp/egress_nat_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) Simple Container + +package gcp + +import ( + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + + "github.com/simple-container-com/api/pkg/clouds/gcloud" +) + +// A nil or empty egress config must reproduce the historical Cloud NAT defaults +// exactly, so existing clusters are unchanged until they opt in. +func TestResolveNatPortSettings_Defaults(t *testing.T) { + for _, cfg := range []*gcloud.ExternalEgressIpConfig{nil, {Enabled: true}} { + s := resolveNatPortSettings(cfg) + assert.Equal(t, gcloud.DefaultMinPortsPerVm, s.minPortsPerVm) + assert.Equal(t, gcloud.DefaultMaxPortsPerVm, s.maxPortsPerVm) + assert.True(t, s.endpointIndependentMapping) + assert.False(t, s.dynamicPortAllocation) + } +} + +func TestResolveNatPortSettings_Overrides(t *testing.T) { + s := resolveNatPortSettings(&gcloud.ExternalEgressIpConfig{ + Enabled: true, + MinPortsPerVm: lo.ToPtr(1024), + MaxPortsPerVm: lo.ToPtr(4096), + DynamicPortAllocation: lo.ToPtr(true), + EndpointIndependentMapping: lo.ToPtr(false), + }) + assert.Equal(t, 1024, s.minPortsPerVm) + assert.Equal(t, 4096, s.maxPortsPerVm) + assert.False(t, s.endpointIndependentMapping) + assert.True(t, s.dynamicPortAllocation) +} + +// A single-field override must not drop the other field's default. +func TestResolveNatPortSettings_PartialOverride(t *testing.T) { + s := resolveNatPortSettings(&gcloud.ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(1024)}) + assert.Equal(t, 1024, s.minPortsPerVm) + assert.Equal(t, gcloud.DefaultMaxPortsPerVm, s.maxPortsPerVm) +} + +// Enabling dynamic port allocation must force endpoint-independent mapping off in +// the resolved settings even if the config left EIM at its default, so the NAT +// args stay acceptable to GCP regardless of validation order. +func TestResolveNatPortSettings_DpaForcesEimOff(t *testing.T) { + s := resolveNatPortSettings(&gcloud.ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true)}) + assert.True(t, s.dynamicPortAllocation) + assert.False(t, s.endpointIndependentMapping) +} diff --git a/pkg/clouds/pulumi/gcp/gke_autopilot.go b/pkg/clouds/pulumi/gcp/gke_autopilot.go index 3a74ea96..c536f436 100644 --- a/pkg/clouds/pulumi/gcp/gke_autopilot.go +++ b/pkg/clouds/pulumi/gcp/gke_autopilot.go @@ -471,7 +471,7 @@ func setupCloudNAT( out.Router = router // Step 3: Create Cloud NAT (configured for cluster's specific subnet) - nat, err := createCloudNat(ctx, clusterName, router, staticIp, region, cluster, subnetwork, opts, params) + nat, err := createCloudNat(ctx, clusterName, router, staticIp, region, cluster, subnetwork, gkeInput.ExternalEgressIp, opts, params) if err != nil { return errors.Wrap(err, "failed to create Cloud NAT") } @@ -545,6 +545,47 @@ func createCloudRouter( }, opts...) } +// natPortSettings holds the resolved Cloud NAT port/mapping configuration. +type natPortSettings struct { + minPortsPerVm int + maxPortsPerVm int + endpointIndependentMapping bool + dynamicPortAllocation bool +} + +// resolveNatPortSettings applies the egress config over the historical defaults +// (64 min ports, endpoint-independent mapping on, dynamic port allocation off), +// so an unset or nil config reproduces the prior behaviour exactly. +func resolveNatPortSettings(cfg *gcloud.ExternalEgressIpConfig) natPortSettings { + s := natPortSettings{ + minPortsPerVm: gcloud.DefaultMinPortsPerVm, + maxPortsPerVm: gcloud.DefaultMaxPortsPerVm, + endpointIndependentMapping: true, + dynamicPortAllocation: false, + } + if cfg == nil { + return s + } + if cfg.MinPortsPerVm != nil { + s.minPortsPerVm = *cfg.MinPortsPerVm + } + if cfg.MaxPortsPerVm != nil { + s.maxPortsPerVm = *cfg.MaxPortsPerVm + } + if cfg.EndpointIndependentMapping != nil { + s.endpointIndependentMapping = *cfg.EndpointIndependentMapping + } + if cfg.DynamicPortAllocation != nil { + s.dynamicPortAllocation = *cfg.DynamicPortAllocation + } + // GCP rejects dynamic port allocation with endpoint-independent mapping on; + // keep the resolved settings self-consistent regardless of validation order. + if s.dynamicPortAllocation { + s.endpointIndependentMapping = false + } + return s +} + // createCloudNat creates a Cloud NAT gateway func createCloudNat( ctx *sdk.Context, @@ -554,6 +595,7 @@ func createCloudNat( region string, cluster *container.Cluster, subnetwork sdk.StringInput, // Optional: specific subnet for private VPC + egressCfg *gcloud.ExternalEgressIpConfig, opts []sdk.ResourceOption, params pApi.ProvisionParams, ) (*compute.RouterNat, error) { @@ -563,6 +605,8 @@ func createCloudNat( // Create array of static IP references for NAT natIps := sdk.StringArray{staticIp.SelfLink} + ports := resolveNatPortSettings(egressCfg) + // Configure NAT for specific GKE cluster subnet instead of all subnets natArgs := &compute.RouterNatArgs{ Name: sdk.String(natName), @@ -573,9 +617,9 @@ func createCloudNat( NatIpAllocateOption: sdk.String("MANUAL_ONLY"), // Use only the IPs we specify in NatIps NatIps: natIps, // Our static IP address - // Port allocation - production-ready defaults - MinPortsPerVm: sdk.Int(64), - MaxPortsPerVm: sdk.Int(65536), + // Port allocation - defaults preserve prior behaviour; tunable via egress config + MinPortsPerVm: sdk.Int(ports.minPortsPerVm), + MaxPortsPerVm: sdk.Int(ports.maxPortsPerVm), // Logging configuration - errors only for cost optimization LogConfig: &compute.RouterNatLogConfigArgs{ @@ -583,8 +627,14 @@ func createCloudNat( Filter: sdk.String("ERRORS_ONLY"), }, - // Enable endpoint independent mapping for better performance - EnableEndpointIndependentMapping: sdk.Bool(true), + EnableEndpointIndependentMapping: sdk.Bool(ports.endpointIndependentMapping), + } + + // Only send EnableDynamicPortAllocation when enabling it: the historical + // default path left the field unset, so keep it absent to avoid a spurious + // resource update on existing NATs. + if ports.dynamicPortAllocation { + natArgs.EnableDynamicPortAllocation = sdk.Bool(true) } // Configure NAT to target ALL IP ranges (primary + secondary) for GKE pods @@ -631,7 +681,7 @@ func createCloudNat( params.Log.Info(ctx.Context(), " - IP Allocation: MANUAL_ONLY (using static IP %v)", staticIp.Name.ToStringOutput()) params.Log.Info(ctx.Context(), " - Source Ranges: LIST_OF_SUBNETWORKS with ALL_IP_RANGES") params.Log.Info(ctx.Context(), " - Subnet: default (includes primary + secondary ranges)") - params.Log.Info(ctx.Context(), " - Port Range: %d-%d per VM", 64, 65536) + params.Log.Info(ctx.Context(), " - Port Range: %d-%d per VM (dynamic=%t, endpointIndependentMapping=%t)", ports.minPortsPerVm, ports.maxPortsPerVm, ports.dynamicPortAllocation, ports.endpointIndependentMapping) params.Log.Info(ctx.Context(), "") params.Log.Info(ctx.Context(), "🔍 Troubleshooting Steps if egress IP is still wrong:") params.Log.Info(ctx.Context(), " 1. Check GCP Console → VPC Network → Cloud NAT") diff --git a/pkg/clouds/pulumi/gcp/postgres.go b/pkg/clouds/pulumi/gcp/postgres.go index f2b34f69..b3ae0848 100644 --- a/pkg/clouds/pulumi/gcp/postgres.go +++ b/pkg/clouds/pulumi/gcp/postgres.go @@ -29,10 +29,8 @@ func Postgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params return nil, errors.Errorf("failed to convert postgresql config for %q", input.Descriptor.Type) } - if pgCfg.AvailabilityType != nil { - if *pgCfg.AvailabilityType != "ZONAL" && *pgCfg.AvailabilityType != "REGIONAL" { - return nil, errors.Errorf("availabilityType must be ZONAL or REGIONAL, got %q", *pgCfg.AvailabilityType) - } + if err := pgCfg.Validate(); err != nil { + return nil, err } // Handle resource adoption - exit early if adopting @@ -82,6 +80,9 @@ func Postgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params DeletionProtection: sdk.Bool(pgCfg.DeletionProtection != nil && *pgCfg.DeletionProtection), }, sdk.Provider(params.Provider)) if err != nil { + if pgCfg.HasPrivateNetwork() { + return nil, errors.Wrapf(err, "failed to provision postgres instance %q (privateNetwork requires a Private Services Access range and servicenetworking connection on the VPC)", postgresName) + } return nil, errors.Wrapf(err, "failed to provision postgres instance %q", postgresName) } @@ -111,22 +112,31 @@ func backupConfiguration(pgCfg *gcloud.PostgresGcpCloudsqlConfig) *sql.DatabaseI return args } -// ipConfiguration returns IP settings only when requireSsl is explicitly set. -// When nil, returns nil so Pulumi leaves existing IP configuration unchanged. +// ipConfiguration returns IP settings only when they must actually be managed: +// requireSsl set, a private network set, or the public IP explicitly disabled. +// When none apply it returns nil so Pulumi leaves existing IP configuration +// (and any out-of-band authorized networks) untouched — in particular +// publicIpEnabled:true stays a no-op since public IPv4 is already the default. // Uses SslMode (Pulumi GCP SDK v8) instead of deprecated RequireSsl. -// Preserves Ipv4Enabled=true to avoid wiping existing authorized networks. func ipConfiguration(pgCfg *gcloud.PostgresGcpCloudsqlConfig) *sql.DatabaseInstanceSettingsIpConfigurationArgs { - if pgCfg.RequireSsl == nil { + publicEnabled := pgCfg.PublicIpEnabled == nil || *pgCfg.PublicIpEnabled + if pgCfg.RequireSsl == nil && !pgCfg.HasPrivateNetwork() && publicEnabled { return nil } - sslMode := "ALLOW_UNENCRYPTED_AND_ENCRYPTED" - if *pgCfg.RequireSsl { - sslMode = "ENCRYPTED_ONLY" + args := &sql.DatabaseInstanceSettingsIpConfigurationArgs{ + Ipv4Enabled: sdk.Bool(publicEnabled), } - return &sql.DatabaseInstanceSettingsIpConfigurationArgs{ - Ipv4Enabled: sdk.Bool(true), - SslMode: sdk.String(sslMode), + if pgCfg.RequireSsl != nil { + sslMode := "ALLOW_UNENCRYPTED_AND_ENCRYPTED" + if *pgCfg.RequireSsl { + sslMode = "ENCRYPTED_ONLY" + } + args.SslMode = sdk.String(sslMode) } + if pgCfg.HasPrivateNetwork() { + args.PrivateNetwork = sdk.String(*pgCfg.PrivateNetwork) + } + return args } func toPostgresRootPasswordExport(resName string) string { diff --git a/pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go b/pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go new file mode 100644 index 00000000..bb89c056 --- /dev/null +++ b/pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) Simple Container + +package gcp + +import ( + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + + "github.com/simple-container-com/api/pkg/clouds/gcloud" +) + +// With nothing that must be managed set, ipConfiguration returns nil so Pulumi +// leaves the instance's IP configuration (and authorized networks) untouched. +func TestIpConfiguration_NilWhenUnset(t *testing.T) { + assert.Nil(t, ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{})) +} + +// publicIpEnabled:true is the existing default, so it must stay a no-op and not +// start managing (and thereby wiping) the IP configuration. +func TestIpConfiguration_PublicEnabledTrueIsNoop(t *testing.T) { + assert.Nil(t, ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(true)})) +} + +// An empty privateNetwork string is treated as unset. +func TestIpConfiguration_EmptyPrivateNetworkIsNoop(t *testing.T) { + assert.Nil(t, ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("")})) +} + +// requireSsl-only must stay byte-identical to the prior behaviour. +func TestIpConfiguration_RequireSslTrueUnchanged(t *testing.T) { + args := ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{RequireSsl: lo.ToPtr(true)}) + require.NotNil(t, args) + assert.Equal(t, sdk.Bool(true), args.Ipv4Enabled) + assert.Equal(t, sdk.String("ENCRYPTED_ONLY"), args.SslMode) + assert.Nil(t, args.PrivateNetwork) +} + +func TestIpConfiguration_RequireSslFalse(t *testing.T) { + args := ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{RequireSsl: lo.ToPtr(false)}) + require.NotNil(t, args) + assert.Equal(t, sdk.Bool(true), args.Ipv4Enabled) + assert.Equal(t, sdk.String("ALLOW_UNENCRYPTED_AND_ENCRYPTED"), args.SslMode) +} + +// A private network keeps the public IP on by default (safe migration) and wires +// the private network path. +func TestIpConfiguration_PrivateNetworkKeepsPublicByDefault(t *testing.T) { + net := "projects/p/global/networks/vpc" + args := ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr(net)}) + require.NotNil(t, args) + assert.Equal(t, sdk.Bool(true), args.Ipv4Enabled) + assert.Equal(t, sdk.String(net), args.PrivateNetwork) +} + +// Explicitly disabling the public IP (only valid alongside a private network) +// sets Ipv4Enabled false. +func TestIpConfiguration_PublicDisabled(t *testing.T) { + net := "projects/p/global/networks/vpc" + args := ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{ + PrivateNetwork: lo.ToPtr(net), + PublicIpEnabled: lo.ToPtr(false), + }) + require.NotNil(t, args) + assert.Equal(t, sdk.Bool(false), args.Ipv4Enabled) + assert.Equal(t, sdk.String(net), args.PrivateNetwork) +}