Count references to --remote-image-dir
Acloud creates acloud_ref_cnt.txt in --remote-image-dir. The file
records number of instances using the directory. Each instance's base
directory contains "image_dir_link" that links to the image directory.
The link and the reference count are atomically updated with flock
commands.
Test: acloud-dev create -vv --local-image ~/target_files.zip \
      --cvd-host-package ~/cvd-host_package.tar.gz \
      --local-system-image ~/system.img \
      --host 192.168.9.2 --host-user vsoc-01 \
      --host-ssh-private-key-path ~/id_rsa \
      --remote-image-dir img
Bug: 293966645
Change-Id: I27a4c9e4f232d162e9b5e978b53845fd62c6a4b3
diff --git a/create/create_args.py b/create/create_args.py
index cba574d..482e6cf 100644
--- a/create/create_args.py
+++ b/create/create_args.py
@@ -19,6 +19,7 @@
 import argparse
 import logging
 import os
+import posixpath as remote_path
 
 from acloud import errors
 from acloud.create import create_common
@@ -882,9 +883,14 @@
         raise errors.UnsupportedCreateArgs(
             "--host-ssh-private-key-path is only supported for remote host.")
 
-    if args.remote_image_dir and args.remote_host is None:
-        raise errors.UnsupportedCreateArgs(
-            "--remote-image-dir is only supported for remote host.")
+    if args.remote_image_dir:
+        if args.remote_host is None:
+            raise errors.UnsupportedCreateArgs(
+                "--remote-image-dir is only supported for remote host.")
+        if remote_path.basename(
+                remote_path.normpath(args.remote_image_dir)) in ("..", "."):
+            raise errors.UnsupportedCreateArgs(
+                "--remote-image-dir must not include the working directory.")
 
 
 def _VerifyGoldfishArgs(args):
diff --git a/internal/lib/cvd_utils.py b/internal/lib/cvd_utils.py
index 40197aa..ed3ecc5 100644
--- a/internal/lib/cvd_utils.py
+++ b/internal/lib/cvd_utils.py
@@ -77,6 +77,11 @@
     _REMOTE_EXTRA_IMAGE_DIR, _INITRAMFS_IMAGE_NAME)
 _REMOTE_SUPER_IMAGE_PATH = remote_path.join(
     _REMOTE_EXTRA_IMAGE_DIR, _SUPER_IMAGE_NAME)
+# The symbolic link to --remote-image-dir. It's in the base directory.
+_IMAGE_DIR_LINK_NAME = "image_dir_link"
+# The text file contains the number of references to --remote-image-dir.
+# Th path is --remote-image-dir + EXT.
+_REF_CNT_FILE_EXT = ".lock"
 
 # Remote host instance name
 _REMOTE_HOST_INSTANCE_NAME_FORMAT = (
@@ -549,6 +554,7 @@
     """
     # FIXME: Use the images and launch_cvd in --remote-image-dir when
     # cuttlefish can reliably share images.
+    _DeleteRemoteImageDirLink(ssh_obj, remote_dir)
     home = remote_path.join("$HOME", remote_dir)
     stop_cvd_path = remote_path.join(remote_dir, "bin", "stop_cvd")
     stop_cvd_cmd = f"'HOME={home} {stop_cvd_path}'"
@@ -561,7 +567,7 @@
             logger.debug(
                 "Failed to stop_cvd (possibly no running device): %s", e)
 
-    # This command deletes all files except hidden files under HOME.
+    # This command deletes all files except hidden files under remote_dir.
     # It does not raise an error if no files can be deleted.
     ssh_obj.Run(f"'rm -rf {remote_path.join(remote_dir, '*')}'")
 
@@ -615,6 +621,67 @@
     return None
 
 
+def PrepareRemoteImageDirLink(ssh_obj, remote_dir, remote_image_dir):
+    """Create a link to a directory containing images and tools.
+
+    Args:
+        ssh_obj: An Ssh object.
+        remote_dir: The directory in which the link is created.
+        remote_image_dir: The directory that is linked to.
+    """
+    remote_link = remote_path.join(remote_dir, _IMAGE_DIR_LINK_NAME)
+
+    # If remote_image_dir is relative to HOME, compute the relative path based
+    # on remote_dir.
+    ln_cmd = ("ln -s " +
+              ("" if remote_path.isabs(remote_image_dir) else "-r ") +
+              f"{remote_image_dir} {remote_link}")
+
+    remote_ref_cnt = remote_path.normpath(remote_image_dir) + _REF_CNT_FILE_EXT
+    ref_cnt_cmd = (f"expr $(test -s {remote_ref_cnt} && "
+                   f"cat {remote_ref_cnt} || echo 0) + 1 > {remote_ref_cnt}")
+
+    # `flock` creates the file automatically.
+    # This command should create its parent directory before `flock`.
+    ssh_obj.Run(shlex.quote(
+        f"mkdir -p {remote_image_dir} && flock {remote_ref_cnt} -c " +
+        shlex.quote(
+            f"mkdir -p {remote_dir} {remote_image_dir} && "
+            f"{ln_cmd} && {ref_cnt_cmd}")))
+
+
+def _DeleteRemoteImageDirLink(ssh_obj, remote_dir):
+    """Delete the directories containing images and tools.
+
+    Args:
+        ssh_obj: An Ssh object.
+        remote_dir: The directory containing the link to the image directory.
+    """
+    remote_link = remote_path.join(remote_dir, _IMAGE_DIR_LINK_NAME)
+    # This command returns an absolute path if the link exists; otherwise
+    # an empty string. It raises an exception only if connection error.
+    remote_image_dir = ssh_obj.Run(
+        shlex.quote(f"readlink -n -e {remote_link} || true"))
+    if not remote_image_dir:
+        return
+
+    remote_ref_cnt = (remote_path.normpath(remote_image_dir) +
+                      _REF_CNT_FILE_EXT)
+    # `expr` returns 1 if the result is 0.
+    ref_cnt_cmd = (f"expr $(test -s {remote_ref_cnt} && "
+                   f"cat {remote_ref_cnt} || echo 1) - 1 > "
+                   f"{remote_ref_cnt}")
+
+    # `flock` creates the file automatically.
+    # This command should create its parent directory before `flock`.
+    ssh_obj.Run(shlex.quote(
+        f"mkdir -p {remote_image_dir} && flock {remote_ref_cnt} -c " +
+        shlex.quote(
+            f"rm -f {remote_link} && "
+            f"{ref_cnt_cmd} || "
+            f"rm -rf {remote_image_dir} {remote_ref_cnt}")))
+
+
 def LoadRemoteImageArgs(ssh_obj, remote_args_path):
     """Load launch_cvd arguments from a remote path.
 
diff --git a/internal/lib/cvd_utils_test.py b/internal/lib/cvd_utils_test.py
index fec013c..fd44e78 100644
--- a/internal/lib/cvd_utils_test.py
+++ b/internal/lib/cvd_utils_test.py
@@ -261,18 +261,36 @@
     def testCleanUpRemoteCvd(self):
         """Test CleanUpRemoteCvd."""
         mock_ssh = mock.Mock()
+        mock_ssh.Run.side_effect = ["", "", ""]
         cvd_utils.CleanUpRemoteCvd(mock_ssh, "dir", raise_error=True)
-        mock_ssh.Run.assert_any_call("'HOME=$HOME/dir dir/bin/stop_cvd'")
-        mock_ssh.Run.assert_any_call("'rm -rf dir/*'")
+        mock_ssh.Run.assert_has_calls([
+            mock.call("'readlink -n -e dir/image_dir_link || true'"),
+            mock.call("'HOME=$HOME/dir dir/bin/stop_cvd'"),
+            mock.call("'rm -rf dir/*'")])
+
+        mock_ssh.reset_mock()
+        mock_ssh.Run.side_effect = ["img_dir", "", "", ""]
+        cvd_utils.CleanUpRemoteCvd(mock_ssh, "dir", raise_error=True)
+        mock_ssh.Run.assert_has_calls([
+            mock.call("'readlink -n -e dir/image_dir_link || true'"),
+            mock.call("'mkdir -p img_dir && flock img_dir.lock -c '\"'\"'"
+                      "rm -f dir/image_dir_link && "
+                      "expr $(test -s img_dir.lock && "
+                      "cat img_dir.lock || echo 1) - 1 > img_dir.lock || "
+                      "rm -rf img_dir img_dir.lock'\"'\"''"),
+            mock.call("'HOME=$HOME/dir dir/bin/stop_cvd'"),
+            mock.call("'rm -rf dir/*'")])
 
         mock_ssh.reset_mock()
         mock_ssh.Run.side_effect = [
+            "",
             subprocess.CalledProcessError(cmd="should raise", returncode=1)]
         with self.assertRaises(subprocess.CalledProcessError):
             cvd_utils.CleanUpRemoteCvd(mock_ssh, "dir", raise_error=True)
 
         mock_ssh.reset_mock()
         mock_ssh.Run.side_effect = [
+            "",
             subprocess.CalledProcessError(cmd="should ignore", returncode=1),
             None]
         cvd_utils.CleanUpRemoteCvd(mock_ssh, "dir", raise_error=False)
@@ -309,6 +327,74 @@
             "host-goldfish-192.0.2.1-5554-123456-sdk_x86_64-sdk")
         self.assertIsNone(result)
 
+    # pylint: disable=protected-access
+    def testRemoteImageDirLink(self):
+        """Test PrepareRemoteImageDirLink and _DeleteRemoteImageDirLink."""
+        self.assertEqual(os.path, cvd_utils.remote_path)
+        with tempfile.TemporaryDirectory(prefix="cvd_utils") as temp_dir:
+            env = os.environ.copy()
+            env["HOME"] = temp_dir
+            # Execute the commands locally.
+            mock_ssh = mock.Mock()
+            mock_ssh.Run.side_effect = lambda cmd: subprocess.check_output(
+                "sh -c " + cmd, shell=True, cwd=temp_dir, env=env
+            ).decode("utf-8")
+            # Relative paths under temp_dir.
+            base_dir_name_1 = "acloud_cf_1"
+            base_dir_name_2 = "acloud_cf_2"
+            image_dir_name = "test/img"
+            rel_ref_cnt_path = "test/img.lock"
+            # Absolute paths.
+            image_dir = os.path.join(temp_dir, image_dir_name)
+            ref_cnt_path = os.path.join(temp_dir, rel_ref_cnt_path)
+            link_path_1 = os.path.join(temp_dir, base_dir_name_1,
+                                       "image_dir_link")
+            link_path_2 = os.path.join(temp_dir, base_dir_name_2,
+                                       "image_dir_link")
+            # Delete non-existing directories.
+            cvd_utils._DeleteRemoteImageDirLink(mock_ssh, base_dir_name_1)
+            mock_ssh.Run.assert_called_with(
+                f"'readlink -n -e {base_dir_name_1}/image_dir_link || true'")
+            self.assertFalse(
+                os.path.exists(os.path.join(temp_dir, base_dir_name_1)))
+            self.assertFalse(os.path.exists(image_dir))
+            self.assertFalse(os.path.exists(ref_cnt_path))
+            # Prepare the first base dir.
+            cvd_utils.PrepareRemoteImageDirLink(mock_ssh, base_dir_name_1,
+                                                image_dir_name)
+            mock_ssh.Run.assert_called_with(
+                f"'mkdir -p {image_dir_name} && flock {rel_ref_cnt_path} -c "
+                f"'\"'\"'mkdir -p {base_dir_name_1} {image_dir_name} && "
+                f"ln -s -r {image_dir_name} "
+                f"{base_dir_name_1}/image_dir_link && "
+                f"expr $(test -s {rel_ref_cnt_path} && "
+                f"cat {rel_ref_cnt_path} || echo 0) + 1 > "
+                f"{rel_ref_cnt_path}'\"'\"''")
+            self.assertTrue(os.path.islink(link_path_1))
+            self.assertEqual("../test/img", os.readlink(link_path_1))
+            self.assertTrue(os.path.isfile(ref_cnt_path))
+            with open(ref_cnt_path, "r", encoding="utf-8") as ref_cnt_file:
+                self.assertEqual("1\n", ref_cnt_file.read())
+            # Prepare the second base dir.
+            cvd_utils.PrepareRemoteImageDirLink(mock_ssh, base_dir_name_2,
+                                                image_dir_name)
+            self.assertTrue(os.path.islink(link_path_2))
+            self.assertEqual("../test/img", os.readlink(link_path_2))
+            self.assertTrue(os.path.isfile(ref_cnt_path))
+            with open(ref_cnt_path, "r", encoding="utf-8") as ref_cnt_file:
+                self.assertEqual("2\n", ref_cnt_file.read())
+            # Delete the first base dir.
+            cvd_utils._DeleteRemoteImageDirLink(mock_ssh, base_dir_name_1)
+            self.assertFalse(os.path.lexists(link_path_1))
+            self.assertTrue(os.path.isfile(ref_cnt_path))
+            with open(ref_cnt_path, "r", encoding="utf-8") as ref_cnt_file:
+                self.assertEqual("1\n", ref_cnt_file.read())
+            # Delete the second base dir.
+            cvd_utils._DeleteRemoteImageDirLink(mock_ssh, base_dir_name_2)
+            self.assertFalse(os.path.lexists(link_path_2))
+            self.assertFalse(os.path.exists(image_dir))
+            self.assertFalse(os.path.exists(ref_cnt_path))
+
     def testLoadRemoteImageArgs(self):
         """Test LoadRemoteImageArgs."""
         self.assertEqual(os.path, cvd_utils.remote_path)
diff --git a/public/actions/remote_host_cf_device_factory.py b/public/actions/remote_host_cf_device_factory.py
index 7d6f88a..cc808be 100644
--- a/public/actions/remote_host_cf_device_factory.py
+++ b/public/actions/remote_host_cf_device_factory.py
@@ -188,6 +188,8 @@
         if remote_image_dir:
             remote_args_path = remote_path.join(remote_image_dir,
                                                 _IMAGE_ARGS_FILE_NAME)
+            cvd_utils.PrepareRemoteImageDirLink(
+                self._ssh, self._GetInstancePath(), remote_image_dir)
             launch_cvd_args = cvd_utils.LoadRemoteImageArgs(
                 self._ssh, remote_args_path)
             if launch_cvd_args is not None:
diff --git a/public/actions/remote_host_cf_device_factory_test.py b/public/actions/remote_host_cf_device_factory_test.py
index 4b6fa5e..6aa2f92 100644
--- a/public/actions/remote_host_cf_device_factory_test.py
+++ b/public/actions/remote_host_cf_device_factory_test.py
@@ -418,6 +418,8 @@
         self._mock_build_api.GetFetchBuildArgs.return_value = ["-test"]
 
         self.assertEqual("inst", factory.CreateInstance())
+        mock_cvd_utils.PrepareRemoteImageDirLink.assert_called_once_with(
+            mock_ssh_obj, "acloud_cf_1", "mock_img_dir")
         mock_cvd_utils.LoadRemoteImageArgs.assert_called_once_with(
             mock_ssh_obj, "mock_img_dir/acloud_image_args.txt")
         mock_cvd_utils.SaveRemoteImageArgs.assert_called_once_with(