Repository: iterative/dvc

Issue:
dvc repro: does not set proper folder permissions in runs cache
## Bug Report

When setting up a shared development server, I have populated the cache and changed all the permissions properly according to the documentation, and set `config.shared` to `group`. Most operations on the cache work as intended, but running `dvc repro ...` as the secondary user resulted in an error. I can't remember specifically, but it was something like "operation not permitted" with the path of a file `cache/runs/5d/...`.

I changed the directory permissions manually again and that run then completed properly. I did some more experiments on the other users account to troubleshoot and inspected the cache again:

```
$ ls -l runs/
total 0
drwxr-xr-x 3 abad   dvc 78 Sep 22 13:12 10
drwxrwxr-x 3 robert dvc 78 Sep 22 06:40 39
drwxrwxr-x 3 robert dvc 78 Sep 22 06:42 5c
drwxr-xr-x 3 abad   dvc 78 Sep 22 13:11 6c
drwxr-xr-x 3 abad   dvc 78 Sep 22 06:45 70
drwxr-xr-x 3 abad   dvc 78 Sep 22 06:45 f3
```

The directories created by my user (robert) were manually fixed by me, but the directories owned by abad were created later by DVC.

@efiop seems to have isolated the bug to:

https://github.com/iterative/dvc/blob/ebaffd728eb55a7d52293383175790b81263f3ab/dvc/stage/cache.py#L148

Where these functions do not follow the `cache.shared` setting.

### Please provide information about your setup

**Output of `dvc version`:**

```console
$ dvc version
```

**Additional Information (if any):**

If applicable, please also provide a `--verbose` output of the command, eg: `dvc add --verbose`.



Discussion:

Resolving patch:
diff --git a/dvc/stage/cache.py b/dvc/stage/cache.py
--- a/dvc/stage/cache.py
+++ b/dvc/stage/cache.py
@@ -1,5 +1,6 @@
 import logging
 import os
+import tempfile
 from contextlib import contextmanager
 
 from funcy import first
@@ -7,9 +8,9 @@
 
 from dvc.cache.local import _log_exceptions
 from dvc.exceptions import DvcException
+from dvc.path_info import PathInfo
 from dvc.schema import COMPILED_LOCK_FILE_STAGE_SCHEMA
 from dvc.utils import dict_sha256, relpath
-from dvc.utils.fs import makedirs
 from dvc.utils.serialize import YAMLFileCorruptedError, dump_yaml, load_yaml
 
 from .loader import StageLoader
@@ -49,6 +50,10 @@ def __init__(self, repo):
         self.repo = repo
         self.cache_dir = os.path.join(repo.cache.local.cache_dir, "runs")
 
+    @property
+    def tree(self):
+        return self.repo.cache.local.tree
+
     def _get_cache_dir(self, key):
         return os.path.join(self.cache_dir, key[:2], key)
 
@@ -143,10 +148,13 @@ def save(self, stage):
         # sanity check
         COMPILED_LOCK_FILE_STAGE_SCHEMA(cache)
 
-        path = self._get_cache_path(cache_key, cache_value)
-        dpath = os.path.dirname(path)
-        makedirs(dpath, exist_ok=True)
-        dump_yaml(path, cache)
+        path = PathInfo(self._get_cache_path(cache_key, cache_value))
+        self.tree.makedirs(path.parent)
+        tmp = tempfile.NamedTemporaryFile(delete=False, dir=path.parent).name
+        assert os.path.exists(path.parent)
+        assert os.path.isdir(path.parent)
+        dump_yaml(tmp, cache)
+        self.tree.move(PathInfo(tmp), path)
 
     def _restore(self, stage):
         stage.save_deps()


Test patch:
diff --git a/tests/unit/stage/test_cache.py b/tests/unit/stage/test_cache.py
--- a/tests/unit/stage/test_cache.py
+++ b/tests/unit/stage/test_cache.py
@@ -158,3 +158,49 @@ def test_stage_cache_wdir(tmp_dir, dvc, mocker):
     assert (tmp_dir / "wdir" / "out_no_cache").exists()
     assert (tmp_dir / "wdir" / "out").read_text() == "out"
     assert (tmp_dir / "wdir" / "out_no_cache").read_text() == "out_no_cache"
+
+
+def test_shared_stage_cache(tmp_dir, dvc, run_copy):
+    import stat
+
+    from dvc.cache import Cache
+
+    tmp_dir.gen("foo", "foo")
+
+    with dvc.config.edit() as config:
+        config["cache"]["shared"] = "group"
+
+    dvc.cache = Cache(dvc)
+
+    run_copy("foo", "bar", name="copy-foo-bar")
+
+    parent_cache_dir = os.path.join(dvc.stage_cache.cache_dir, "88",)
+    cache_dir = os.path.join(
+        parent_cache_dir,
+        "883395068439203a9de3d1e1649a16e9027bfd1ab5dab4f438d321c4a928b328",
+    )
+    cache_file = os.path.join(
+        cache_dir,
+        "e42b7ebb9bc5ac4bccab769c8d1338914dad25d7ffecc8671dbd4581bad4aa15",
+    )
+
+    # sanity check
+    assert os.path.isdir(cache_dir)
+    assert os.listdir(cache_dir) == [os.path.basename(cache_file)]
+    assert os.path.isfile(cache_file)
+
+    def _mode(path):
+        return stat.S_IMODE(os.stat(path).st_mode)
+
+    if os.name == "nt":
+        dir_mode = 0o777
+        file_mode = 0o666
+    else:
+        dir_mode = 0o775
+        file_mode = 0o664
+
+    assert _mode(dvc.cache.local.cache_dir) == dir_mode
+    assert _mode(dvc.stage_cache.cache_dir) == dir_mode
+    assert _mode(parent_cache_dir) == dir_mode
+    assert _mode(cache_dir) == dir_mode
+    assert _mode(cache_file) == file_mode