[runtime env] [1.11.0 release cherry-pick] fix bug where pip options don't work in `requirements.txt`
diff --git a/python/ray/_private/runtime_env/pip.py b/python/ray/_private/runtime_env/pip.py
index 1ce0ed2de0481..d0fc43d33b49e 100644
--- a/python/ray/_private/runtime_env/pip.py
+++ b/python/ray/_private/runtime_env/pip.py
@@ -29,11 +29,25 @@ def _install_pip_list_to_dir(
         target_dir: str,
         logger: Optional[logging.Logger] = default_logger):
     try_to_create_directory(target_dir)
-    exit_code, output = exec_cmd_stream_to_logger(
-        ["pip", "install", f"--target={target_dir}"] + pip_list, logger)
-    if exit_code != 0:
-        shutil.rmtree(target_dir)
-        raise RuntimeError(f"Failed to install pip requirements:\n{output}")
+    try:
+        pip_requirements_file = os.path.join(target_dir, "requirements.txt")
+        with open(pip_requirements_file, "w") as file:
+            for line in pip_list:
+                file.write(line + "\n")
+        exit_code, output = exec_cmd_stream_to_logger(
+            [
+                "pip", "install", f"--target={target_dir}", "-r",
+                pip_requirements_file
+            ],
+            logger,
+        )
+        if exit_code != 0:
+            shutil.rmtree(target_dir)
+            raise RuntimeError(
+                f"Failed to install pip requirements:\n{output}")
+    finally:
+        if os.path.exists(pip_requirements_file):
+            os.remove(pip_requirements_file)
 
 
 def get_uri(runtime_env: Dict) -> Optional[str]:
diff --git a/python/ray/_private/runtime_env/validation.py b/python/ray/_private/runtime_env/validation.py
index 8e06ba726f7b3..c515cbdcbe39a 100644
--- a/python/ray/_private/runtime_env/validation.py
+++ b/python/ray/_private/runtime_env/validation.py
@@ -110,9 +110,34 @@ def parse_and_validate_conda(conda: Union[str, dict]) -> Union[str, dict]:
 
 
 def _rewrite_pip_list_ray_libraries(pip_list: List[str]) -> List[str]:
+    """Remove Ray and replace Ray libraries with their dependencies.
+
+    The `pip` field of runtime_env installs packages into the current
+    environment, inheriting the existing environment.  If users want to
+    use Ray libraries like `ray[serve]` in their job, they must include
+    `ray[serve]` in their `runtime_env` `pip` field.  However, without this
+    function, the Ray installed at runtime would take precedence over the
+    Ray that exists in the cluster, which would lead to version mismatch
+    issues.
+
+    To work around this, this function deletes Ray from the input `pip_list`
+    if it's specified without any libraries (e.g. "ray" or "ray>1.4"). If
+    a Ray library is specified (e.g. "ray[serve]"), it is replaced by
+    its dependencies (e.g. "uvicorn", ...).
+
+    """
     result = []
     for specifier in pip_list:
-        requirement = Requirement.parse(specifier)
+        try:
+            requirement = Requirement.parse(specifier)
+        except Exception:
+            # Some lines in a pip_list might not be requirements but
+            # rather options for `pip`; e.g. `--extra-index-url MY_INDEX`.
+            # Requirement.parse would raise an InvalidRequirement in this
+            # case.  Since we are only interested in lines specifying Ray
+            # or its libraries, we should just skip this line.
+            result.append(specifier)
+            continue
         package_name = requirement.name
         if package_name == "ray":
             libraries = requirement.extras  # e.g. ("serve", "tune")
diff --git a/python/ray/tests/test_runtime_env_conda_and_pip.py b/python/ray/tests/test_runtime_env_conda_and_pip.py
index 30fe8ebeee7e9..70dbbac3bf8cb 100644
--- a/python/ray/tests/test_runtime_env_conda_and_pip.py
+++ b/python/ray/tests/test_runtime_env_conda_and_pip.py
@@ -8,7 +8,10 @@
                                      check_local_files_gced,
                                      generate_runtime_env_dict)
 from ray._private.runtime_env.conda import _get_conda_dict_with_ray_inserted
-from ray._private.runtime_env.validation import ParsedRuntimeEnv
+from ray._private.runtime_env.validation import (
+    ParsedRuntimeEnv,
+    _rewrite_pip_list_ray_libraries,
+)
 
 import yaml
 import tempfile
@@ -22,6 +25,15 @@
     os.environ["RAY_RUNTIME_ENV_LOCAL_DEV_MODE"] = "1"
 
 
+def test_rewrite_pip_list_ray_libraries():
+    input = ["--extra-index-url my.url", "ray==1.4", "requests", "ray[serve]"]
+    output = _rewrite_pip_list_ray_libraries(input)
+    assert "ray" not in output
+    assert "ray==1.4" not in output
+    assert "ray[serve]" not in output
+    assert output[:2] == ["--extra-index-url my.url", "requests"]
+
+
 def test_get_conda_dict_with_ray_inserted_m1_wheel(monkeypatch):
     # Disable dev mode to prevent Ray dependencies being automatically inserted
     # into the conda dict.
@@ -58,11 +70,15 @@ def test_get_conda_dict_with_ray_inserted_m1_wheel(monkeypatch):
     os.environ.get("CI") and sys.platform != "linux",
     reason="Requires PR wheels built in CI, so only run on linux CI machines.")
 @pytest.mark.parametrize("field", ["conda", "pip"])
-def test_files_remote_cluster(start_cluster, field):
-    """Test that requirements files are parsed on the driver, not the cluster.
+def test_requirements_files(start_cluster, field):
+    """Test the use of requirements.txt and environment.yaml.
 
+    Tests that requirements files are parsed on the driver, not the cluster.
     This is the desired behavior because the file paths only make sense on the
     driver machine. The files do not exist on the remote cluster.
+
+    Also tests the common use case of specifying the option --extra-index-url
+    in a pip requirements.txt file.
     """
     cluster, address = start_cluster
 
@@ -72,18 +88,17 @@ def test_files_remote_cluster(start_cluster, field):
     # temporary directory.  So if the nodes try to read the requirements file,
     # this test should fail because the relative path won't make sense.
     with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
+        pip_list = [
+            "--extra-index-url https://pypi.org/simple",
+            "pip-install-test==0.5",
+        ]
         if field == "conda":
-            conda_dict = {
-                "dependencies": ["pip", {
-                    "pip": ["pip-install-test==0.5"]
-                }]
-            }
+            conda_dict = {"dependencies": ["pip", {"pip": pip_list}]}
             relative_filepath = "environment.yml"
             conda_file = Path(relative_filepath)
             conda_file.write_text(yaml.dump(conda_dict))
             runtime_env = {"conda": relative_filepath}
         elif field == "pip":
-            pip_list = ["pip-install-test==0.5"]
             relative_filepath = "requirements.txt"
             pip_file = Path(relative_filepath)
             pip_file.write_text("\n".join(pip_list))

<!-- Thank you for your contribution! Please review https://github.com/ray-project/ray/blob/master/CONTRIBUTING.rst before opening a pull request. -->

<!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. -->

## Why are these changes needed?
This PR cherry-picks the fix https://github.com/ray-project/ray/pull/22065 to the 1.11.0 release branch.

Merging this PR requires two approvals:  -- one committer and the TL owning the component (@edoakes).  
<!-- Please give a short summary of the change and the problem this solves. -->

## Related issue number

<!-- For example: "Closes #1234" -->

## Checks

- [ ] I've run `scripts/format.sh` to lint the changes in this PR.
- [ ] I've included any doc changes needed for https://docs.ray.io/en/master/.
- [ ] I've made sure the tests are passing. Note that there might be a few flaky tests, see the recent failures at https://flakey-tests.ray.io/
- Testing Strategy
   - [ ] Unit tests
   - [ ] Release tests
   - [ ] This PR is not tested :(
