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 11e1ce2c1d3 Add an option to set the Dataflow container base 
repository (Python, Java, Go) (#40690)
11e1ce2c1d3 is described below

commit 11e1ce2c1d3894f9479799ce8c646fce835c6c9f
Author: Shunping Huang <[email protected]>
AuthorDate: Tue Oct 20 19:46:53 2026 -0400

    Add an option to set the Dataflow container base repository (Python, Java, 
Go) (#40690)
    
    * [Python] Add --dataflow_container_base_repository option
    
    Let users point the Dataflow runner's default SDK container images, and
    the rewritten cross-language apache/beam images, at a base repository
    other than gcr.io/cloud-dataflow/v1beta3. This lets jobs run in Trusted
    Partner Cloud (TPC) universes, which have their own registries. If the
    option is unset, behavior is unchanged.
    
    * [Java] Add --dataflowContainerBaseRepository option
    
    * [Go] Add --dataflow_container_base_repository flag
    
    * [Python] Use dataflow_container_base_repository for prebuilt SDK base 
image
    
    * [Java] Mark dataflowContainerBaseRepository as @Nullable
---
 CHANGES.md                                         |  1 +
 .../beam/runners/dataflow/DataflowRunner.java      | 25 ++++++--
 .../options/DataflowPipelineWorkerPoolOptions.java | 17 +++++
 .../beam/runners/dataflow/DataflowRunnerTest.java  | 53 +++++++++++++++
 sdks/go/pkg/beam/runners/dataflow/dataflow.go      | 74 ++++++++++++++++++++-
 sdks/go/pkg/beam/runners/dataflow/dataflow_test.go | 65 +++++++++++++++++++
 .../python/apache_beam/options/pipeline_options.py | 13 ++++
 .../runners/dataflow/dataflow_runner.py            |  7 ++
 .../runners/dataflow/dataflow_runner_test.py       | 50 +++++++++++++++
 .../runners/dataflow/internal/apiclient.py         | 31 +++++++--
 .../runners/dataflow/internal/apiclient_test.py    | 75 ++++++++++++++++++++++
 11 files changed, 401 insertions(+), 10 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index d06940d12cf..d044e08105b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -82,6 +82,7 @@
 * (Python) Added `--aws_credentials_secret_manager` so AWS credentials 
(`--aws_access_key_id`, `--aws_secret_access_key`, `--aws_session_token` and 
their `--s3_*` overrides) can be secret specs resolved through a secret manager 
such as `GoogleCloudSecretManager`, instead of plain-text values 
([#40528](https://github.com/apache/beam/issues/40528)).
 * (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)).
 
 ## Breaking Changes
 
diff --git 
a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowRunner.java
 
b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowRunner.java
index f1b0c89090d..796aaa960a4 100644
--- 
a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowRunner.java
+++ 
b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowRunner.java
@@ -169,6 +169,7 @@ import org.apache.beam.sdk.values.WindowingStrategy;
 import 
org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.InvalidProtocolBufferException;
 import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.TextFormat;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.CharMatcher;
 import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Joiner;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
 import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings;
@@ -1041,9 +1042,7 @@ public class DataflowRunner extends 
PipelineRunner<DataflowPipelineJob> {
           String imageAndTag =
               normalizeDataflowImageAndTag(
                   containerImage.substring(containerImage.lastIndexOf("/")));
-          containerImage =
-              
DataflowRunnerInfo.getDataflowRunnerInfo().getContainerImageBaseRepository()
-                  + imageAndTag;
+          containerImage = getContainerImageBaseRepository(options) + 
imageAndTag;
         }
         environmentBuilder.setPayload(
             RunnerApi.DockerPayload.newBuilder()
@@ -2722,14 +2721,30 @@ public class DataflowRunner extends 
PipelineRunner<DataflowPipelineJob> {
 
   /** Construct the default Dataflow container full image URL. */
   static String getDefaultContainerImageUrl(DataflowPipelineOptions options) {
-    DataflowRunnerInfo dataflowRunnerInfo = 
DataflowRunnerInfo.getDataflowRunnerInfo();
     return String.format(
         "%s/%s:%s",
-        dataflowRunnerInfo.getContainerImageBaseRepository(),
+        getContainerImageBaseRepository(options),
         getDefaultContainerImageNameForJob(options),
         getDefaultContainerVersion(options));
   }
 
+  /**
+   * Returns the base repository of the default Dataflow container images, 
without a trailing slash.
+   * Uses {@code --dataflowContainerBaseRepository} if set, and the repository 
configured in {@link
+   * DataflowRunnerInfo} otherwise.
+   */
+  @VisibleForTesting
+  static String getContainerImageBaseRepository(DataflowPipelineOptions 
options) {
+    String baseRepository = options.getDataflowContainerBaseRepository();
+    if (baseRepository != null) {
+      String trimmed = 
CharMatcher.is('/').trimTrailingFrom(baseRepository.trim());
+      if (!trimmed.isEmpty()) {
+        return trimmed;
+      }
+    }
+    return 
DataflowRunnerInfo.getDataflowRunnerInfo().getContainerImageBaseRepository();
+  }
+
   /**
    * Construct the default Dataflow container image name based on pipeline 
type and Java version.
    */
diff --git 
a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineWorkerPoolOptions.java
 
b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineWorkerPoolOptions.java
index bd2b968ff89..e8cc7ddc8e9 100644
--- 
a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineWorkerPoolOptions.java
+++ 
b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineWorkerPoolOptions.java
@@ -136,6 +136,23 @@ public interface DataflowPipelineWorkerPoolOptions extends 
GcpOptions, FileStagi
 
   void setSdkContainerImage(String value);
 
+  /**
+   * Base repository (registry host and path prefix) of the default Dataflow 
container images, e.g.
+   * {@code docker.example.com/my-prefix/cloud-dataflow/v1beta3}. If unset, 
the default repository
+   * of the Dataflow runner is used.
+   */
+  @Description(
+      "Base repository (registry host and path prefix) from which the Dataflow 
runner pulls its "
+          + "default SDK container images, e.g. "
+          + "docker.example.com/my-prefix/cloud-dataflow/v1beta3. The runner 
appends the image "
+          + "name and tag, e.g. <base>/beam_java17_sdk:<version>. This is also 
used for the "
+          + "default images of cross-language Apache Beam environments. 
Ignored for the Java SDK "
+          + "image if sdkContainerImage is set. If not set, the Google Cloud 
Dataflow default "
+          + "(gcr.io/cloud-dataflow/v1beta3) is used.")
+  @Nullable String getDataflowContainerBaseRepository();
+
+  void setDataflowContainerBaseRepository(@Nullable String value);
+
   /**
    * GCE <a 
href="https://cloud.google.com/compute/docs/networking";>network</a> for 
launching
    * workers.
diff --git 
a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java
 
b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java
index b196aa1714c..02e70133293 100644
--- 
a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java
+++ 
b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java
@@ -34,6 +34,7 @@ import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.lessThanOrEqualTo;
 import static org.hamcrest.Matchers.matchesRegex;
 import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.startsWith;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
@@ -1311,6 +1312,20 @@ public class DataflowRunnerTest implements Serializable {
     assertThat(runner.applySdkEnvironmentOverrides(pipeline, options), 
equalTo(expectedPipeline));
   }
 
+  @Test
+  public void testApplySdkEnvironmentOverridesWithCustomBaseRepository() 
throws IOException {
+    DataflowPipelineOptions options = buildPipelineOptions();
+    options.setDataflowContainerBaseRepository(
+        "docker.example.com/my-prefix/cloud-dataflow/v1beta3/");
+    String dockerHubPythonContainerUrl = 
"apache/beam_python3.11_sdk:2.68.0rc2";
+    String customPythonContainerUrl =
+        
"docker.example.com/my-prefix/cloud-dataflow/v1beta3/beam_python3.11_sdk:2.68.0";
+    DataflowRunner runner = DataflowRunner.fromOptions(options);
+    RunnerApi.Pipeline pipeline = 
containerUrlToPipeline(dockerHubPythonContainerUrl);
+    RunnerApi.Pipeline expectedPipeline = 
containerUrlToPipeline(customPythonContainerUrl);
+    assertThat(runner.applySdkEnvironmentOverrides(pipeline, options), 
equalTo(expectedPipeline));
+  }
+
   @Test
   public void testStageArtifactWithoutStagedName() throws IOException {
     DataflowPipelineOptions options = buildPipelineOptions();
@@ -2106,6 +2121,44 @@ public class DataflowRunnerTest implements Serializable {
     }
   }
 
+  @Test
+  public void testGetContainerImageBaseRepositoryDefault() {
+    DataflowPipelineOptions options = 
PipelineOptionsFactory.as(DataflowPipelineOptions.class);
+    String defaultBaseRepository =
+        
DataflowRunnerInfo.getDataflowRunnerInfo().getContainerImageBaseRepository();
+    assertThat(
+        DataflowRunner.getContainerImageBaseRepository(options), 
equalTo(defaultBaseRepository));
+    options.setDataflowContainerBaseRepository("");
+    assertThat(
+        DataflowRunner.getContainerImageBaseRepository(options), 
equalTo(defaultBaseRepository));
+    assertThat(getContainerImageForJob(options), 
startsWith(defaultBaseRepository + "/"));
+  }
+
+  @Test
+  public void testGetContainerImageForJobWithCustomBaseRepository() {
+    String baseRepository = 
"docker.example.com/my-prefix/cloud-dataflow/v1beta3";
+    DataflowPipelineOptions options = 
PipelineOptionsFactory.as(DataflowPipelineOptions.class);
+    String defaultImage = getContainerImageForJob(options);
+    String defaultBaseRepository =
+        
DataflowRunnerInfo.getDataflowRunnerInfo().getContainerImageBaseRepository();
+
+    for (String value : new String[] {baseRepository, baseRepository + "/"}) {
+      options.setDataflowContainerBaseRepository(value);
+      assertThat(DataflowRunner.getContainerImageBaseRepository(options), 
equalTo(baseRepository));
+      assertThat(
+          getContainerImageForJob(options),
+          equalTo(baseRepository + 
defaultImage.substring(defaultBaseRepository.length())));
+    }
+  }
+
+  @Test
+  public void testSdkContainerImageTakesPrecedenceOverBaseRepository() {
+    DataflowPipelineOptions options = 
PipelineOptionsFactory.as(DataflowPipelineOptions.class);
+    options.setDataflowContainerBaseRepository("docker.example.com/prefix");
+    options.setSdkContainerImage("some-container");
+    assertThat(getContainerImageForJob(options), equalTo("some-container"));
+  }
+
   @Test
   public void testStreamingWriteWithNoShardingReturnsNewTransform() {
     PipelineOptions options = TestPipeline.testingPipelineOptions();
diff --git a/sdks/go/pkg/beam/runners/dataflow/dataflow.go 
b/sdks/go/pkg/beam/runners/dataflow/dataflow.go
index ce5e99b0a8d..87226404fcd 100644
--- a/sdks/go/pkg/beam/runners/dataflow/dataflow.go
+++ b/sdks/go/pkg/beam/runners/dataflow/dataflow.go
@@ -45,11 +45,13 @@ import (
        "github.com/apache/beam/sdks/v2/go/pkg/beam/core/util/protox"
        "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"
        "github.com/apache/beam/sdks/v2/go/pkg/beam/options/gcpopts"
        "github.com/apache/beam/sdks/v2/go/pkg/beam/options/jobopts"
        
"github.com/apache/beam/sdks/v2/go/pkg/beam/runners/dataflow/dataflowlib"
        "github.com/apache/beam/sdks/v2/go/pkg/beam/util/gcsx"
        "github.com/apache/beam/sdks/v2/go/pkg/beam/x/hooks/perf"
+       "google.golang.org/protobuf/proto"
 )
 
 // TODO(herohde) 5/16/2017: the Dataflow flags should match the other SDKs.
@@ -59,6 +61,7 @@ var (
        stagingLocation                = flag.String("staging_location", "", 
"GCS staging location (required).")
        workerHarnessImage             = 
flag.String("worker_harness_container_image", "", "Worker harness container 
image (optional). Deprecated in favor of the sdk_container_image flag.")
        image                          = flag.String("sdk_container_image", "", 
"Worker harness container image (optional).")
+       containerBaseRepository        = 
flag.String("dataflow_container_base_repository", "", "Base repository 
(registry host and path prefix) from which Dataflow pulls the default SDK 
container images, e.g. docker.example.com/my-prefix/cloud-dataflow/v1beta3 
(optional). If set, it is also used for the default images of cross-language 
Apache Beam environments. Defaults to "+defaultContainerBaseRepository+".")
        labels                         = flag.String("labels", "", 
"JSON-formatted map[string]string of job labels (optional).")
        serviceAccountEmail            = flag.String("service_account_email", 
"", "Service account email (optional).")
        numWorkers                     = flag.Int64("num_workers", 0, "Number 
of workers (optional).")
@@ -237,6 +240,16 @@ func Execute(ctx context.Context, p *beam.Pipeline) 
(beam.PipelineResult, error)
        if err != nil {
                return nil, errors.WithContext(err, "applying container image 
overrides")
        }
+       // Only rewrite cross-language Apache Beam images when a base 
repository is
+       // set explicitly, to keep the default behavior of pulling them from 
Docker
+       // Hub unchanged. Images already rewritten by the overrides above no 
longer
+       // match apache/beam* and are left as is.
+       if strings.TrimSpace(*containerBaseRepository) != "" {
+               err = updateBeamContainerImagesForDataflow(model, 
getContainerBaseRepository(), opts.ContainerImage)
+               if err != nil {
+                       return nil, errors.WithContext(err, "updating 
cross-language container images")
+               }
+       }
 
        if *dryRun {
                log.Info(ctx, "Dry-run: not submitting job!")
@@ -468,13 +481,72 @@ func getContainerImage(ctx context.Context) string {
                        // to have a dev image.
                        // However, RC versions should automatically be picked 
up, since
                        // they are never tagged the RC number, just the main 
version.
-                       return "gcr.io/cloud-dataflow/v1beta3/beam_go_sdk:" + 
core.SdkVersion
+                       return getContainerBaseRepository() + "/beam_go_sdk:" + 
core.SdkVersion
                }
                return envConfig
        }
        panic(fmt.Sprintf("Unsupported environment %v", urn))
 }
 
+// defaultContainerBaseRepository is the repository of the default Dataflow
+// container images, used when --dataflow_container_base_repository is unset.
+const defaultContainerBaseRepository = "gcr.io/cloud-dataflow/v1beta3"
+
+// getContainerBaseRepository returns the base repository of the default
+// Dataflow container images, without a trailing slash.
+func getContainerBaseRepository() string {
+       if repo := 
strings.TrimRight(strings.TrimSpace(*containerBaseRepository), "/"); repo != "" 
{
+               return repo
+       }
+       return defaultContainerBaseRepository
+}
+
+// updateBeamContainerImagesForDataflow rewrites the images of Docker
+// environments that use Apache Beam images from Docker Hub (apache/beam_*),
+// typically from cross-language transforms, to the same image in
+// baseRepository. Release candidate suffixes are trimmed from the tag, since
+// Dataflow images are only tagged with the release version. The image of the
+// pipeline's own SDK environment (sdkImage) is left unchanged.
+func updateBeamContainerImagesForDataflow(p *pipepb.Pipeline, baseRepository, 
sdkImage string) error {
+       for _, env := range p.GetComponents().GetEnvironments() {
+               if env.GetUrn() != graphx.URNEnvDocker {
+                       continue
+               }
+               var payload pipepb.DockerPayload
+               if err := proto.Unmarshal(env.GetPayload(), &payload); err != 
nil {
+                       return err
+               }
+               img := payload.GetContainerImage()
+               if !strings.HasPrefix(img, beamContainerImagePrefix) || img == 
sdkImage {
+                       continue
+               }
+               payload.ContainerImage = baseRepository + "/" + 
trimRCTag(img[strings.LastIndex(img, "/")+1:])
+               pl, err := proto.Marshal(&payload)
+               if err != nil {
+                       return err
+               }
+               env.Payload = pl
+       }
+       return nil
+}
+
+// beamContainerImagePrefix is the prefix of Apache Beam images on Docker Hub.
+const beamContainerImagePrefix = "apache/beam"
+
+// trimRCTag removes a release candidate suffix (e.g. "rc1") from the tag of
+// an image name such as "beam_java11_sdk:2.60.0rc1".
+func trimRCTag(imageAndTag string) string {
+       tagIdx := strings.LastIndex(imageAndTag, ":")
+       if tagIdx < 0 {
+               return imageAndTag
+       }
+       tag := imageAndTag[tagIdx:]
+       if rcIdx := strings.LastIndex(strings.ToLower(tag), "rc"); rcIdx > 0 {
+               return imageAndTag[:tagIdx] + tag[:rcIdx]
+       }
+       return imageAndTag
+}
+
 func checkSoftDeletePolicyEnabled(ctx context.Context, bucketName string, 
locationName string) {
        bucket, _, err := gcsx.ParseObject(bucketName)
        if err != nil {
diff --git a/sdks/go/pkg/beam/runners/dataflow/dataflow_test.go 
b/sdks/go/pkg/beam/runners/dataflow/dataflow_test.go
index 83d3c010843..5348eef6e1a 100644
--- a/sdks/go/pkg/beam/runners/dataflow/dataflow_test.go
+++ b/sdks/go/pkg/beam/runners/dataflow/dataflow_test.go
@@ -24,9 +24,12 @@ import (
        "testing"
 
        "github.com/apache/beam/sdks/v2/go/pkg/beam/core"
+       "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/graphx"
+       pipepb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/pipeline_v1"
        "github.com/apache/beam/sdks/v2/go/pkg/beam/options/gcpopts"
        "github.com/apache/beam/sdks/v2/go/pkg/beam/options/jobopts"
        
"github.com/apache/beam/sdks/v2/go/pkg/beam/runners/dataflow/dataflowlib"
+       "google.golang.org/protobuf/proto"
 )
 
 func TestDontUseFlagAsPipelineOption(t *testing.T) {
@@ -364,6 +367,67 @@ func TestGetJobOptions_DockerGCROverride(t *testing.T) {
        }
 }
 
+func TestGetContainerImage_CustomBaseRepository(t *testing.T) {
+       const base = "docker.example.com/my-prefix/cloud-dataflow/v1beta3"
+       for _, value := range []string{base, base + "/", " " + base + " "} {
+               resetGlobals()
+               *jobopts.EnvironmentType = "docker"
+               *containerBaseRepository = value
+
+               if got, want := getContainerImage(context.Background()), 
base+"/beam_go_sdk:"+core.SdkVersion; got != want {
+                       t.Errorf("getContainerImage() with base repository %q = 
%q, want %q", value, got, want)
+               }
+       }
+}
+
+func 
TestGetContainerImage_SdkContainerImageTakesPrecedenceOverBaseRepository(t 
*testing.T) {
+       resetGlobals()
+       *jobopts.EnvironmentType = "docker"
+       *containerBaseRepository = "docker.example.com/prefix"
+       *image = "testContainerImageOverride"
+
+       if got, want := getContainerImage(context.Background()), 
"testContainerImageOverride"; got != want {
+               t.Fatalf("getContainerImage() = %q, want %q", got, want)
+       }
+}
+
+func TestUpdateBeamContainerImagesForDataflow(t *testing.T) {
+       const base = "docker.example.com/my-prefix/cloud-dataflow/v1beta3"
+       const sdkImage = "apache/beam_go_sdk:2.60.0"
+       tests := map[string]struct {
+               urn, image, want string
+       }{
+               "xlang":      {graphx.URNEnvDocker, 
"apache/beam_java11_sdk:2.60.0", base + "/beam_java11_sdk:2.60.0"},
+               "xlangRC":    {graphx.URNEnvDocker, 
"apache/beam_python3.11_sdk:2.60.0rc1", base + "/beam_python3.11_sdk:2.60.0"},
+               "sdkImage":   {graphx.URNEnvDocker, sdkImage, sdkImage},
+               "nonBeam":    {graphx.URNEnvDocker, 
"other_org/beam_java11_sdk:2.60.0", "other_org/beam_java11_sdk:2.60.0"},
+               "overridden": {graphx.URNEnvDocker, 
"gcr.io/my-project/beam_java11_sdk:2.60.0", 
"gcr.io/my-project/beam_java11_sdk:2.60.0"},
+               "notDocker":  {"beam:env:process:v1", 
"apache/beam_java11_sdk:2.60.0", "apache/beam_java11_sdk:2.60.0"},
+       }
+       envs := map[string]*pipepb.Environment{}
+       for name, test := range tests {
+               pl, err := proto.Marshal(&pipepb.DockerPayload{ContainerImage: 
test.image})
+               if err != nil {
+                       t.Fatal(err)
+               }
+               envs[name] = &pipepb.Environment{Urn: test.urn, Payload: pl}
+       }
+       p := &pipepb.Pipeline{Components: &pipepb.Components{Environments: 
envs}}
+
+       if err := updateBeamContainerImagesForDataflow(p, base, sdkImage); err 
!= nil {
+               t.Fatalf("updateBeamContainerImagesForDataflow() returned error 
%v", err)
+       }
+       for name, test := range tests {
+               var payload pipepb.DockerPayload
+               if err := 
proto.Unmarshal(p.GetComponents().GetEnvironments()[name].GetPayload(), 
&payload); err != nil {
+                       t.Fatal(err)
+               }
+               if got := payload.GetContainerImage(); got != test.want {
+                       t.Errorf("%s: container image = %q, want %q", name, 
got, test.want)
+               }
+       }
+}
+
 func TestGetJobOptions_TransformMapping(t *testing.T) {
        resetGlobals()
        *stagingLocation = "gs://testStagingLocation"
@@ -584,6 +648,7 @@ func resetGlobals() {
        *gcpopts.Project = ""
        *gcpopts.Region = ""
        *image = ""
+       *containerBaseRepository = ""
        *jobopts.EnvironmentType = ""
        *jobopts.EnvironmentConfig = ""
        *jobopts.Experiments = ""
diff --git a/sdks/python/apache_beam/options/pipeline_options.py 
b/sdks/python/apache_beam/options/pipeline_options.py
index dca07da5146..da3aae4e6a1 100644
--- a/sdks/python/apache_beam/options/pipeline_options.py
+++ b/sdks/python/apache_beam/options/pipeline_options.py
@@ -1583,6 +1583,19 @@ class WorkerOptions(PipelineOptions):
             'separated by a comma where first value gives a regex to '
             'identify the container image to override and the second value '
             'gives the replacement container image.'))
+    parser.add_argument(
+        '--dataflow_container_base_repository',
+        default=None,
+        help=(
+            'Base repository (registry host and path prefix) from which the '
+            'Dataflow runner pulls its default SDK container images, e.g. '
+            'docker.example.com/my-project/cloud-dataflow/v1beta3. The '
+            'runner appends the image name and tag, e.g. '
+            '<base>/beam_python3.11_sdk:<version>. This is also used for the '
+            'default images of cross-language Apache Beam environments. '
+            'Ignored for the local SDK image if --sdk_container_image is set. '
+            'If not set, the Google Cloud Dataflow default '
+            '(gcr.io/cloud-dataflow/v1beta3) is used.'))
     parser.add_argument(
         '--default_sdk_harness_log_level',
         default=None,
diff --git a/sdks/python/apache_beam/runners/dataflow/dataflow_runner.py 
b/sdks/python/apache_beam/runners/dataflow/dataflow_runner.py
index 389e51b5728..58bcbb5c334 100644
--- a/sdks/python/apache_beam/runners/dataflow/dataflow_runner.py
+++ b/sdks/python/apache_beam/runners/dataflow/dataflow_runner.py
@@ -410,6 +410,13 @@ class DataflowRunner(PipelineRunner):
         # if prebuild_sdk_container_engine is specified we will build a new sdk
         # container image with dependencies pre-installed and use that image,
         # instead of using the inferred default container image.
+        worker_options = options.view_as(WorkerOptions)
+        if (worker_options.dataflow_container_base_repository and
+            not worker_options.sdk_container_image):
+          # Build on top of the Dataflow image in the configured repository
+          # instead of the Apache Beam image on Docker Hub.
+          worker_options.sdk_container_image = (
+              apiclient.get_container_image_from_options(options))
         self._default_environment = (
             environments.DockerEnvironment.from_options(options))
         options.view_as(WorkerOptions).sdk_container_image = (
diff --git a/sdks/python/apache_beam/runners/dataflow/dataflow_runner_test.py 
b/sdks/python/apache_beam/runners/dataflow/dataflow_runner_test.py
index c10902cf1fc..c68f053c7eb 100644
--- a/sdks/python/apache_beam/runners/dataflow/dataflow_runner_test.py
+++ b/sdks/python/apache_beam/runners/dataflow/dataflow_runner_test.py
@@ -27,6 +27,7 @@ import apache_beam.transforms as ptransform
 from apache_beam.options.pipeline_options import DebugOptions
 from apache_beam.options.pipeline_options import GoogleCloudOptions
 from apache_beam.options.pipeline_options import PipelineOptions
+from apache_beam.options.pipeline_options import WorkerOptions
 from apache_beam.pipeline import AppliedPTransform
 from apache_beam.pipeline import Pipeline
 from apache_beam.portability import common_urns
@@ -361,6 +362,55 @@ class DataflowRunnerTest(unittest.TestCase, 
ExtraAssertionsMixin):
             dependencies=environments.python_sdk_dependencies(options=options) 
+
             main_session_dep))
 
+  def _run_prebuild_and_get_base_image(self, extra_properties):
+    self.default_properties.extend(
+        ['--prebuild_sdk_container_engine=local_docker'] + extra_properties)
+    options = PipelineOptions(self.default_properties)
+    options.view_as(DebugOptions).add_experiment(
+        'disable_logging_submission_environment')
+    base_images = []
+
+    def fake_build(pipeline_options):
+      # The builder uses sdk_container_image as the base image if set.
+      base_images.append(
+          pipeline_options.view_as(WorkerOptions).sdk_container_image)
+      return 'prebuilt/beam_python_prebuilt_sdk:tag'
+
+    remote_runner = DataflowRunner()
+    with mock.patch('apache_beam.runners.portability.sdk_container_builder.'
+                    'SdkContainerImageBuilder.build_container_image',
+                    side_effect=fake_build):
+      with Pipeline(remote_runner, options=options) as p:
+        p | ptransform.Create([1, 2, 3])  # pylint: 
disable=expression-not-assigned
+
+    from apache_beam.utils import proto_utils
+    images = [
+        proto_utils.parse_Bytes(
+            env.payload, beam_runner_api_pb2.DockerPayload).container_image for
+        env in remote_runner.proto_pipeline.components.environments.values()
+    ]
+    self.assertEqual(images, ['prebuilt/beam_python_prebuilt_sdk:tag'])
+    self.assertEqual(len(base_images), 1)
+    return base_images[0]
+
+  def test_prebuild_uses_dataflow_container_base_repository(self):
+    base = 'docker.example.com/my-prefix/cloud-dataflow/v1beta3'
+    base_image = self._run_prebuild_and_get_base_image(
+        ['--dataflow_container_base_repository=' + base])
+    self.assertTrue(
+        base_image.startswith(base + '/beam_python'),
+        'unexpected base image %s' % base_image)
+
+  def test_prebuild_without_base_repository_keeps_default_base_image(self):
+    self.assertIsNone(self._run_prebuild_and_get_base_image([]))
+
+  def test_prebuild_sdk_container_image_takes_precedence(self):
+    base_image = self._run_prebuild_and_get_base_image([
+        '--dataflow_container_base_repository=docker.example.com/prefix',
+        '--sdk_container_image=FOO'
+    ])
+    self.assertEqual(base_image, 'FOO')
+
   def test_remote_runner_translation(self):
     remote_runner = DataflowRunner()
     with Pipeline(remote_runner,
diff --git a/sdks/python/apache_beam/runners/dataflow/internal/apiclient.py 
b/sdks/python/apache_beam/runners/dataflow/internal/apiclient.py
index 8a478f3fb63..7cb4e3fff93 100644
--- a/sdks/python/apache_beam/runners/dataflow/internal/apiclient.py
+++ b/sdks/python/apache_beam/runners/dataflow/internal/apiclient.py
@@ -789,7 +789,8 @@ class DataflowApplicationClient(object):
     return None
 
   @staticmethod
-  def _update_container_image_for_dataflow(beam_container_image_url):
+  def _update_container_image_for_dataflow(
+      beam_container_image_url, pipeline_options=None):
     # By default Dataflow pipelines use containers hosted in Dataflow GCR
     # instead of Docker Hub.
     image_suffix = beam_container_image_url.rsplit('/', 1)[1]
@@ -799,7 +800,9 @@ class DataflowApplicationClient(object):
     if len(check_rc) == 2:
       image_suffix = image_suffix[:-2 - len(check_rc[1])]
 
-    return names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY + '/' + image_suffix
+    return (
+        get_container_base_repository_from_options(pipeline_options) + '/' +
+        image_suffix)
 
   @staticmethod
   def _apply_sdk_environment_overrides(
@@ -831,7 +834,7 @@ class DataflowApplicationClient(object):
           new_container_image != current_sdk_container_image):
         new_container_image = (
             DataflowApplicationClient._update_container_image_for_dataflow(
-                docker_payload.container_image))
+                docker_payload.container_image, pipeline_options))
 
       if not new_container_image:
         raise ValueError(
@@ -1200,6 +1203,26 @@ def _get_container_image_tag():
   return base_version
 
 
+def get_container_base_repository_from_options(pipeline_options):
+  """For internal use only; no backwards-compatibility guarantees.
+
+    Args:
+      pipeline_options (PipelineOptions): A container for pipeline options.
+        May be None, in which case the default repository is returned.
+
+    Returns:
+      str: Base repository of the default Dataflow container images, without
+        a trailing slash.
+  """
+  base_repository = None
+  if pipeline_options is not None:
+    base_repository = pipeline_options.view_as(
+        WorkerOptions).dataflow_container_base_repository
+  if base_repository:
+    base_repository = base_repository.strip().rstrip('/')
+  return base_repository or names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY
+
+
 def get_container_image_from_options(pipeline_options):
   """For internal use only; no backwards-compatibility guarantees.
 
@@ -1215,7 +1238,7 @@ def get_container_image_from_options(pipeline_options):
 
   # Dataflow Legacy and Portable Runner exist in different repositories.
   # Set to legacy format, override if Dataflow Portable Runner
-  container_repo = names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY
+  container_repo = get_container_base_repository_from_options(pipeline_options)
   image_name = '{repository}/beam_python{major}.{minor}_sdk'.format(
       repository=container_repo,
       major=sys.version_info[0],
diff --git 
a/sdks/python/apache_beam/runners/dataflow/internal/apiclient_test.py 
b/sdks/python/apache_beam/runners/dataflow/internal/apiclient_test.py
index b82c7f55067..368ac5dbe38 100644
--- a/sdks/python/apache_beam/runners/dataflow/internal/apiclient_test.py
+++ b/sdks/python/apache_beam/runners/dataflow/internal/apiclient_test.py
@@ -520,6 +520,81 @@ class UtilTest(unittest.TestCase):
 
     self._verify_pipeline_sdk_not_overridden(pipeline_options)
 
+  def test_default_container_base_repository(self):
+    pipeline_options = PipelineOptions(['--temp_location', 'gs://any/temp'])
+    self.assertEqual(
+        apiclient.get_container_base_repository_from_options(pipeline_options),
+        names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY)
+    self.assertEqual(
+        apiclient.get_container_base_repository_from_options(None),
+        names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY)
+    self.assertTrue(
+        
apiclient.get_container_image_from_options(pipeline_options).startswith(
+            names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY + '/beam_python'))
+
+  def test_custom_container_base_repository(self):
+    base = 'docker.example.com/my-prefix/cloud-dataflow/v1beta3'
+    for value in [base, base + '/']:
+      pipeline_options = PipelineOptions([
+          '--temp_location',
+          'gs://any/temp',
+          '--dataflow_container_base_repository=' + value
+      ])
+      self.assertEqual(
+          apiclient.get_container_base_repository_from_options(
+              pipeline_options),
+          base)
+      self.assertEqual(
+          apiclient.get_container_image_from_options(pipeline_options),
+          '%s/beam_python%d.%d_sdk:%s' % (
+              base,
+              sys.version_info[0],
+              sys.version_info[1],
+              apiclient._get_required_container_version()))
+
+  def test_sdk_container_image_takes_precedence_over_base_repository(self):
+    pipeline_options = PipelineOptions([
+        '--temp_location',
+        'gs://any/temp',
+        '--dataflow_container_base_repository=docker.example.com/prefix',
+        '--sdk_container_image=dummy_prefix/dummy_name:dummy_tag'
+    ])
+    self.assertEqual(
+        apiclient.get_container_image_from_options(pipeline_options),
+        'dummy_prefix/dummy_name:dummy_tag')
+
+  def test_custom_container_base_repository_for_xlang_environments(self):
+    base = 'docker.example.com/my-prefix/cloud-dataflow/v1beta3'
+    pipeline_options = PipelineOptions([
+        '--experiments=use_runner_v2',
+        '--temp_location',
+        'gs://any-location/temp',
+        '--dataflow_container_base_repository=' + base
+    ])
+    pipeline = Pipeline(options=pipeline_options)
+    pipeline | Create([1, 2, 3]) | ParDo(DoFn())  # 
pylint:disable=expression-not-assigned
+
+    dummy_env = DockerEnvironment(
+        container_image='apache/beam_java11_sdk:2.00.0rc1')
+    proto_pipeline, _ = pipeline.to_runner_api(
+        return_context=True, default_environment=dummy_env)
+
+    # Accessing non-public method for testing.
+    apiclient.DataflowApplicationClient._apply_sdk_environment_overrides(
+        proto_pipeline, {}, pipeline_options)
+
+    from apache_beam.utils import proto_utils
+    images = [
+        proto_utils.parse_Bytes(
+            env.payload, beam_runner_api_pb2.DockerPayload).container_image
+        for env in proto_pipeline.components.environments.values()
+    ]
+    self.assertIn(base + '/beam_java11_sdk:2.00.0', images)
+    self.assertFalse(
+        any(
+            image.startswith(names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY)
+            for image in images))
+
   def test_invalid_default_job_name(self):
     # Regexp for job names in dataflow.
     regexp = '^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$'

Reply via email to