This is an automated email from the ASF dual-hosted git repository.
shunping pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new f1ec3ac0fac [Go] Support non-default universes for GCS and Dataflow
clients (#40686)
f1ec3ac0fac is described below
commit f1ec3ac0fac702d0f96e730193b756ccdbe6f8ee
Author: Shunping Huang <[email protected]>
AuthorDate: Tue Oct 20 22:46:48 2026 -0400
[Go] Support non-default universes for GCS and Dataflow clients (#40686)
---
CHANGES.md | 1 +
sdks/go/pkg/beam/options/gcpopts/options.go | 67 ++++++++++++
sdks/go/pkg/beam/options/gcpopts/universe_test.go | 120 +++++++++++++++++++++
sdks/go/pkg/beam/runners/dataflow/dataflow.go | 2 +-
.../runners/dataflow/dataflowlib/client_test.go | 91 ++++++++++++++++
.../pkg/beam/runners/dataflow/dataflowlib/job.go | 25 +++--
sdks/go/pkg/beam/util/gcsx/client_test.go | 53 +++++++++
sdks/go/pkg/beam/util/gcsx/gcs.go | 27 ++++-
8 files changed, 372 insertions(+), 14 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 2940a8c6e70..b9accf9cf88 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -83,6 +83,7 @@
* (Python) Added `AwsSecret` (`AwsSecretsManager` / `type:awssecret`) to
retrieve secrets from AWS Secrets Manager, configured from `AwsOptions`
(`--aws_*`) with optional per-secret connection overrides
([#40528](https://github.com/apache/beam/issues/40528)).
* (Go) Added `schema.RegisterLogicalTypeConversion` and
`schema.RegisterLogicalTypeCoder` to register portable logical types in one
call, and Go types for the standard `beam:logical_type:date:v1` and
`beam:logical_type:micros_instant:v1` logical types, so schemas from other SDKs
with these types can be used in cross-language pipelines
([#39684](https://github.com/apache/beam/issues/39684)).
* (Python, Java, Go) Added `--dataflow_container_base_repository` (Python, Go)
and `--dataflowContainerBaseRepository` (Java) to set the repository (e.g.
`docker.example.com/my-prefix/cloud-dataflow/v1beta3`) from which the Dataflow
runner pulls its default SDK container images, including the default images of
cross-language Apache Beam environments. Defaults to
`gcr.io/cloud-dataflow/v1beta3`
([#40569](https://github.com/apache/beam/issues/40569)).
+* (Go) GCS and Dataflow clients now support non-default universes such as
Trusted Partner Cloud (TPC), using the universe domain from
`GOOGLE_CLOUD_UNIVERSE_DOMAIN` or the default credentials. Added a
`--gcs_endpoint` pipeline option to override the GCS endpoint
([#40569](https://github.com/apache/beam/issues/40569)).
## Breaking Changes
diff --git a/sdks/go/pkg/beam/options/gcpopts/options.go
b/sdks/go/pkg/beam/options/gcpopts/options.go
index 2acb73dbd5e..b5df80e2de0 100644
--- a/sdks/go/pkg/beam/options/gcpopts/options.go
+++ b/sdks/go/pkg/beam/options/gcpopts/options.go
@@ -22,8 +22,10 @@ import (
"os"
"os/exec"
"strings"
+ "sync"
"cloud.google.com/go/storage"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime"
"github.com/apache/beam/sdks/v2/go/pkg/beam/log"
"golang.org/x/oauth2/google"
)
@@ -34,8 +36,73 @@ var (
// Region is the GCP region where the job should be run.
Region = flag.String("region", "", "GCP Region (required)")
+
+ // GcsEndpoint overrides the Google Cloud Storage API endpoint.
+ GcsEndpoint = flag.String("gcs_endpoint", "", "The URL for the GCS API,
"+
+ "e.g. https://storage.googleapis.com/storage/v1/ (optional). If
not set, the "+
+ "endpoint is derived from the universe domain, which is read
from "+
+ "GOOGLE_CLOUD_UNIVERSE_DOMAIN or the default credentials.")
)
+// GetGcsEndpoint returns the GCS endpoint override from the --gcs_endpoint
+// flag. If the flag is unset, it falls back to the "gcs_endpoint" pipeline
+// option, which is how the value reaches remote workers, where flags are not
+// parsed. Returns an empty string if no override is configured.
+func GetGcsEndpoint() string {
+ if *GcsEndpoint != "" {
+ return *GcsEndpoint
+ }
+ return runtime.GlobalOptions.Get("gcs_endpoint")
+}
+
+const universeDomainEnvVar = "GOOGLE_CLOUD_UNIVERSE_DOMAIN"
+
+// findDefaultCredentials is a variable to allow overriding in tests.
+var findDefaultCredentials = google.FindDefaultCredentials
+
+// cachedUniverseDomain caches the universe domain resolved from the default
+// credentials, since resolving it may require a metadata server round trip
+// and clients are created frequently.
+var cachedUniverseDomain struct {
+ mu sync.Mutex
+ resolved bool
+ value string
+}
+
+// GetUniverseDomain returns the Google Cloud universe domain, first from the
+// GOOGLE_CLOUD_UNIVERSE_DOMAIN environment variable, then falling back to the
+// universe domain of the default credentials. Returns an empty string if it
+// cannot be determined.
+//
+// Google API clients don't infer the universe domain from the credentials,
+// and requests fail if the two differ, so clients should pass this value via
+// option.WithUniverseDomain to support non-default universes such as Trusted
+// Partner Cloud (TPC).
+func GetUniverseDomain(ctx context.Context) string {
+ if ud := os.Getenv(universeDomainEnvVar); ud != "" {
+ return ud
+ }
+
+ cachedUniverseDomain.mu.Lock()
+ defer cachedUniverseDomain.mu.Unlock()
+ if cachedUniverseDomain.resolved {
+ return cachedUniverseDomain.value
+ }
+ creds, err := findDefaultCredentials(ctx)
+ if err != nil {
+ // Don't cache: credentials may become available later, and
client
+ // construction surfaces the credential error itself.
+ return ""
+ }
+ ud, err := creds.GetUniverseDomain()
+ if err != nil {
+ return ""
+ }
+ cachedUniverseDomain.resolved = true
+ cachedUniverseDomain.value = ud
+ return ud
+}
+
// GetProject returns the project, if non empty and exits otherwise.
// Convenience function.
func GetProject(ctx context.Context) string {
diff --git a/sdks/go/pkg/beam/options/gcpopts/universe_test.go
b/sdks/go/pkg/beam/options/gcpopts/universe_test.go
new file mode 100644
index 00000000000..35050d1010e
--- /dev/null
+++ b/sdks/go/pkg/beam/options/gcpopts/universe_test.go
@@ -0,0 +1,120 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gcpopts
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime"
+ "golang.org/x/oauth2/google"
+)
+
+func TestGetUniverseDomain(t *testing.T) {
+ tests := []struct {
+ name string
+ env string
+ credsUD string
+ credsErr error
+ want string
+ wantCalls int // credential lookups across two calls
+ }{
+ {
+ name: "env var takes precedence",
+ env: "env.example.com",
+ credsUD: "creds.example.com",
+ want: "env.example.com",
+ },
+ {
+ name: "falls back to credentials and caches",
+ credsUD: "creds.example.com",
+ want: "creds.example.com",
+ wantCalls: 1,
+ },
+ {
+ name: "credential errors are not cached",
+ credsErr: errors.New("no credentials"),
+ want: "",
+ wantCalls: 2,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ resetUniverseDomainCache(t)
+ t.Setenv(universeDomainEnvVar, test.env)
+ calls := 0
+ orig := findDefaultCredentials
+ findDefaultCredentials = func(context.Context,
...string) (*google.Credentials, error) {
+ calls++
+ if test.credsErr != nil {
+ return nil, test.credsErr
+ }
+ return &google.Credentials{
+ UniverseDomainProvider: func() (string,
error) { return test.credsUD, nil },
+ }, nil
+ }
+ t.Cleanup(func() { findDefaultCredentials = orig })
+
+ // Call twice so wantCalls can verify caching: a
successful lookup
+ // is reused by the second call, while a failed one is
retried.
+ for i := 0; i < 2; i++ {
+ if got := GetUniverseDomain(t.Context()); got
!= test.want {
+ t.Errorf("GetUniverseDomain() = %q,
want %q", got, test.want)
+ }
+ }
+ if calls != test.wantCalls {
+ t.Errorf("credentials looked up %d times, want
%d", calls, test.wantCalls)
+ }
+ })
+ }
+}
+
+// resetUniverseDomainCache clears the cached universe domain and restores it
+// when the test finishes.
+func resetUniverseDomainCache(t *testing.T) {
+ t.Helper()
+ reset := func() {
+ cachedUniverseDomain.mu.Lock()
+ cachedUniverseDomain.resolved = false
+ cachedUniverseDomain.value = ""
+ cachedUniverseDomain.mu.Unlock()
+ }
+ reset()
+ t.Cleanup(reset)
+}
+
+func TestGetGcsEndpoint(t *testing.T) {
+ const flagValue = "https://flag.example.com/storage/v1/"
+ const optionValue = "https://option.example.com/storage/v1/"
+
+ origFlag := *GcsEndpoint
+ t.Cleanup(func() {
+ *GcsEndpoint = origFlag
+ runtime.GlobalOptions.Set("gcs_endpoint", "")
+ })
+ // On workers, the value arrives as a pipeline option rather than a
flag.
+ runtime.GlobalOptions.Set("gcs_endpoint", optionValue)
+
+ *GcsEndpoint = ""
+ if got := GetGcsEndpoint(); got != optionValue {
+ t.Errorf("GetGcsEndpoint() with flag unset = %q, want pipeline
option %q", got, optionValue)
+ }
+ *GcsEndpoint = flagValue
+ if got := GetGcsEndpoint(); got != flagValue {
+ t.Errorf("GetGcsEndpoint() with flag set = %q, want flag %q",
got, flagValue)
+ }
+}
diff --git a/sdks/go/pkg/beam/runners/dataflow/dataflow.go
b/sdks/go/pkg/beam/runners/dataflow/dataflow.go
index 87226404fcd..20fb4fae005 100644
--- a/sdks/go/pkg/beam/runners/dataflow/dataflow.go
+++ b/sdks/go/pkg/beam/runners/dataflow/dataflow.go
@@ -553,7 +553,7 @@ func checkSoftDeletePolicyEnabled(ctx context.Context,
bucketName string, locati
log.Warnf(ctx, "Error parsing bucket name: %v", err)
return
}
- client, err := storage.NewClient(ctx)
+ client, err := gcsx.NewClient(ctx, storage.ScopeReadOnly)
if err != nil {
log.Warnf(ctx, "Error creating GCS client: %v", err)
return
diff --git a/sdks/go/pkg/beam/runners/dataflow/dataflowlib/client_test.go
b/sdks/go/pkg/beam/runners/dataflow/dataflowlib/client_test.go
new file mode 100644
index 00000000000..63e3791f9bf
--- /dev/null
+++ b/sdks/go/pkg/beam/runners/dataflow/dataflowlib/client_test.go
@@ -0,0 +1,91 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dataflowlib
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestNewClient_BasePath(t *testing.T) {
+ // Fake service account credentials in a non-default universe. The auth
+ // library parses the key when the client is created (non-default
universes
+ // use self-signed JWTs), so it must be a valid key. No token is fetched
+ // until a request is made.
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("Failed generating key: %v", err)
+ }
+ der, err := x509.MarshalPKCS8PrivateKey(key)
+ if err != nil {
+ t.Fatalf("Failed marshaling key: %v", err)
+ }
+ content, err := json.Marshal(map[string]string{
+ "type": "service_account",
+ "project_id": "test-project",
+ "client_email": "[email protected]",
+ "private_key_id": "test-key-id",
+ "private_key": string(pem.EncodeToMemory(&pem.Block{Type:
"PRIVATE KEY", Bytes: der})),
+ "universe_domain": "creds.example.com",
+ })
+ if err != nil {
+ t.Fatalf("Failed marshaling credentials: %v", err)
+ }
+ credFile := filepath.Join(t.TempDir(), "creds.json")
+ if err := os.WriteFile(credFile, content, 0600); err != nil {
+ t.Fatalf("Failed writing fake credential file: %v", err)
+ }
+ t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", credFile)
+
+ tests := []struct {
+ name string
+ endpoint string
+ env string
+ want string
+ }{
+ {
+ // The client library doesn't read the credentials'
universe domain,
+ // so this verifies that NewClient passes it through.
+ name: "universe domain from credentials",
+ want: "https://dataflow.creds.example.com/",
+ },
+ {
+ name: "endpoint takes precedence over universe
domain",
+ endpoint: "http://localhost:8080/",
+ env: "creds.example.com",
+ want: "http://localhost:8080/",
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ t.Setenv("GOOGLE_CLOUD_UNIVERSE_DOMAIN", test.env)
+
+ client, err := NewClient(t.Context(), test.endpoint)
+ if err != nil {
+ t.Fatalf("NewClient(%q) failed: %v",
test.endpoint, err)
+ }
+ if got := client.BasePath; got != test.want {
+ t.Errorf("NewClient(%q).BasePath = %q, want
%q", test.endpoint, got, test.want)
+ }
+ })
+ }
+}
diff --git a/sdks/go/pkg/beam/runners/dataflow/dataflowlib/job.go
b/sdks/go/pkg/beam/runners/dataflow/dataflowlib/job.go
index 27a0a0d0f22..841e1749876 100644
--- a/sdks/go/pkg/beam/runners/dataflow/dataflowlib/job.go
+++ b/sdks/go/pkg/beam/runners/dataflow/dataflowlib/job.go
@@ -27,8 +27,9 @@ import (
"github.com/apache/beam/sdks/v2/go/pkg/beam/internal/errors"
"github.com/apache/beam/sdks/v2/go/pkg/beam/log"
pipepb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/pipeline_v1"
- "golang.org/x/oauth2/google"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/options/gcpopts"
df "google.golang.org/api/dataflow/v1b3"
+ "google.golang.org/api/option"
"google.golang.org/protobuf/proto"
// Importing to get the side effect of the remote execution hook. See
init().
@@ -312,20 +313,22 @@ func currentStateMessage(currentState, jobID string)
(bool, string, error) {
// NewClient creates a new dataflow client with default application credentials
// and CloudPlatformScope. The Dataflow endpoint is optionally overridden.
+//
+// In non-default universes (e.g. Trusted Partner Cloud), the universe domain
+// is taken from GOOGLE_CLOUD_UNIVERSE_DOMAIN or, if unset, from the default
+// credentials.
func NewClient(ctx context.Context, endpoint string) (*df.Service, error) {
- cl, err := google.DefaultClient(ctx, df.CloudPlatformScope)
- if err != nil {
- return nil, err
- }
- client, err := df.New(cl)
- if err != nil {
- return nil, err
- }
+ opts := []option.ClientOption{option.WithScopes(df.CloudPlatformScope)}
if endpoint != "" {
log.Infof(ctx, "Dataflow endpoint override: %s", endpoint)
- client.BasePath = endpoint
+ opts = append(opts, option.WithEndpoint(endpoint))
+ }
+ // The client ignores the credentials' universe domain, and requests
fail
+ // if it differs from the configured one (googleapis.com by default).
+ if ud := gcpopts.GetUniverseDomain(ctx); ud != "" {
+ opts = append(opts, option.WithUniverseDomain(ud))
}
- return client, nil
+ return df.NewService(ctx, opts...)
}
// GetRunningJobByName gets a Dataflow job running by its name and returns an
diff --git a/sdks/go/pkg/beam/util/gcsx/client_test.go
b/sdks/go/pkg/beam/util/gcsx/client_test.go
new file mode 100644
index 00000000000..6b31646d660
--- /dev/null
+++ b/sdks/go/pkg/beam/util/gcsx/client_test.go
@@ -0,0 +1,53 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gcsx
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/options/gcpopts"
+)
+
+func TestNewUnauthenticatedClient_UsesGcsEndpoint(t *testing.T) {
+ var gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ gotPath = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"name": "some-bucket"}`))
+ }))
+ defer srv.Close()
+
+ orig := *gcpopts.GcsEndpoint
+ *gcpopts.GcsEndpoint = srv.URL + "/storage/v1/"
+ t.Cleanup(func() { *gcpopts.GcsEndpoint = orig })
+
+ ctx := t.Context()
+ client, err := NewUnauthenticatedClient(ctx)
+ if err != nil {
+ t.Fatalf("NewUnauthenticatedClient() failed: %v", err)
+ }
+ defer client.Close()
+
+ if _, err := client.Bucket("some-bucket").Attrs(ctx); err != nil {
+ t.Fatalf("Attrs() failed: %v", err)
+ }
+ if want := "/storage/v1/b/some-bucket"; !strings.HasPrefix(gotPath,
want) {
+ t.Errorf("request path = %q, want prefix %q", gotPath, want)
+ }
+}
diff --git a/sdks/go/pkg/beam/util/gcsx/gcs.go
b/sdks/go/pkg/beam/util/gcsx/gcs.go
index 0986851c16d..73300fcc6dc 100644
--- a/sdks/go/pkg/beam/util/gcsx/gcs.go
+++ b/sdks/go/pkg/beam/util/gcsx/gcs.go
@@ -25,6 +25,7 @@ import (
"cloud.google.com/go/storage"
"github.com/apache/beam/sdks/v2/go/pkg/beam/internal/errors"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/options/gcpopts"
"google.golang.org/api/option"
)
@@ -33,14 +34,36 @@ var userAgent = option.WithUserAgent("(GPN:Beam)")
// NewClient creates a new GCS client with default application credentials,
and supplied
// OAuth scope. The OAuth scopes are defined in
https://pkg.go.dev/cloud.google.com/go/storage#pkg-constants.
// Sets the user agent to Beam.
+//
+// The endpoint can be overridden with the --gcs_endpoint flag. In non-default
+// universes (e.g. Trusted Partner Cloud), the universe domain is taken from
+// GOOGLE_CLOUD_UNIVERSE_DOMAIN or, if unset, from the default credentials.
func NewClient(ctx context.Context, scope string) (*storage.Client, error) {
- return storage.NewClient(ctx, option.WithScopes(scope), userAgent)
+ opts := []option.ClientOption{option.WithScopes(scope), userAgent}
+ opts = append(opts, endpointOptions()...)
+ // storage.NewClient ignores the credentials' universe domain, and
requests
+ // fail if it differs from the configured one (googleapis.com by
default).
+ if ud := gcpopts.GetUniverseDomain(ctx); ud != "" {
+ opts = append(opts, option.WithUniverseDomain(ud))
+ }
+ return storage.NewClient(ctx, opts...)
}
// NewUnauthenticatedClient creates a new GCS client without authentication.
// Sets the user agent to Beam.
func NewUnauthenticatedClient(ctx context.Context) (*storage.Client, error) {
- return storage.NewClient(ctx, option.WithoutAuthentication(), userAgent)
+ opts := []option.ClientOption{option.WithoutAuthentication(), userAgent}
+ opts = append(opts, endpointOptions()...)
+ return storage.NewClient(ctx, opts...)
+}
+
+// endpointOptions returns the client options for the --gcs_endpoint override,
+// if any.
+func endpointOptions() []option.ClientOption {
+ if ep := gcpopts.GetGcsEndpoint(); ep != "" {
+ return []option.ClientOption{option.WithEndpoint(ep)}
+ }
+ return nil
}
// Upload writes the given content to GCS. If the specified bucket does not