diff --git a/go.mod b/go.mod index 6524d172..ccc9bbad 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/nais/cli go 1.22 +toolchain go1.22.2 + require ( cloud.google.com/go/cloudsqlconn v1.9.0 github.com/GoogleCloudPlatform/cloudsql-proxy v1.35.1 @@ -45,12 +47,14 @@ require ( github.com/go-openapi/jsonpointer v0.20.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.4 // indirect + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect + github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect @@ -65,6 +69,8 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/onsi/ginkgo/v2 v2.17.1 // indirect + github.com/onsi/gomega v1.33.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.18.0 // indirect diff --git a/go.sum b/go.sum index 716b0aef..315650af 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,9 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= @@ -112,6 +115,7 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfF github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA= github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= @@ -174,8 +178,12 @@ github.com/nais/liberator v0.0.0-20231027133155-2fd0a0affcd1 h1:GDNEgBlmxfid7B6v github.com/nais/liberator v0.0.0-20231027133155-2fd0a0affcd1/go.mod h1:cWThp1WBBbkRFhMI2DQMvBTTEN+6GPzmmh+Xjv8vffE= github.com/onsi/ginkgo/v2 v2.14.0 h1:vSmGj2Z5YPb9JwCWT6z6ihcUvDhuXLc3sJiqd3jMKAY= github.com/onsi/ginkgo/v2 v2.14.0/go.mod h1:JkUdW7JkN0V6rFvsHcJ478egV3XH9NxpD27Hal/PhZw= +github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= +github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE= +github.com/onsi/gomega v1.33.0/go.mod h1:+925n5YtiFsLzzafLUHzVMBpvvRAzrydIBiSIxjX3wY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -204,6 +212,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -280,6 +289,7 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= diff --git a/pkg/postgres/dbinfo.go b/pkg/postgres/dbinfo.go index 5a8785cc..75307873 100644 --- a/pkg/postgres/dbinfo.go +++ b/pkg/postgres/dbinfo.go @@ -144,9 +144,21 @@ func (i *DBInfo) dbConnectionMultiDB(ctx context.Context) (*ConnectionInfo, erro } func createConnectionInfo(secret corev1.Secret, instance string) *ConnectionInfo { - u, err := url.Parse(getSecretDataValue(secret, "_URL")) - if err != nil { - panic(err) + var pgUrl *url.URL + var jdbcUrl *url.URL + var err error + for name, val := range secret.Data { + if strings.HasSuffix(name, "_URL") { + value := string(val) + if strings.HasSuffix(name, "_JDBC_URL") { + jdbcUrl, err = url.Parse(value) + } else { + pgUrl, err = url.Parse(value) + } + if err != nil { + panic(err) + } + } } return &ConnectionInfo{ @@ -155,7 +167,8 @@ func createConnectionInfo(secret corev1.Secret, instance string) *ConnectionInfo dbName: getSecretDataValue(secret, "_DATABASE"), port: getSecretDataValue(secret, "_PORT"), host: getSecretDataValue(secret, "_HOST"), - url: u, + url: pgUrl, + jdbcUrl: jdbcUrl, instance: instance, } } @@ -279,19 +292,33 @@ type ConnectionInfo struct { port string host string url *url.URL + jdbcUrl *url.URL } func (c *ConnectionInfo) ProxyConnectionString() string { return fmt.Sprintf("host=%v user=%v dbname=%v password=%v sslmode=disable", c.instance, c.username, c.dbName, c.password) } -func (c *ConnectionInfo) JDBCURL() string { - return c.url.String() -} - func (c *ConnectionInfo) SetPassword(password string) { c.password = password - c.url.User = url.UserPassword(c.username, password) + if c.url != nil { + c.url.User = url.UserPassword(c.username, password) + } + if c.jdbcUrl != nil { + queries := c.jdbcUrl.Query() + queries.Set("password", password) + c.jdbcUrl.RawQuery = queries.Encode() + } else if c.url != nil { + queries := c.url.Query() + queries.Set("password", password) + queries.Set("user", c.username) + c.jdbcUrl = &url.URL{ + Scheme: "jdbc:postgresql", + Host: c.url.Host, + Path: c.dbName, + RawQuery: queries.Encode(), + } + } } func getSecretDataValue(secret corev1.Secret, suffix string) string { diff --git a/pkg/postgres/password.go b/pkg/postgres/password.go index 78049847..bae82b15 100644 --- a/pkg/postgres/password.go +++ b/pkg/postgres/password.go @@ -68,15 +68,28 @@ func updateKubernetesSecret(ctx context.Context, dbInfo *DBInfo, dbConnectionInf return fmt.Errorf("unable to the k8s secret %q in %q: %w", "google-sql-"+dbInfo.appName, dbInfo.namespace, err) } + jdbcUrlSet := false + prefix := "" for key := range secret.Data { if strings.HasSuffix(key, "_PASSWORD") { secret.Data[key] = []byte(dbConnectionInfo.password) } if strings.HasSuffix(key, "_URL") { - secret.Data[key] = []byte(dbConnectionInfo.JDBCURL()) + if strings.HasSuffix(key, "_JDBC_URL") && dbConnectionInfo.jdbcUrl != nil { + secret.Data[key] = []byte(dbConnectionInfo.jdbcUrl.String()) + jdbcUrlSet = true + } else if dbConnectionInfo.url != nil { + secret.Data[key] = []byte(dbConnectionInfo.url.String()) + prefix = strings.TrimSuffix(key, "_URL") + } } } + if !jdbcUrlSet && dbConnectionInfo.jdbcUrl != nil && len(prefix) > 0 { + key := prefix + "_JDBC_URL" + secret.Data[key] = []byte(dbConnectionInfo.jdbcUrl.String()) + } + _, err = dbInfo.k8sClient.CoreV1().Secrets(dbInfo.namespace).Update(ctx, secret, v1.UpdateOptions{}) if err != nil { return fmt.Errorf("failed updating k8s secret %q in %q with new password: %w", "google-sql-"+dbInfo.appName, dbInfo.namespace, err) diff --git a/pkg/postgres/password_test.go b/pkg/postgres/password_test.go new file mode 100644 index 00000000..b8319a7e --- /dev/null +++ b/pkg/postgres/password_test.go @@ -0,0 +1,215 @@ +package postgres + +import ( + "context" + "fmt" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + core_v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + "net/url" + "strings" +) + +const ( + namespace = "password-ns" + secretName = "google-sql-password-app" + appName = "password-app" + newPassword = "new-password" + oldPassword = "old-password" + + jdbcUrlTmpl = "jdbc:postgresql://localhost:5432/my-database?user=my-user&password=%s" + pgUrlTmpl = "postgresql://my-user:%s@localhost:5432/my-database" +) + +var newJdbcUrl *url.URL +var newPgUrl *url.URL + +func init() { + var err error + newJdbcUrl, err = url.Parse(fmt.Sprintf(jdbcUrlTmpl, newPassword)) + if err != nil { + panic(err) + } + + newPgUrl, err = url.Parse(fmt.Sprintf(pgUrlTmpl, newPassword)) + if err != nil { + panic(err) + } +} + +type test struct { + secretPrep []SecretPrep + assertSecret []AssertSecret +} + +var _ = Describe("Password", func() { + var k8sClient *fake.Clientset + var secret *core_v1.Secret + + BeforeEach(func() { + k8sClient = fake.NewSimpleClientset() + secret = &core_v1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespace, + }, + Data: map[string][]byte{ + "DB_HOST": []byte("localhost"), + "DB_PORT": []byte("5432"), + "DB_DATABASE": []byte("my-database"), + "DB_USERNAME": []byte("my-user"), + }, + } + }) + + DescribeTableSubtree("", + func(test test) { + var dbInfo *DBInfo + var dbConnectionInfo *ConnectionInfo + + BeforeEach(func() { + for _, prep := range test.secretPrep { + prep(secret) + } + + err := k8sClient.Tracker().Add(secret) + Expect(err).To(BeNil()) + + dbInfo = createDbInfo(k8sClient) + dbConnectionInfo = createConnectionInfo(*secret, dbInfo.instanceName) + }) + + It("rotating password", func(ctx context.Context) { + dbConnectionInfo.SetPassword(newPassword) + + err := updateKubernetesSecret(ctx, dbInfo, dbConnectionInfo) + Expect(err).To(BeNil()) + + gvr, _ := meta.UnsafeGuessKindToResource(secret.GroupVersionKind()) + actual, err := k8sClient.Tracker().Get(gvr, secret.Namespace, secret.Name) + Expect(err).To(BeNil()) + + actualSecret, ok := actual.(*core_v1.Secret) + Expect(ok).To(BeTrue()) + + for _, assert := range test.assertSecret { + assert(actualSecret) + } + }) + }, + Entry("has only password", test{ + secretPrep: []SecretPrep{AddPassword}, + assertSecret: []AssertSecret{HasPassword, HasNoUrl, HasNoJdbcUrl}, + }), + Entry("has password and url", test{ + secretPrep: []SecretPrep{AddPassword, AddUrl}, + assertSecret: []AssertSecret{HasPassword, HasUrl, HasJdbcUrl}, + }), + Entry("has all", test{ + secretPrep: []SecretPrep{AddPassword, AddUrl, AddJdbcUrl}, + assertSecret: []AssertSecret{HasPassword, HasUrl, HasJdbcUrl}, + }), + Entry("has password and jdbc url", test{ + secretPrep: []SecretPrep{AddPassword, AddJdbcUrl}, + assertSecret: []AssertSecret{HasPassword, HasNoUrl, HasJdbcUrl}, + }), + ) +}) + +func createDbInfo(k8sClient kubernetes.Interface) *DBInfo { + return &DBInfo{ + k8sClient: k8sClient, + dynamicClient: nil, + config: nil, + namespace: namespace, + appName: appName, + projectID: "project-id", + connectionName: "connection:name", + multiDB: false, + instanceName: "my-instance", + databaseName: "my-database", + user: "my-user", + } +} + +type SecretPrep func(secret *core_v1.Secret) + +func AddPassword(secret *core_v1.Secret) { + secret.Data["DB_PASSWORD"] = []byte(oldPassword) +} + +func AddUrl(secret *core_v1.Secret) { + secret.Data["DB_URL"] = []byte(fmt.Sprintf(pgUrlTmpl, oldPassword)) +} + +func AddJdbcUrl(secret *core_v1.Secret) { + secret.Data["DB_JDBC_URL"] = []byte(fmt.Sprintf(jdbcUrlTmpl, oldPassword)) +} + +type AssertSecret func(actual *core_v1.Secret) + +func EqualUrlNoQuery(expected *url.URL) types.GomegaMatcher { + expectedNoQuery, _, _ := strings.Cut(expected.String(), "?") + return WithTransform(func(actual *url.URL) string { + actualNoQuery, _, _ := strings.Cut(actual.String(), "?") + return actualNoQuery + }, Equal(expectedNoQuery)) +} + +func EqualQuery(expected *url.URL) types.GomegaMatcher { + expectedQuery := expected.Query() + return WithTransform(func(actual *url.URL) url.Values { + return actual.Query() + }, Equal(expectedQuery)) +} + +func HasPassword(actual *core_v1.Secret) { + By("should have new password in DB_PASSWORD", func() { + Expect(actual.Data["DB_PASSWORD"]).To(Equal([]byte(newPassword))) + }) +} + +func HasUrl(actual *core_v1.Secret) { + By("should have new password in DB_URL", func() { + u, err := url.Parse(string(actual.Data["DB_URL"])) + Expect(err).To(BeNil()) + Expect(u).To(SatisfyAll( + EqualUrlNoQuery(newPgUrl), + EqualQuery(newPgUrl), + )) + }) +} + +func HasNoUrl(actual *core_v1.Secret) { + By("should not have DB_URL", func() { + _, ok := actual.Data["DB_URL"] + Expect(ok).To(BeFalse()) + }) +} + +func HasJdbcUrl(actual *core_v1.Secret) { + By("should have new password in DB_JDBC_URL", func() { + u, err := url.Parse(string(actual.Data["DB_JDBC_URL"])) + Expect(err).To(BeNil()) + Expect(u).To(SatisfyAll( + EqualUrlNoQuery(newJdbcUrl), + EqualQuery(newJdbcUrl), + )) + }) +} + +func HasNoJdbcUrl(actual *core_v1.Secret) { + By("should not have DB_JDBC_URL", func() { + _, ok := actual.Data["DB_JDBC_URL"] + Expect(ok).To(BeFalse()) + }) +} diff --git a/pkg/postgres/postgres_suite_test.go b/pkg/postgres/postgres_suite_test.go new file mode 100644 index 00000000..bd7e1c1b --- /dev/null +++ b/pkg/postgres/postgres_suite_test.go @@ -0,0 +1,13 @@ +package postgres + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPostgres(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Postgres Suite") +}