Repository: spack/spack

Issue:
containerize: Preserve view symlink
For a `spack containerize` image, the `/opt/view` symlink is resolved when it is copied to the final image (see [this question on Stack Overflow](https://stackoverflow.com/questions/66821257/what-are-docker-copys-rules-about-symlinks-how-can-i-preserve-symlinks)). This makes the final image larger than necessary with duplicate symlinks, and later attempts to regenerate the view fail. Copy a directory instead of the symlink itself to avoid this quirk.

After building, the final image is over 30 MB smaller for a small environment:
```console
$ cat spack.yaml
spack:
  specs: [dyninst]
  container:
    images:
      os: 'ubuntu:22.04'
      spack: develop
    strip: false
$ podman images 'tmp-*'
REPOSITORY            TAG         IMAGE ID      CREATED             SIZE
localhost/tmp-after   latest      667f5a7fc9fa  About a minute ago  470 MB
localhost/tmp-before  latest      462d8305f041  28 minutes ago      505 MB
```



Discussion:
@spackbot fix style
Let me see if I can fix that for you!

I was able to run `spack style --fix` for you!
<details>
<summary><b>spack style --fix</b></summary>

```bash
==> Running style checks on spack
  selected: isort, black, flake8, mypy
==> Modified files
  lib/spack/spack/container/writers/__init__.py
==> Running isort checks
  isort checks were clean
==> Running black checks
reformatted lib/spack/spack/container/writers/__init__.py
All done! ✨ 🍰 ✨
1 file reformatted.
  black checks were clean
==> Running flake8 checks
  flake8 checks were clean
==> Running mypy checks
Success: no issues found in 577 source files
  mypy checks were clean
==> spack style checks were clean

```
</details>
Keep in mind that I cannot fix your flake8 or mypy errors, so if you have any you'll need to fix them and update the pull request.
If I was able to push to your branch, if you make further changes you will need to pull from your updated branch before pushing again.


I've updated the branch with style fixes.
Wouldn't it be more straightforward to have an extra parent dir and copy that instead? e.g. `/opt/spack/view`. That should also make the first stage more usable, which may be used for debugging.

Also @becker33, didn't you work on getting rid of that view symlink?

Resolving patch:
diff --git a/lib/spack/spack/container/writers/__init__.py b/lib/spack/spack/container/writers/__init__.py
--- a/lib/spack/spack/container/writers/__init__.py
+++ b/lib/spack/spack/container/writers/__init__.py
@@ -5,8 +5,8 @@
 """Writers for different kind of recipes and related
 convenience functions.
 """
-import collections
 import copy
+from collections import namedtuple
 from typing import Optional
 
 import spack.environment as ev
@@ -159,13 +159,13 @@ def depfile(self):
     @tengine.context_property
     def run(self):
         """Information related to the run image."""
-        Run = collections.namedtuple("Run", ["image"])
+        Run = namedtuple("Run", ["image"])
         return Run(image=self.final_image)
 
     @tengine.context_property
     def build(self):
         """Information related to the build image."""
-        Build = collections.namedtuple("Build", ["image"])
+        Build = namedtuple("Build", ["image"])
         return Build(image=self.build_image)
 
     @tengine.context_property
@@ -176,12 +176,13 @@ def strip(self):
     @tengine.context_property
     def paths(self):
         """Important paths in the image"""
-        Paths = collections.namedtuple("Paths", ["environment", "store", "hidden_view", "view"])
+        Paths = namedtuple("Paths", ["environment", "store", "view_parent", "view", "former_view"])
         return Paths(
             environment="/opt/spack-environment",
             store="/opt/software",
-            hidden_view="/opt/._view",
-            view="/opt/view",
+            view_parent="/opt/views",
+            view="/opt/views/view",
+            former_view="/opt/view",  # /opt/view -> /opt/views/view for backward compatibility
         )
 
     @tengine.context_property
@@ -257,7 +258,7 @@ def _package_info_from(self, package_list):
 
         update, install, clean = commands_for(os_pkg_manager)
 
-        Packages = collections.namedtuple("Packages", ["update", "install", "list", "clean"])
+        Packages = namedtuple("Packages", ["update", "install", "list", "clean"])
         return Packages(update=update, install=install, list=package_list, clean=clean)
 
     def _os_pkg_manager(self):
@@ -273,7 +274,7 @@ def _os_pkg_manager(self):
 
     @tengine.context_property
     def extra_instructions(self):
-        Extras = collections.namedtuple("Extra", ["build", "final"])
+        Extras = namedtuple("Extra", ["build", "final"])
         extras = self.container_config.get("extra_instructions", {})
         build, final = extras.get("build", None), extras.get("final", None)
         return Extras(build=build, final=final)
@@ -295,7 +296,7 @@ def bootstrap(self):
             context = {"bootstrap": {"image": self.bootstrap_image, "spack_checkout": command}}
             bootstrap_recipe = env.get_template(template_path).render(**context)
 
-        Bootstrap = collections.namedtuple("Bootstrap", ["image", "recipe"])
+        Bootstrap = namedtuple("Bootstrap", ["image", "recipe"])
         return Bootstrap(image=self.bootstrap_image, recipe=bootstrap_recipe)
 
     @tengine.context_property
@@ -303,7 +304,7 @@ def render_phase(self):
         render_bootstrap = bool(self.bootstrap_image)
         render_build = not (self.last_phase == "bootstrap")
         render_final = self.last_phase in (None, "final")
-        Render = collections.namedtuple("Render", ["bootstrap", "build", "final"])
+        Render = namedtuple("Render", ["bootstrap", "build", "final"])
         return Render(bootstrap=render_bootstrap, build=render_build, final=render_final)
 
     def __call__(self):


Test patch: