Repository: kubeflow/pipelines

Issue:
[feature] Support pip_index_urls in containerized python components
### Feature Area

<!-- Uncomment the labels below which are relevant to this feature: -->
<!-- /area frontend -->
<!-- /area backend -->
/area sdk
<!-- /area samples -->
/area components


### What feature would you like to see?

So far the argument `pip_index_urls` is used only for lightweight python component. But when `target_image` is provided and therefore containerized python component can be generated with `kfp component build`, the argument is not considered - it is not a part of generated Dockerfile.
This is something that could be relatively easily (I think) implemented and I'd be happy to give it a try if there's interest.

### What is the use case or pain point?

Similarly as with lightweight python component, one might be using internal pypi index for internal libraries to be used in containerized components.

### Is there a workaround currently?

Yes. After running `kfp component build` modify generated Dockerfile (add --extra-index-url to the pip line), run docker build directly from commandline (not using `kfp` anymore) and then push the image.


---

<!-- Don't delete message below to encourage users to support your feature request! -->
Love this idea? Give it a 👍. 


Discussion:
Thanks, @b4sus. I'd be happy to review your contribution if you'd like to work on this!

Resolving patch:
diff --git a/sdk/python/kfp/cli/component.py b/sdk/python/kfp/cli/component.py
--- a/sdk/python/kfp/cli/component.py
+++ b/sdk/python/kfp/cli/component.py
@@ -46,9 +46,9 @@
 
 WORKDIR {component_root_dir}
 COPY {requirements_file} {requirements_file}
-RUN pip install --no-cache-dir -r {requirements_file}
+RUN pip install {index_urls}--no-cache-dir -r {requirements_file}
 {maybe_copy_kfp_package}
-RUN pip install --no-cache-dir {kfp_package_path}
+RUN pip install {index_urls}--no-cache-dir {kfp_package_path}
 COPY . .
 '''
 
@@ -156,6 +156,7 @@ def __init__(
 
         self._base_image = None
         self._target_image = None
+        self._pip_index_urls = None
         self._load_components()
 
     def _load_components(self):
@@ -214,6 +215,13 @@ def _load_components(self):
             raise sys.exit(1)
         logging.info(f'Using target image: {self._target_image}')
 
+        pip_index_urls = []
+        for comp in self._components:
+            if comp.pip_index_urls is not None:
+                pip_index_urls.extend(comp.pip_index_urls)
+        if pip_index_urls:
+            self._pip_index_urls = list(dict.fromkeys(pip_index_urls))
+
     def _maybe_write_file(self,
                           filename: str,
                           contents: str,
@@ -270,12 +278,15 @@ def generate_kfp_config(self):
         config.save()
 
     def maybe_generate_dockerfile(self, overwrite_dockerfile: bool = False):
+        index_urls_options = component_factory.make_index_url_options(
+            self._pip_index_urls)
         dockerfile_contents = _DOCKERFILE_TEMPLATE.format(
             base_image=self._base_image,
             maybe_copy_kfp_package=self._maybe_copy_kfp_package,
             component_root_dir=_COMPONENT_ROOT_DIR,
             kfp_package_path=self._kfp_package_path,
             requirements_file=_REQUIREMENTS_TXT,
+            index_urls=index_urls_options,
         )
 
         self._maybe_write_file(_DOCKERFILE, dockerfile_contents,
diff --git a/sdk/python/kfp/components/component_factory.py b/sdk/python/kfp/components/component_factory.py
--- a/sdk/python/kfp/components/component_factory.py
+++ b/sdk/python/kfp/components/component_factory.py
@@ -50,6 +50,7 @@ class ComponentInfo():
     output_component_file: Optional[str] = None
     base_image: str = _DEFAULT_BASE_IMAGE
     packages_to_install: Optional[List[str]] = None
+    pip_index_urls: Optional[List[str]] = None
 
 
 # A map from function_name to components.  This is always populated when a
@@ -63,19 +64,37 @@ def _python_function_name_to_component_name(name):
     return name_with_spaces[0].upper() + name_with_spaces[1:]
 
 
-def _make_index_url_options(pip_index_urls: Optional[List[str]]) -> str:
+def make_index_url_options(pip_index_urls: Optional[List[str]]) -> str:
+    """Generates index url options for pip install command based on provided
+    pip_index_urls.
+
+    Args:
+        pip_index_urls: Optional list of pip index urls
+
+    Returns:
+        - Empty string if pip_index_urls is empty/None.
+        - '--index-url url --trusted-host url ' if pip_index_urls contains 1
+        url
+        - the above followed by '--extra-index-url url --trusted-host url '
+        for
+        each next url in pip_index_urls if pip_index_urls contains more than 1
+        url
+
+        Note: In case pip_index_urls is not empty, the returned string will
+        contain space at the end.
+    """
     if not pip_index_urls:
         return ''
 
     index_url = pip_index_urls[0]
     extra_index_urls = pip_index_urls[1:]
 
-    options = [f'--index-url {index_url} --trusted-host {index_url} ']
+    options = [f'--index-url {index_url} --trusted-host {index_url}']
     options.extend(
-        f'--extra-index-url {extra_index_url} --trusted-host {extra_index_url} '
+        f'--extra-index-url {extra_index_url} --trusted-host {extra_index_url}'
         for extra_index_url in extra_index_urls)
 
-    return ' '.join(options)
+    return ' '.join(options) + ' '
 
 
 _install_python_packages_script_template = '''
@@ -97,7 +116,7 @@ def _get_packages_to_install_command(
 
     concat_package_list = ' '.join(
         [repr(str(package)) for package in package_list])
-    index_url_options = _make_index_url_options(pip_index_urls)
+    index_url_options = make_index_url_options(pip_index_urls)
     install_python_packages_script = _install_python_packages_script_template.format(
         index_url_options=index_url_options,
         concat_package_list=concat_package_list)
@@ -468,7 +487,8 @@ def create_component_from_func(
         component_spec=component_spec,
         output_component_file=output_component_file,
         base_image=base_image,
-        packages_to_install=packages_to_install)
+        packages_to_install=packages_to_install,
+        pip_index_urls=pip_index_urls)
 
     if REGISTERED_MODULES is not None:
         REGISTERED_MODULES[component_name] = component_info


Test patch:
diff --git a/sdk/python/kfp/cli/component_test.py b/sdk/python/kfp/cli/component_test.py
--- a/sdk/python/kfp/cli/component_test.py
+++ b/sdk/python/kfp/cli/component_test.py
@@ -30,11 +30,14 @@
 from kfp.cli import component
 
 
-def _make_component(func_name: str,
-                    base_image: Optional[str] = None,
-                    target_image: Optional[str] = None,
-                    packages_to_install: Optional[List[str]] = None,
-                    output_component_file: Optional[str] = None) -> str:
+def _make_component(
+    func_name: str,
+    base_image: Optional[str] = None,
+    target_image: Optional[str] = None,
+    packages_to_install: Optional[List[str]] = None,
+    output_component_file: Optional[str] = None,
+    pip_index_urls: Optional[List[str]] = None,
+) -> str:
     return textwrap.dedent('''
     from kfp.dsl import *
 
@@ -42,7 +45,8 @@ def _make_component(func_name: str,
         base_image={base_image},
         target_image={target_image},
         packages_to_install={packages_to_install},
-        output_component_file={output_component_file})
+        output_component_file={output_component_file},
+        pip_index_urls={pip_index_urls})
     def {func_name}():
         pass
     ''').format(
@@ -50,6 +54,7 @@ def {func_name}():
         target_image=repr(target_image),
         packages_to_install=repr(packages_to_install),
         output_component_file=repr(output_component_file),
+        pip_index_urls=repr(pip_index_urls),
         func_name=func_name)
 
 
@@ -443,6 +448,66 @@ def test_docker_file_is_created_correctly(self):
                 COPY . .
                 '''))
 
+    @mock.patch('kfp.__version__', '1.2.3')
+    def test_docker_file_is_created_correctly_with_one_url(self):
+        component = _make_component(
+            func_name='train',
+            target_image='custom-image',
+            pip_index_urls=['https://pypi.org/simple'])
+        _write_components('components.py', component)
+
+        result = self.runner.invoke(
+            self.cli,
+            ['build', str(self._working_dir)],
+        )
+        self.assertEqual(result.exit_code, 0)
+        self._docker_client.api.build.assert_called_once()
+        self.assert_file_exists_and_contains(
+            'Dockerfile',
+            textwrap.dedent('''\
+                # Generated by KFP.
+
+                FROM python:3.7
+
+                WORKDIR /usr/local/src/kfp/components
+                COPY runtime-requirements.txt runtime-requirements.txt
+                RUN pip install --index-url https://pypi.org/simple --trusted-host https://pypi.org/simple --no-cache-dir -r runtime-requirements.txt
+
+                RUN pip install --index-url https://pypi.org/simple --trusted-host https://pypi.org/simple --no-cache-dir kfp==1.2.3
+                COPY . .
+                '''))
+
+    @mock.patch('kfp.__version__', '1.2.3')
+    def test_docker_file_is_created_correctly_with_two_urls(self):
+        component = _make_component(
+            func_name='train',
+            target_image='custom-image',
+            pip_index_urls=[
+                'https://pypi.org/simple', 'https://example.com/pypi/simple'
+            ])
+        _write_components('components.py', component)
+
+        result = self.runner.invoke(
+            self.cli,
+            ['build', str(self._working_dir)],
+        )
+        self.assertEqual(result.exit_code, 0)
+        self._docker_client.api.build.assert_called_once()
+        self.assert_file_exists_and_contains(
+            'Dockerfile',
+            textwrap.dedent('''\
+                # Generated by KFP.
+
+                FROM python:3.7
+
+                WORKDIR /usr/local/src/kfp/components
+                COPY runtime-requirements.txt runtime-requirements.txt
+                RUN pip install --index-url https://pypi.org/simple --trusted-host https://pypi.org/simple --extra-index-url https://example.com/pypi/simple --trusted-host https://example.com/pypi/simple --no-cache-dir -r runtime-requirements.txt
+
+                RUN pip install --index-url https://pypi.org/simple --trusted-host https://pypi.org/simple --extra-index-url https://example.com/pypi/simple --trusted-host https://example.com/pypi/simple --no-cache-dir kfp==1.2.3
+                COPY . .
+                '''))
+
     def test_existing_dockerfile_is_unchanged_by_default(self):
         component = _make_component(
             func_name='train', target_image='custom-image')