From 4d33be6a53ecb7e26888f24f7cbd390dc4b72c73 Mon Sep 17 00:00:00 2001 From: Jonathan Hess Date: Wed, 8 Apr 2026 11:11:09 -0600 Subject: [PATCH 1/3] chore: Add hidden options to support v1-to-v2 translation. --- cmd/options.go | 24 ++++++++++++++++++++++++ cmd/options_test.go | 30 ++++++++++++++++++++++++++++++ cmd/root.go | 12 +++++++++++- internal/proxy/proxy.go | 8 ++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/cmd/options.go b/cmd/options.go index aceb01843..505f13e5d 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -76,6 +76,30 @@ func WithAutoIP() Option { } } +// WithProxyV1Compatibility enables legacy behavior of v1 and will print +// out "Ready for new connections" when the proxy starts. +func WithProxyV1Compatibility() Option { + return func(c *Command) { + c.conf.ProxyV1Compatibility = true + } +} + +// WithProxyV1LogDebugStdout enables legacy behavior of v1 and will print +// debug/info logs to stdout instead of stderr. +func WithProxyV1LogDebugStdout() Option { + return func(c *Command) { + c.conf.LogDebugStdout = true + } +} + +// WithProxyV1Verbose enables legacy behavior of v1 and will set +// the debug-logs flag on the v2 proxy. +func WithProxyV1Verbose(v bool) Option { + return func(c *Command) { + c.conf.DebugLogs = v + } +} + // WithQuietLogging configures the Proxy to log error messages only. func WithQuietLogging() Option { return func(c *Command) { diff --git a/cmd/options_test.go b/cmd/options_test.go index c3dc718f6..ea29aa76f 100644 --- a/cmd/options_test.go +++ b/cmd/options_test.go @@ -146,6 +146,36 @@ func TestCommandOptions(t *testing.T) { }, option: WithLazyRefresh(), }, + { + desc: "with proxy v1 compatibility", + isValid: func(c *Command) error { + if !c.conf.ProxyV1Compatibility { + return errors.New("compatibility was false, but should be true") + } + return nil + }, + option: WithProxyV1Compatibility(), + }, + { + desc: "with proxy v1 verbose true", + isValid: func(c *Command) error { + if !c.conf.DebugLogs { + return errors.New("DebugLogs was false, but should be true") + } + return nil + }, + option: WithProxyV1Verbose(true), + }, + { + desc: "with proxy v1 verbose false", + isValid: func(c *Command) error { + if c.conf.DebugLogs { + return errors.New("DebugLogs was true, but should be false") + } + return nil + }, + option: WithProxyV1Verbose(false), + }, } for _, tc := range tcs { diff --git a/cmd/root.go b/cmd/root.go index 319ab675a..5f36928ce 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -73,7 +73,12 @@ func semanticVersion() string { // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - if err := NewCommand().Execute(); err != nil { + ExecuteCommand(NewCommand()) +} + +// ExecuteCommand runs a preconfigured command, and exits the appropriate error code. +func ExecuteCommand(c *Command) { + if err := c.Execute(); err != nil { exit := 1 var terr *exitError if errors.As(err, &terr) { @@ -646,6 +651,8 @@ func loadConfig(c *Command, args []string, opts []Option) error { if c.conf.Quiet { c.logger = log.NewStdLogger(io.Discard, os.Stderr) + } else if c.conf.LogDebugStdout { + c.logger = log.NewStdLogger(os.Stdout, os.Stderr) } err = parseConfig(c, c.conf, args) @@ -1105,6 +1112,9 @@ func runSignalWrapper(cmd *Command) (err error) { return err case p = <-startCh: cmd.logger.Infof("The proxy has started successfully and is ready for new connections!") + if cmd.conf.ProxyV1Compatibility { + cmd.logger.Infof("Ready for new connections") + } // If running under systemd with Type=notify, it will send a message to the // service manager that it is ready to handle connections now. go func() { diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index ff58c552a..beb6df292 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -188,6 +188,14 @@ type Config struct { // endpoint for all instances PSC bool + // ProxyV1Compatibility supports a legacy behavior where the Proxy will + // print out "Ready for new connections" when the proxy starts. + ProxyV1Compatibility bool + + // LogDebugStdout supports a legacy behavior where the Proxy will + // print debug/info logs to stdout instead of stderr. + LogDebugStdout bool + // AutoIP supports a legacy behavior where the Proxy will connect to // the first IP address returned from the SQL ADmin API response. This // setting should be avoided and used only to support legacy Proxy From 79e0f2a5fa53f990c3e9fb2f958775b88ac299f2 Mon Sep 17 00:00:00 2001 From: Jonathan Hess Date: Wed, 8 Apr 2026 15:00:58 -0600 Subject: [PATCH 2/3] feat: Implement the instances-metadata flag from Proxy v1. Fixes https://github.com/GoogleCloudPlatform/cloudsql-proxy/issues/1259 --- cmd/root.go | 34 ++++++++++++++++++++++++++++++++++ internal/proxy/proxy.go | 4 ++++ migration-guide.md | 12 +++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 5f36928ce..5760bb324 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -35,6 +35,7 @@ import ( "contrib.go.opencensus.io/exporter/prometheus" "contrib.go.opencensus.io/exporter/stackdriver" + "cloud.google.com/go/compute/metadata" "github.com/GoogleCloudPlatform/cloud-sql-proxy/v2/cloudsql" "github.com/GoogleCloudPlatform/cloud-sql-proxy/v2/internal/healthcheck" "github.com/GoogleCloudPlatform/cloud-sql-proxy/v2/internal/log" @@ -598,6 +599,11 @@ status code.`) `If set, the Proxy will skip any instances that are invalid/unreachable ( only applicable to Unix sockets)`) + localFlags.StringVar(&c.conf.InstancesMetadata, "instances-metadata", "", + `If provided, it is treated as a path to a metadata value which +contains a comma-separated list of instance connection names. +Only supported when running on Google Compute Engine.`) + // Global and per instance flags localFlags.StringVarP(&c.conf.Addr, "address", "a", "127.0.0.1", "(*) Address to bind Cloud SQL instance listeners.") @@ -644,6 +650,15 @@ func loadConfig(c *Command, args []string, opts []Option) error { o(c) } + // If instances-metadata is set, fetch instances from GCE metadata. + if c.conf.InstancesMetadata != "" { + mArgs, err := instanceFromMetadata(c.conf.InstancesMetadata) + if err != nil { + return err + } + args = append(args, mArgs...) + } + // Handle logger separately from config if c.conf.StructuredLogs { c.logger = log.NewStructuredLogger(c.conf.Quiet) @@ -1270,3 +1285,22 @@ func startHTTPServer(ctx context.Context, l cloudsql.Logger, addr string, mux *h l.Errorf("failed to shutdown HTTP server: %v\n", err) } } + +func instanceFromMetadata(path string) ([]string, error) { + if !metadata.OnGCE() { + return nil, newBadCommandError("instances-metadata unsupported outside of Google Compute Engine") + } + val, err := metadata.Get(path) + if err != nil { + return nil, fmt.Errorf("failed to fetch metadata from %q: %v", path, err) + } + + var args []string + for _, inst := range strings.Split(val, ",") { + inst = strings.TrimSpace(inst) + if inst != "" { + args = append(args, inst) + } + } + return args, nil +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index beb6df292..4848f02d7 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -208,6 +208,10 @@ type Config struct { // of a request context, e.g., Cloud Run. LazyRefresh bool + // InstancesMetadata is the GCE metadata path to a value that contains a + // comma-separated list of instance connection names. + InstancesMetadata string + // Instances are configuration for individual instances. Instance // configuration takes precedence over global configuration. Instances []InstanceConnConfig diff --git a/migration-guide.md b/migration-guide.md index 1bc0dfc9f..a21d248b1 100644 --- a/migration-guide.md +++ b/migration-guide.md @@ -77,6 +77,16 @@ vs ./cloud-sql-proxy --unix-socket /cloudsql ``` +### Automatic instance discovery from metadata + +```shell +# v1 +./cloud_sql_proxy -dir /cloudsql -instances_metadata=instance/attributes/cloud-sql-instances + +# v2 +./cloud-sql-proxy --unix-socket /cloudsql --instances-metadata instance/attributes/cloud-sql-instances +``` + ### Listen on multiple TCP sockets with incrementing ports ```shell @@ -155,7 +165,7 @@ The following table lists in alphabetical order v1 flags and their v2 version. | fuse_tmp | fuse-temp-dir | | | health_check_port | http-port | Use --http-address=0.0.0.0 when using a health check in Kubernetes | | host | sqladmin-api-endpoint | | -| instances_metadata | 🤔 | [Feature Request](https://github.com/GoogleCloudPlatform/cloudsql-proxy/issues/1259) | +| instances_metadata | instances-metadata | | | ip_address_types | private-ip | Defaults to public. To connect to a private IP, you must add the --private-ip flag | | log_debug_stdout | ❌ | v2 logs to stdout, errors to stderr by default | | max_connections | max-connections | | From 89c742f7b3583133165b1bd4391a58f5f7a64444 Mon Sep 17 00:00:00 2001 From: Jonathan Hess Date: Wed, 8 Apr 2026 15:14:32 -0600 Subject: [PATCH 3/3] feat: Add support for v1 projects flag. --- cmd/options.go | 8 +++ cmd/root.go | 122 +++++++++++++++++++++++++++++++++++++++- internal/proxy/proxy.go | 4 ++ migration-guide.md | 2 +- 4 files changed, 134 insertions(+), 2 deletions(-) diff --git a/cmd/options.go b/cmd/options.go index 505f13e5d..a02607a1e 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -92,6 +92,14 @@ func WithProxyV1LogDebugStdout() Option { } } +// WithProxyV1Projects enables legacy behavior of v1 and will connect to +// all Second Generation instances in the provided projects. +func WithProxyV1Projects(projects []string) Option { + return func(c *Command) { + c.conf.Projects = projects + } +} + // WithProxyV1Verbose enables legacy behavior of v1 and will set // the debug-logs flag on the v2 proxy. func WithProxyV1Verbose(v bool) Option { diff --git a/cmd/root.go b/cmd/root.go index 5760bb324..59e4fdf9a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -33,9 +33,9 @@ import ( "syscall" "time" + "cloud.google.com/go/compute/metadata" "contrib.go.opencensus.io/exporter/prometheus" "contrib.go.opencensus.io/exporter/stackdriver" - "cloud.google.com/go/compute/metadata" "github.com/GoogleCloudPlatform/cloud-sql-proxy/v2/cloudsql" "github.com/GoogleCloudPlatform/cloud-sql-proxy/v2/internal/healthcheck" "github.com/GoogleCloudPlatform/cloud-sql-proxy/v2/internal/log" @@ -45,6 +45,10 @@ import ( "github.com/spf13/pflag" "github.com/spf13/viper" "go.opencensus.io/trace" + "golang.org/x/oauth2" + "google.golang.org/api/impersonate" + "google.golang.org/api/option" + sqladmin "google.golang.org/api/sqladmin/v1" ) var ( @@ -659,6 +663,15 @@ func loadConfig(c *Command, args []string, opts []Option) error { args = append(args, mArgs...) } + // If projects is set, fetch instances from those projects. + if len(c.conf.Projects) > 0 { + pArgs, err := instanceFromProjects(c.Context(), c.conf) + if err != nil { + return err + } + args = append(args, pArgs...) + } + // Handle logger separately from config if c.conf.StructuredLogs { c.logger = log.NewStructuredLogger(c.conf.Quiet) @@ -1304,3 +1317,110 @@ func instanceFromMetadata(path string) ([]string, error) { } return args, nil } + +func instanceFromProjects(ctx context.Context, conf *proxy.Config) ([]string, error) { + var opts []option.ClientOption + if conf.APIEndpointURL != "" { + opts = append(opts, option.WithEndpoint(conf.APIEndpointURL)) + } + if conf.QuotaProject != "" { + opts = append(opts, option.WithQuotaProject(conf.QuotaProject)) + } + + // Handle credentials + switch { + case conf.ImpersonationChain != "": + target, delegates := parseImpersonationChain(conf.ImpersonationChain) + var iopts []option.ClientOption + switch { + case conf.Token != "": + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: conf.Token}) + iopts = append(iopts, option.WithTokenSource(ts)) + case conf.CredentialsFile != "": + iopts = append(iopts, option.WithCredentialsFile(conf.CredentialsFile)) + case conf.CredentialsJSON != "": + iopts = append(iopts, option.WithCredentialsJSON([]byte(conf.CredentialsJSON))) + } + ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ + TargetPrincipal: target, + Delegates: delegates, + Scopes: []string{sqladmin.SqlserviceAdminScope}, + }, iopts...) + if err != nil { + return nil, err + } + opts = append(opts, option.WithTokenSource(ts)) + case conf.Token != "": + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: conf.Token}) + opts = append(opts, option.WithTokenSource(ts)) + case conf.CredentialsFile != "": + opts = append(opts, option.WithCredentialsFile(conf.CredentialsFile)) + case conf.CredentialsJSON != "": + opts = append(opts, option.WithCredentialsJSON([]byte(conf.CredentialsJSON))) + } + + sql, err := sqladmin.NewService(ctx, opts...) + if err != nil { + return nil, err + } + + ch := make(chan string) + errCh := make(chan error, len(conf.Projects)) + var wg sync.WaitGroup + wg.Add(len(conf.Projects)) + for _, proj := range conf.Projects { + proj := proj + go func() { + defer wg.Done() + err := sql.Instances.List(proj).Pages(ctx, func(r *sqladmin.InstancesListResponse) error { + for _, in := range r.Items { + // The Proxy only supports Second Gen + if in.BackendType == "SECOND_GEN" { + ch <- in.ConnectionName + } + } + return nil + }) + if err != nil { + errCh <- fmt.Errorf("failed to list instances in %q: %v", proj, err) + } + }() + } + go func() { + wg.Wait() + close(ch) + close(errCh) + }() + + var args []string + for x := range ch { + args = append(args, x) + } + + // Check for any errors + for err := range errCh { + if err != nil { + return nil, err + } + } + + if len(args) == 0 { + return nil, fmt.Errorf("no Cloud SQL Instances found in projects: %v", conf.Projects) + } + return args, nil +} + +func parseImpersonationChain(chain string) (string, []string) { + accts := strings.Split(chain, ",") + target := accts[0] + // Assign delegates if the chain is more than one account. Delegation + // goes from last back towards target, e.g., With sa1,sa2,sa3, sa3 + // delegates to sa2, which impersonates the target sa1. + var delegates []string + if l := len(accts); l > 1 { + for i := l - 1; i > 0; i-- { + delegates = append(delegates, accts[i]) + } + } + return target, delegates +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 4848f02d7..eaff3cfe9 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -212,6 +212,10 @@ type Config struct { // comma-separated list of instance connection names. InstancesMetadata string + // Projects is a list of projects from which to connect to all + // Second Generation instances. + Projects []string + // Instances are configuration for individual instances. Instance // configuration takes precedence over global configuration. Instances []InstanceConnConfig diff --git a/migration-guide.md b/migration-guide.md index a21d248b1..0837dd58d 100644 --- a/migration-guide.md +++ b/migration-guide.md @@ -169,7 +169,7 @@ The following table lists in alphabetical order v1 flags and their v2 version. | ip_address_types | private-ip | Defaults to public. To connect to a private IP, you must add the --private-ip flag | | log_debug_stdout | ❌ | v2 logs to stdout, errors to stderr by default | | max_connections | max-connections | | -| projects | ❌ | v2 prefers explicit connection configuration to avoid user error | +| projects | ❌ | Not supported as a v2 flag. v2 prefers explicit configuration. v1 -projects is supported in v2 compatibility mode. | | quiet | quiet | quiet disables all logging except errors | | quota_project | quota-project | | | refresh_config_throttle | ❌ | |