Repository: iterative/dvc

Issue:
add / commit: overwrites per-output remote
# Bug Report

## Description

When a .dvc has had a value set for `remote:` (under `out:`) and the actual file content is changed, then `dvc add` and `dvc commit` remove the `remote` value

### Reproduce

 1. `dvc remote add --default storage_2 /tmp/dvcstore_alt`
 2. Add a file like `cp ~/Downloads/example.tsv ./example.tsv`
 3. `dvc add example.tsv`
 4. Edit `example.tsv.dvc` to include a `remote: storage_2

```
outs:
- md5: 0f473951d64d6dd73e07965da6b2758b
  size: 4393
  path: example.tsv
  remote: storage_2

```

 5. git commit and dvc push
 6. Edit `example.tsv`
 7. `dvc add example.tsv`
 8. See that `example.tsv.dvc` has removed the `remote:` value

```diff
diff --git a/example.tsv.dvc b/example.tsv.dvc
index c39daf5..70654ae 100644
--- a/example.tsv.dvc
+++ b/example.tsv.dvc
@@ -1,5 +1,4 @@
 outs:
-- md5: 0f473951d64d6dd73e07965da6b2758b
-  size: 4393
+- md5: f18353455728c7d54aafac4d6087cd9f
+  size: 4511
   path: example.tsv
-  remote: storage_2

```

### Expected

add and commit should leave `remote` if present in a .dvc file

### Environment information

**Output of `dvc doctor`:**

```console
$ dvc doctor
DVC version: 2.9.3 (brew)
---------------------------------
Platform: Python 3.9.10 on macOS-11.6.2-x86_64-i386-64bit
Supports:
	azure (adlfs = 2021.10.0, knack = 0.9.0, azure-identity = 1.7.1),
	gdrive (pydrive2 = 1.10.0),
	gs (gcsfs = 2021.11.1),
	webhdfs (fsspec = 2021.11.1),
	http (aiohttp = 3.8.1, aiohttp-retry = 2.4.6),
	https (aiohttp = 3.8.1, aiohttp-retry = 2.4.6),
	s3 (s3fs = 2021.11.1, boto3 = 1.19.8),
	ssh (sshfs = 2021.11.2),
	webdav (webdav4 = 0.9.3),
	webdavs (webdav4 = 0.9.3)
Cache types: reflink, hardlink, symlink
Cache directory: apfs on /dev/disk1s1s1
Caches: local
Remotes: local, local
Workspace directory: apfs on /dev/disk1s1s1
Repo: dvc, git

```

**Additional Information (if any):**

This feature looks relatively new/experimental from https://github.com/iterative/dvc/pull/6486 although it's in the docs at https://dvc.org/doc/user-guide/project-structure/dvc-files#output-entries


Comments:
not only commit/add, some action like 'dvc move' would not count desc/remote in as well.  It seems we cannot add remote and desc into dvc file currently. 
@skshetry  how about updating the doc in  https://dvc.org/doc/user-guide/project-structure/dvc-files#output-entries
We try to preserve the data for meta and desc here, but we did not do that for `remote`.

https://github.com/iterative/dvc/blob/30d6839a5f471fb02a7a2dcbd6a6616b71277762/dvc/stage/__init__.py#L116-L119

The `remote` can be accessed from `out.remote`, and can be set to the new stage that we are creating, as we do for `desc`.

---

For the `move`, it's going to be a bit more complicated, but involves the same thing that we do above.


Hey DVC team! Wondering whether there is an ETA on this issue or if not, whether I can take a stab at a fix? I am working with a setup where I require per-dvc-file remote outputs and this bug is making it infeasible to update (and push) ml model files to dvc remotes when conducting model training on Gitlab runners.

Patch:
diff --git a/dvc/output.py b/dvc/output.py
--- a/dvc/output.py
+++ b/dvc/output.py
@@ -670,6 +670,9 @@ def dumpd(self):
         if self.live:
             ret[self.PARAM_LIVE] = self.live
 
+        if self.remote:
+            ret[self.PARAM_REMOTE] = self.remote
+
         return ret
 
     def verify_metric(self):
diff --git a/dvc/repo/imp_url.py b/dvc/repo/imp_url.py
--- a/dvc/repo/imp_url.py
+++ b/dvc/repo/imp_url.py
@@ -24,7 +24,7 @@ def imp_url(
     jobs=None,
 ):
     from dvc.dvcfile import Dvcfile
-    from dvc.stage import Stage, create_stage, restore_meta
+    from dvc.stage import Stage, create_stage, restore_fields
 
     out = resolve_output(url, out)
     path, wdir, out = resolve_paths(
@@ -58,7 +58,7 @@ def imp_url(
         outs=[out],
         erepo=erepo,
     )
-    restore_meta(stage)
+    restore_fields(stage)
 
     if desc:
         stage.outs[0].desc = desc
diff --git a/dvc/repo/stage.py b/dvc/repo/stage.py
--- a/dvc/repo/stage.py
+++ b/dvc/repo/stage.py
@@ -157,7 +157,12 @@ def create(
             stage_data: Stage data to create from
                 (see create_stage and loads_from for more information)
         """
-        from dvc.stage import PipelineStage, Stage, create_stage, restore_meta
+        from dvc.stage import (
+            PipelineStage,
+            Stage,
+            create_stage,
+            restore_fields,
+        )
         from dvc.stage.exceptions import InvalidStageName
         from dvc.stage.utils import (
             is_valid_name,
@@ -190,7 +195,7 @@ def create(
             new_index = self.repo.index.add(stage)
             new_index.check_graph()
 
-        restore_meta(stage)
+        restore_fields(stage)
         return stage
 
     def from_target(
diff --git a/dvc/stage/__init__.py b/dvc/stage/__init__.py
--- a/dvc/stage/__init__.py
+++ b/dvc/stage/__init__.py
@@ -95,7 +95,7 @@ def create_stage(cls, repo, path, external=False, **kwargs):
     return stage
 
 
-def restore_meta(stage):
+def restore_fields(stage):
     from .exceptions import StageNotFound
 
     if not stage.dvcfile.exists():
@@ -113,10 +113,12 @@ def restore_meta(stage):
     stage.meta = old.meta
     stage.desc = old.desc
 
-    old_desc = {out.def_path: out.desc for out in old.outs}
+    old_fields = {out.def_path: (out.desc, out.remote) for out in old.outs}
 
     for out in stage.outs:
-        out.desc = old_desc.get(out.def_path, None)
+        out_fields = old_fields.get(out.def_path, None)
+        if out_fields:
+            out.desc, out.remote = out_fields
 
 
 class Stage(params.StageParams):


Test patch:
diff --git a/tests/func/test_add.py b/tests/func/test_add.py
--- a/tests/func/test_add.py
+++ b/tests/func/test_add.py
@@ -894,7 +894,7 @@ def test_add_with_cache_link_error(tmp_dir, dvc, mocker, capsys):
     }
 
 
-def test_add_preserve_meta(tmp_dir, dvc):
+def test_add_preserve_fields(tmp_dir, dvc):
     text = textwrap.dedent(
         """\
         # top comment
@@ -902,11 +902,11 @@ def test_add_preserve_meta(tmp_dir, dvc):
         outs:
         - path: foo # out comment
           desc: out desc
+          remote: testremote
         meta: some metadata
     """
     )
     tmp_dir.gen("foo.dvc", text)
-
     tmp_dir.dvc_gen("foo", "foo")
     assert (tmp_dir / "foo.dvc").read_text() == textwrap.dedent(
         """\
@@ -915,6 +915,7 @@ def test_add_preserve_meta(tmp_dir, dvc):
         outs:
         - path: foo # out comment
           desc: out desc
+          remote: testremote
           md5: acbd18db4cc2f85cedef654fccc4a4d8
           size: 3
         meta: some metadata
diff --git a/tests/func/test_commit.py b/tests/func/test_commit.py
--- a/tests/func/test_commit.py
+++ b/tests/func/test_commit.py
@@ -1,4 +1,5 @@
 import os
+import textwrap
 
 import pytest
 
@@ -39,6 +40,36 @@ def test_commit_force(tmp_dir, dvc):
     assert dvc.status([stage.path]) == {}
 
 
+def test_commit_preserve_fields(tmp_dir, dvc):
+    text = textwrap.dedent(
+        """\
+        # top comment
+        desc: top desc
+        outs:
+        - path: foo # out comment
+          desc: out desc
+          remote: testremote
+        meta: some metadata
+    """
+    )
+    tmp_dir.gen("foo.dvc", text)
+    tmp_dir.dvc_gen("foo", "foo", commit=False)
+    dvc.commit("foo")
+    assert (tmp_dir / "foo.dvc").read_text() == textwrap.dedent(
+        """\
+        # top comment
+        desc: top desc
+        outs:
+        - path: foo # out comment
+          desc: out desc
+          remote: testremote
+          md5: acbd18db4cc2f85cedef654fccc4a4d8
+          size: 3
+        meta: some metadata
+    """
+    )
+
+
 @pytest.mark.parametrize("run_kw", [{"single_stage": True}, {"name": "copy"}])
 def test_commit_with_deps(tmp_dir, dvc, run_copy, run_kw):
     tmp_dir.gen("foo", "foo")