From 08921041b2f3aebd1373acc30111567c340c64d7 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Wed, 22 Jul 2026 11:11:13 +0300 Subject: [PATCH 1/3] feat(gcp): tunable Cloud NAT ports and Cloud SQL private IP Add opt-in Cloud NAT port/mapping tuning to externalEgressIp (minPortsPerVm, maxPortsPerVm, dynamicPortAllocation, endpointIndependentMapping) and Cloud SQL private-IP support (privateNetwork, publicIpEnabled). The in-cluster cloud-sql-proxy dials --private-ip whenever a private network is configured. All new fields are optional and default to the previous behaviour (64 min ports, endpoint-independent mapping on, dynamic port allocation off, public IPv4 on), so existing clusters and instances are unchanged until they opt in. Rationale: high-egress workloads that open many concurrent outbound connections can exhaust NAT source ports under the fixed 64-port, endpoint-independent defaults; dropped SYNs then show up as dial i/o timeouts on new connections. The new knobs allow raising the port budget and enabling dynamic port allocation, or removing the public egress path for the database entirely via private IP. Signed-off-by: Dmitrii Creed --- pkg/clouds/gcloud/gke_autopilot.go | 45 +++++++ pkg/clouds/gcloud/postgres.go | 10 ++ pkg/clouds/pulumi/gcp/cloudsql_proxy.go | 22 ++-- pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go | 31 +++-- pkg/clouds/pulumi/gcp/compute_proc.go | 2 + pkg/clouds/pulumi/gcp/egress_nat_test.go | 110 ++++++++++++++++++ pkg/clouds/pulumi/gcp/gke_autopilot.go | 53 +++++++-- pkg/clouds/pulumi/gcp/postgres.go | 35 ++++-- .../pulumi/gcp/postgres_ipconfig_test.go | 57 +++++++++ 9 files changed, 333 insertions(+), 32 deletions(-) create mode 100644 pkg/clouds/pulumi/gcp/egress_nat_test.go create mode 100644 pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go diff --git a/pkg/clouds/gcloud/gke_autopilot.go b/pkg/clouds/gcloud/gke_autopilot.go index 2897cd4d..d69b9000 100644 --- a/pkg/clouds/gcloud/gke_autopilot.go +++ b/pkg/clouds/gcloud/gke_autopilot.go @@ -48,6 +48,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 +211,33 @@ func (c *ExternalEgressIpConfig) Validate() error { } } + if c.MinPortsPerVm != nil && (*c.MinPortsPerVm < 2 || *c.MinPortsPerVm > 65536) { + return errors.Errorf("minPortsPerVm must be between 2 and 65536, got %d", *c.MinPortsPerVm) + } + if c.MaxPortsPerVm != nil && (*c.MaxPortsPerVm < 2 || *c.MaxPortsPerVm > 65536) { + return errors.Errorf("maxPortsPerVm must be between 2 and 65536, got %d", *c.MaxPortsPerVm) + } + if c.MinPortsPerVm != nil && c.MaxPortsPerVm != nil && *c.MaxPortsPerVm < *c.MinPortsPerVm { + return errors.Errorf("maxPortsPerVm (%d) must be >= minPortsPerVm (%d)", *c.MaxPortsPerVm, *c.MinPortsPerVm) + } + + if c.DynamicPortAllocation != nil && *c.DynamicPortAllocation { + // GCP rejects dynamic port allocation together with endpoint-independent + // mapping, and requires both port bounds to be powers of two. + if c.EndpointIndependentMapping == nil || *c.EndpointIndependentMapping { + return errors.New("dynamicPortAllocation requires endpointIndependentMapping: false") + } + if c.MinPortsPerVm != nil && !isPowerOfTwo(*c.MinPortsPerVm) { + return errors.Errorf("minPortsPerVm must be a power of two when dynamicPortAllocation is enabled, got %d", *c.MinPortsPerVm) + } + if c.MaxPortsPerVm != nil && !isPowerOfTwo(*c.MaxPortsPerVm) { + return errors.Errorf("maxPortsPerVm must be a power of two when dynamicPortAllocation is enabled, got %d", *c.MaxPortsPerVm) + } + } + 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..098c7f8e 100644 --- a/pkg/clouds/gcloud/postgres.go +++ b/pkg/clouds/gcloud/postgres.go @@ -34,6 +34,16 @@ type PostgresGcpCloudsqlConfig struct { AvailabilityType *string `json:"availabilityType,omitempty" yaml:"availabilityType,omitempty"` // ZONAL or REGIONAL // SSL RequireSsl *bool `json:"requireSsl,omitempty" yaml:"requireSsl,omitempty"` + // Private IP: when set to a VPC network resource path + // (projects/{project}/global/networks/{vpc}) the instance is given a + // private IP on that network and the in-cluster cloud-sql-proxy dials it + // via --private-ip instead of the public endpoint. Requires Private + // Services Access (a servicenetworking peering range) on the VPC. + PrivateNetwork *string `json:"privateNetwork,omitempty" yaml:"privateNetwork,omitempty"` + // PublicIpEnabled toggles the instance's public IPv4 address (default true + // to preserve existing authorized networks). Set false only once every + // consumer reaches the instance over private IP. + 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"` 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..9a706447 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,27 @@ 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 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 +73,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 +86,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 +101,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 +130,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 +162,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..84f4d25e 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.PrivateNetwork != nil, }) 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.PrivateNetwork != nil, }, 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..3e6f9302 --- /dev/null +++ b/pkg/clouds/pulumi/gcp/egress_nat_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) Simple Container + +package gcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/simple-container-com/api/pkg/clouds/gcloud" +) + +func natIntPtr(i int) *int { return &i } +func natBoolPtr(b bool) *bool { return &b } + +// 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, 64, s.minPortsPerVm) + assert.Equal(t, 65536, 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: natIntPtr(1024), + MaxPortsPerVm: natIntPtr(4096), + DynamicPortAllocation: natBoolPtr(true), + EndpointIndependentMapping: natBoolPtr(false), + }) + assert.Equal(t, 1024, s.minPortsPerVm) + assert.Equal(t, 4096, s.maxPortsPerVm) + assert.False(t, s.endpointIndependentMapping) + assert.True(t, s.dynamicPortAllocation) +} + +// Dynamic port allocation must be rejected unless endpoint-independent mapping is +// explicitly off and both bounds are powers of two (GCP API constraints). +func TestExternalEgressIpConfig_NatTuningValidation(t *testing.T) { + cases := []struct { + name string + cfg gcloud.ExternalEgressIpConfig + wantErr string + }{ + { + name: "valid dynamic port allocation", + cfg: gcloud.ExternalEgressIpConfig{ + Enabled: true, + MinPortsPerVm: natIntPtr(1024), + MaxPortsPerVm: natIntPtr(8192), + DynamicPortAllocation: natBoolPtr(true), + EndpointIndependentMapping: natBoolPtr(false), + }, + }, + { + name: "valid raised min ports without dpa", + cfg: gcloud.ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: natIntPtr(1000)}, + }, + { + name: "dpa requires eim off when unset", + cfg: gcloud.ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: natBoolPtr(true)}, + wantErr: "endpointIndependentMapping: false", + }, + { + name: "dpa requires eim off when explicitly true", + cfg: gcloud.ExternalEgressIpConfig{ + Enabled: true, DynamicPortAllocation: natBoolPtr(true), EndpointIndependentMapping: natBoolPtr(true), + }, + wantErr: "endpointIndependentMapping: false", + }, + { + name: "dpa min ports must be power of two", + cfg: gcloud.ExternalEgressIpConfig{ + Enabled: true, DynamicPortAllocation: natBoolPtr(true), EndpointIndependentMapping: natBoolPtr(false), + MinPortsPerVm: natIntPtr(1000), + }, + wantErr: "power of two", + }, + { + name: "min ports out of range", + cfg: gcloud.ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: natIntPtr(70000)}, + wantErr: "minPortsPerVm must be between", + }, + { + name: "max below min", + cfg: gcloud.ExternalEgressIpConfig{ + Enabled: true, MinPortsPerVm: natIntPtr(4096), MaxPortsPerVm: natIntPtr(1024), + }, + wantErr: "must be >=", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.cfg.Validate() + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} diff --git a/pkg/clouds/pulumi/gcp/gke_autopilot.go b/pkg/clouds/pulumi/gcp/gke_autopilot.go index 3a74ea96..19f5ef2f 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,42 @@ 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: 64, + maxPortsPerVm: 65536, + 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 + } + return s +} + // createCloudNat creates a Cloud NAT gateway func createCloudNat( ctx *sdk.Context, @@ -554,6 +590,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 +600,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 +612,10 @@ 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), + EnableDynamicPortAllocation: sdk.Bool(ports.dynamicPortAllocation), // Logging configuration - errors only for cost optimization LogConfig: &compute.RouterNatLogConfigArgs{ @@ -583,8 +623,7 @@ func createCloudNat( Filter: sdk.String("ERRORS_ONLY"), }, - // Enable endpoint independent mapping for better performance - EnableEndpointIndependentMapping: sdk.Bool(true), + EnableEndpointIndependentMapping: sdk.Bool(ports.endpointIndependentMapping), } // Configure NAT to target ALL IP ranges (primary + secondary) for GKE pods @@ -631,7 +670,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..9e769002 100644 --- a/pkg/clouds/pulumi/gcp/postgres.go +++ b/pkg/clouds/pulumi/gcp/postgres.go @@ -35,6 +35,12 @@ func Postgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params } } + // Disabling the public IP without a private network would leave the instance + // unreachable by the cloud-sql-proxy. + if pgCfg.PublicIpEnabled != nil && !*pgCfg.PublicIpEnabled && pgCfg.PrivateNetwork == nil { + return nil, errors.New("publicIpEnabled: false requires privateNetwork to be set") + } + // Handle resource adoption - exit early if adopting if pgCfg.Adopt { return AdoptPostgres(ctx, stack, input, params) @@ -111,22 +117,29 @@ 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. -// Uses SslMode (Pulumi GCP SDK v8) instead of deprecated RequireSsl. -// Preserves Ipv4Enabled=true to avoid wiping existing authorized networks. +// ipConfiguration returns IP settings only when one of requireSsl, privateNetwork +// or publicIpEnabled is explicitly set. When all are nil it returns nil so Pulumi +// leaves existing IP configuration unchanged. Uses SslMode (Pulumi GCP SDK v8) +// instead of deprecated RequireSsl. Ipv4Enabled defaults to true to avoid wiping +// existing authorized networks unless publicIpEnabled is explicitly false. func ipConfiguration(pgCfg *gcloud.PostgresGcpCloudsqlConfig) *sql.DatabaseInstanceSettingsIpConfigurationArgs { - if pgCfg.RequireSsl == nil { + if pgCfg.RequireSsl == nil && pgCfg.PrivateNetwork == nil && pgCfg.PublicIpEnabled == nil { return nil } - sslMode := "ALLOW_UNENCRYPTED_AND_ENCRYPTED" - if *pgCfg.RequireSsl { - sslMode = "ENCRYPTED_ONLY" + args := &sql.DatabaseInstanceSettingsIpConfigurationArgs{ + Ipv4Enabled: sdk.Bool(pgCfg.PublicIpEnabled == nil || *pgCfg.PublicIpEnabled), } - 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.PrivateNetwork != nil { + 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..0a48ce81 --- /dev/null +++ b/pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) Simple Container + +package gcp + +import ( + "testing" + + "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" +) + +func ipCfgStrPtr(s string) *string { return &s } +func ipCfgBoolPtr(b bool) *bool { return &b } + +// With none of requireSsl/privateNetwork/publicIpEnabled set, ipConfiguration must +// return nil so Pulumi leaves the instance's IP configuration untouched. +func TestIpConfiguration_NilWhenUnset(t *testing.T) { + assert.Nil(t, ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{})) +} + +// requireSsl-only must stay byte-identical to the prior behaviour: public IPv4 on, +// encrypted SSL, no private network. +func TestIpConfiguration_RequireSslOnlyUnchanged(t *testing.T) { + args := ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{RequireSsl: ipCfgBoolPtr(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) +} + +// 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: ipCfgStrPtr(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) must +// set Ipv4Enabled false. +func TestIpConfiguration_PublicDisabled(t *testing.T) { + net := "projects/p/global/networks/vpc" + args := ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{ + PrivateNetwork: ipCfgStrPtr(net), + PublicIpEnabled: ipCfgBoolPtr(false), + }) + require.NotNil(t, args) + assert.Equal(t, sdk.Bool(false), args.Ipv4Enabled) + assert.Equal(t, sdk.String(net), args.PrivateNetwork) +} From f347d8b0a0b80a7a97a28880d2135a3f654d956e Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Wed, 22 Jul 2026 14:48:37 +0300 Subject: [PATCH 2/3] fix(gcp): harden NAT/private-IP validation and proxy cutover per review Addresses multi-model review of the NAT-tuning + Cloud SQL private-IP change: - NAT validation now checks EFFECTIVE port bounds (defaults applied) against GCP's real range (floor 32, ceiling 65536) and requires max > min under dynamic port allocation, so a lone maxPortsPerVm below the default minimum or a sub-floor value is rejected up front instead of failing late at pulumi up. - resolveNatPortSettings forces endpoint-independent mapping off whenever dynamic port allocation is on, keeping the resolved NAT args self-consistent regardless of validation order. - EnableDynamicPortAllocation is emitted only when true, so the default path is byte-identical to the previous NAT resource (no spurious update on existing clusters). - The in-cluster proxy switches to --private-ip only once the public IP is disabled (UsesPrivateIpProxy), not the instant privateNetwork is set, so adding a private IP while public stays on is a safe no-cutover step. - publicIpEnabled:true is now a genuine no-op in ipConfiguration (was starting to manage the IP block and could wipe out-of-band authorized networks); empty privateNetwork strings are treated as unset. - Cloud SQL config gains a Validate() (availabilityType, privateNetwork format, publicIpEnabled requires privateNetwork) plus HasPrivateNetwork/ UsesPrivateIpProxy helpers; instance-create errors hint at the Private Services Access prerequisite. - Shared port-bound constants; regenerated JSON schemas for the new fields; guide note on NAT port tuning. Added tests for the new validation branches, requireSsl=false, publicIpEnabled no-op, empty private network, the config to proxy derivation, and the container-args --private-ip path. Signed-off-by: Dmitrii Creed --- docs/docs/guides/parent-gcp-gke-autopilot.md | 6 ++ docs/schemas/gcp/gkeautopilotresource.json | 12 +++ .../gcp/postgresgcpcloudsqlconfig.json | 6 ++ pkg/clouds/gcloud/config_validation_test.go | 90 +++++++++++++++++ pkg/clouds/gcloud/gke_autopilot.go | 45 ++++++--- pkg/clouds/gcloud/postgres.go | 54 +++++++++-- pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go | 14 +++ pkg/clouds/pulumi/gcp/compute_proc.go | 4 +- pkg/clouds/pulumi/gcp/egress_nat_test.go | 97 ++++--------------- pkg/clouds/pulumi/gcp/gke_autopilot.go | 21 +++- pkg/clouds/pulumi/gcp/postgres.go | 33 +++---- .../pulumi/gcp/postgres_ipconfig_test.go | 43 +++++--- 12 files changed, 292 insertions(+), 133 deletions(-) create mode 100644 pkg/clouds/gcloud/config_validation_test.go 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/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 d69b9000..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"` @@ -211,27 +220,41 @@ func (c *ExternalEgressIpConfig) Validate() error { } } - if c.MinPortsPerVm != nil && (*c.MinPortsPerVm < 2 || *c.MinPortsPerVm > 65536) { - return errors.Errorf("minPortsPerVm must be between 2 and 65536, got %d", *c.MinPortsPerVm) + // 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 && (*c.MaxPortsPerVm < 2 || *c.MaxPortsPerVm > 65536) { - return errors.Errorf("maxPortsPerVm must be between 2 and 65536, got %d", *c.MaxPortsPerVm) + 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 c.MinPortsPerVm != nil && c.MaxPortsPerVm != nil && *c.MaxPortsPerVm < *c.MinPortsPerVm { - return errors.Errorf("maxPortsPerVm (%d) must be >= minPortsPerVm (%d)", *c.MaxPortsPerVm, *c.MinPortsPerVm) + 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, and requires both port bounds to be powers of two. + // 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 c.MinPortsPerVm != nil && !isPowerOfTwo(*c.MinPortsPerVm) { - return errors.Errorf("minPortsPerVm must be a power of two when dynamicPortAllocation is enabled, got %d", *c.MinPortsPerVm) + 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 c.MaxPortsPerVm != nil && !isPowerOfTwo(*c.MaxPortsPerVm) { - return errors.Errorf("maxPortsPerVm must be a power of two when dynamicPortAllocation is enabled, got %d", *c.MaxPortsPerVm) + if maxPorts <= minPorts { + return errors.Errorf("effective maxPortsPerVm (%d) must be > minPortsPerVm (%d) when dynamicPortAllocation is enabled", maxPorts, minPorts) } } diff --git a/pkg/clouds/gcloud/postgres.go b/pkg/clouds/gcloud/postgres.go index 098c7f8e..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,15 +40,19 @@ type PostgresGcpCloudsqlConfig struct { AvailabilityType *string `json:"availabilityType,omitempty" yaml:"availabilityType,omitempty"` // ZONAL or REGIONAL // SSL RequireSsl *bool `json:"requireSsl,omitempty" yaml:"requireSsl,omitempty"` - // Private IP: when set to a VPC network resource path + // PrivateNetwork: when set to a VPC network resource path // (projects/{project}/global/networks/{vpc}) the instance is given a - // private IP on that network and the in-cluster cloud-sql-proxy dials it - // via --private-ip instead of the public endpoint. Requires Private - // Services Access (a servicenetworking peering range) on the VPC. + // 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). Set false only once every - // consumer reaches the instance over private IP. + // 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"` @@ -56,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_test.go b/pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go index 9a706447..7a7b8580 100644 --- a/pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go +++ b/pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go @@ -53,6 +53,20 @@ func TestCloudsqlProxyCommandArgs_PrivateIp(t *testing.T) { 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) { diff --git a/pkg/clouds/pulumi/gcp/compute_proc.go b/pkg/clouds/pulumi/gcp/compute_proc.go index 84f4d25e..0c3c0fe2 100644 --- a/pkg/clouds/pulumi/gcp/compute_proc.go +++ b/pkg/clouds/pulumi/gcp/compute_proc.go @@ -284,7 +284,7 @@ func createCloudsqlProxy(ctx *sdk.Context, params appendParams, namespaceOutput GcpProvider: params.gcpProvider, KubeProvider: params.kubeProvider, Metadata: cloudsqlProxyMetaFromOutput(namespaceOutput, cloudsqlProxyName, params), - PrivateIp: params.config.PrivateNetwork != nil, + PrivateIp: params.config.UsesPrivateIpProxy(), }) if err != nil { return nil, err @@ -380,7 +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.PrivateNetwork != nil, + 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 index 3e6f9302..e5f2dcb2 100644 --- a/pkg/clouds/pulumi/gcp/egress_nat_test.go +++ b/pkg/clouds/pulumi/gcp/egress_nat_test.go @@ -6,22 +6,19 @@ package gcp import ( "testing" + "github.com/samber/lo" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/simple-container-com/api/pkg/clouds/gcloud" ) -func natIntPtr(i int) *int { return &i } -func natBoolPtr(b bool) *bool { return &b } - // 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, 64, s.minPortsPerVm) - assert.Equal(t, 65536, s.maxPortsPerVm) + assert.Equal(t, gcloud.DefaultMinPortsPerVm, s.minPortsPerVm) + assert.Equal(t, gcloud.DefaultMaxPortsPerVm, s.maxPortsPerVm) assert.True(t, s.endpointIndependentMapping) assert.False(t, s.dynamicPortAllocation) } @@ -30,10 +27,10 @@ func TestResolveNatPortSettings_Defaults(t *testing.T) { func TestResolveNatPortSettings_Overrides(t *testing.T) { s := resolveNatPortSettings(&gcloud.ExternalEgressIpConfig{ Enabled: true, - MinPortsPerVm: natIntPtr(1024), - MaxPortsPerVm: natIntPtr(4096), - DynamicPortAllocation: natBoolPtr(true), - EndpointIndependentMapping: natBoolPtr(false), + 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) @@ -41,70 +38,18 @@ func TestResolveNatPortSettings_Overrides(t *testing.T) { assert.True(t, s.dynamicPortAllocation) } -// Dynamic port allocation must be rejected unless endpoint-independent mapping is -// explicitly off and both bounds are powers of two (GCP API constraints). -func TestExternalEgressIpConfig_NatTuningValidation(t *testing.T) { - cases := []struct { - name string - cfg gcloud.ExternalEgressIpConfig - wantErr string - }{ - { - name: "valid dynamic port allocation", - cfg: gcloud.ExternalEgressIpConfig{ - Enabled: true, - MinPortsPerVm: natIntPtr(1024), - MaxPortsPerVm: natIntPtr(8192), - DynamicPortAllocation: natBoolPtr(true), - EndpointIndependentMapping: natBoolPtr(false), - }, - }, - { - name: "valid raised min ports without dpa", - cfg: gcloud.ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: natIntPtr(1000)}, - }, - { - name: "dpa requires eim off when unset", - cfg: gcloud.ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: natBoolPtr(true)}, - wantErr: "endpointIndependentMapping: false", - }, - { - name: "dpa requires eim off when explicitly true", - cfg: gcloud.ExternalEgressIpConfig{ - Enabled: true, DynamicPortAllocation: natBoolPtr(true), EndpointIndependentMapping: natBoolPtr(true), - }, - wantErr: "endpointIndependentMapping: false", - }, - { - name: "dpa min ports must be power of two", - cfg: gcloud.ExternalEgressIpConfig{ - Enabled: true, DynamicPortAllocation: natBoolPtr(true), EndpointIndependentMapping: natBoolPtr(false), - MinPortsPerVm: natIntPtr(1000), - }, - wantErr: "power of two", - }, - { - name: "min ports out of range", - cfg: gcloud.ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: natIntPtr(70000)}, - wantErr: "minPortsPerVm must be between", - }, - { - name: "max below min", - cfg: gcloud.ExternalEgressIpConfig{ - Enabled: true, MinPortsPerVm: natIntPtr(4096), MaxPortsPerVm: natIntPtr(1024), - }, - wantErr: "must be >=", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - err := tc.cfg.Validate() - if tc.wantErr == "" { - require.NoError(t, err) - return - } - require.Error(t, err) - assert.Contains(t, err.Error(), tc.wantErr) - }) - } +// 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 19f5ef2f..c536f436 100644 --- a/pkg/clouds/pulumi/gcp/gke_autopilot.go +++ b/pkg/clouds/pulumi/gcp/gke_autopilot.go @@ -558,8 +558,8 @@ type natPortSettings struct { // so an unset or nil config reproduces the prior behaviour exactly. func resolveNatPortSettings(cfg *gcloud.ExternalEgressIpConfig) natPortSettings { s := natPortSettings{ - minPortsPerVm: 64, - maxPortsPerVm: 65536, + minPortsPerVm: gcloud.DefaultMinPortsPerVm, + maxPortsPerVm: gcloud.DefaultMaxPortsPerVm, endpointIndependentMapping: true, dynamicPortAllocation: false, } @@ -578,6 +578,11 @@ func resolveNatPortSettings(cfg *gcloud.ExternalEgressIpConfig) natPortSettings 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 } @@ -613,9 +618,8 @@ func createCloudNat( NatIps: natIps, // Our static IP address // Port allocation - defaults preserve prior behaviour; tunable via egress config - MinPortsPerVm: sdk.Int(ports.minPortsPerVm), - MaxPortsPerVm: sdk.Int(ports.maxPortsPerVm), - EnableDynamicPortAllocation: sdk.Bool(ports.dynamicPortAllocation), + MinPortsPerVm: sdk.Int(ports.minPortsPerVm), + MaxPortsPerVm: sdk.Int(ports.maxPortsPerVm), // Logging configuration - errors only for cost optimization LogConfig: &compute.RouterNatLogConfigArgs{ @@ -626,6 +630,13 @@ func createCloudNat( 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 params.Log.Info(ctx.Context(), "🎯 Configuring NAT for ALL IP ranges (primary + secondary)") diff --git a/pkg/clouds/pulumi/gcp/postgres.go b/pkg/clouds/pulumi/gcp/postgres.go index 9e769002..b3ae0848 100644 --- a/pkg/clouds/pulumi/gcp/postgres.go +++ b/pkg/clouds/pulumi/gcp/postgres.go @@ -29,16 +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) - } - } - - // Disabling the public IP without a private network would leave the instance - // unreachable by the cloud-sql-proxy. - if pgCfg.PublicIpEnabled != nil && !*pgCfg.PublicIpEnabled && pgCfg.PrivateNetwork == nil { - return nil, errors.New("publicIpEnabled: false requires privateNetwork to be set") + if err := pgCfg.Validate(); err != nil { + return nil, err } // Handle resource adoption - exit early if adopting @@ -88,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) } @@ -117,17 +112,19 @@ func backupConfiguration(pgCfg *gcloud.PostgresGcpCloudsqlConfig) *sql.DatabaseI return args } -// ipConfiguration returns IP settings only when one of requireSsl, privateNetwork -// or publicIpEnabled is explicitly set. When all are nil it returns nil so Pulumi -// leaves existing IP configuration unchanged. Uses SslMode (Pulumi GCP SDK v8) -// instead of deprecated RequireSsl. Ipv4Enabled defaults to true to avoid wiping -// existing authorized networks unless publicIpEnabled is explicitly false. +// 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. func ipConfiguration(pgCfg *gcloud.PostgresGcpCloudsqlConfig) *sql.DatabaseInstanceSettingsIpConfigurationArgs { - if pgCfg.RequireSsl == nil && pgCfg.PrivateNetwork == nil && pgCfg.PublicIpEnabled == nil { + publicEnabled := pgCfg.PublicIpEnabled == nil || *pgCfg.PublicIpEnabled + if pgCfg.RequireSsl == nil && !pgCfg.HasPrivateNetwork() && publicEnabled { return nil } args := &sql.DatabaseInstanceSettingsIpConfigurationArgs{ - Ipv4Enabled: sdk.Bool(pgCfg.PublicIpEnabled == nil || *pgCfg.PublicIpEnabled), + Ipv4Enabled: sdk.Bool(publicEnabled), } if pgCfg.RequireSsl != nil { sslMode := "ALLOW_UNENCRYPTED_AND_ENCRYPTED" @@ -136,7 +133,7 @@ func ipConfiguration(pgCfg *gcloud.PostgresGcpCloudsqlConfig) *sql.DatabaseInsta } args.SslMode = sdk.String(sslMode) } - if pgCfg.PrivateNetwork != nil { + if pgCfg.HasPrivateNetwork() { args.PrivateNetwork = sdk.String(*pgCfg.PrivateNetwork) } return args diff --git a/pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go b/pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go index 0a48ce81..bb89c056 100644 --- a/pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go +++ b/pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go @@ -6,6 +6,7 @@ package gcp import ( "testing" + "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -14,42 +15,56 @@ import ( "github.com/simple-container-com/api/pkg/clouds/gcloud" ) -func ipCfgStrPtr(s string) *string { return &s } -func ipCfgBoolPtr(b bool) *bool { return &b } - -// With none of requireSsl/privateNetwork/publicIpEnabled set, ipConfiguration must -// return nil so Pulumi leaves the instance's IP configuration untouched. +// 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{})) } -// requireSsl-only must stay byte-identical to the prior behaviour: public IPv4 on, -// encrypted SSL, no private network. -func TestIpConfiguration_RequireSslOnlyUnchanged(t *testing.T) { - args := ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{RequireSsl: ipCfgBoolPtr(true)}) +// 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: ipCfgStrPtr(net)}) + 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) must -// set Ipv4Enabled false. +// 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: ipCfgStrPtr(net), - PublicIpEnabled: ipCfgBoolPtr(false), + PrivateNetwork: lo.ToPtr(net), + PublicIpEnabled: lo.ToPtr(false), }) require.NotNil(t, args) assert.Equal(t, sdk.Bool(false), args.Ipv4Enabled) From 4c730f3819d23a3e76c2c01205ac116685c592f0 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Sun, 26 Jul 2026 15:48:02 +0300 Subject: [PATCH 3/3] deps: bump golang.org/x/text v0.38.0 -> v0.40.0 (GO-2026-5970) govulncheck flagged GO-2026-5970 (norm.Iter infinite loop on invalid UTF-8, fixed in x/text v0.39.0) as reachable through http.Client.Do, cases.Caser and mongo.Connect. Bumped to the latest v0.40.0. Tools state is pre-baked (go get $(tools.go imports) + go mod tidy) so CI's Build Setup runs 'go generate -tags tools' against a complete go.sum; that pulls in the side-effect bumps of x/net, x/sync, x/mod, x/tools, x/telemetry and delve. Signed-off-by: Dmitrii Creed --- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) 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=