ybox 0.9.8.1__py3-none-any.whl → 0.9.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ybox/run/cmd.py CHANGED
@@ -5,7 +5,7 @@ Code for the `ybox-cmd` script that is used to execute programs in an active ybo
5
5
  import argparse
6
6
  import sys
7
7
 
8
- from ybox.cmd import run_command
8
+ from ybox.cmd import parser_version_check, run_command
9
9
  from ybox.env import get_docker_command
10
10
 
11
11
 
@@ -51,4 +51,5 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
51
51
  parser.add_argument("container_name", type=str, help="name of the active ybox")
52
52
  parser.add_argument("command", nargs="*", default="/bin/bash",
53
53
  help="run the given command (default is /bin/bash)")
54
+ parser_version_check(parser, argv)
54
55
  return parser.parse_args(argv)
ybox/run/control.py CHANGED
@@ -6,25 +6,30 @@ import argparse
6
6
  import sys
7
7
  import time
8
8
 
9
- from ybox.cmd import check_active_ybox, get_ybox_state, run_command
9
+ from ybox.cmd import (check_active_ybox, get_ybox_state, parser_version_check,
10
+ run_command)
10
11
  from ybox.config import StaticConfiguration
11
12
  from ybox.env import Environ, get_docker_command
12
13
  from ybox.print import fgcolor, print_color, print_error
13
14
  from ybox.util import wait_for_ybox_container
14
15
 
16
+ # TODO: SW: add backup/restore of the running image with the option to backup $HOME of the
17
+ # container user, and shared root warning if it is being used
18
+
15
19
 
16
20
  def main() -> None:
17
21
  """main function for `ybox-control` script"""
18
22
  main_argv(sys.argv[1:])
19
23
 
20
24
 
21
- def start_container(docker_cmd: str, container_name: str):
25
+ def start_container(docker_cmd: str, args: argparse.Namespace):
22
26
  """
23
27
  Start an existing ybox container.
24
28
 
25
29
  :param docker_cmd: the podman/docker executable to use
26
- :param container_name: name of the container
30
+ :param args: arguments having all attributes passed by the user
27
31
  """
32
+ container_name = args.container
28
33
  if status := get_ybox_state(docker_cmd, container_name, (), exit_on_error=False):
29
34
  if status[0] == "running":
30
35
  print_color(f"Ybox container '{container_name}' already active", fg=fgcolor.cyan)
@@ -33,24 +38,38 @@ def start_container(docker_cmd: str, container_name: str):
33
38
  run_command([docker_cmd, "container", "start", container_name],
34
39
  error_msg="container start")
35
40
  conf = StaticConfiguration(Environ(docker_cmd), status[1], container_name)
36
- wait_for_ybox_container(docker_cmd, conf)
41
+ wait_for_ybox_container(docker_cmd, conf, args.timeout)
37
42
  else:
38
43
  print_error(f"No ybox container '{container_name}' found")
39
44
  sys.exit(1)
40
45
 
41
46
 
42
- def stop_container(docker_cmd: str, container_name: str, fail_on_error: bool):
47
+ def stop_container(docker_cmd: str, args: argparse.Namespace):
43
48
  """
44
- Stop a ybox container.
49
+ Stop an active ybox container.
50
+
51
+ :param docker_cmd: the podman/docker executable to use
52
+ :param args: arguments having all attributes passed by the user
53
+ """
54
+ _stop_container(docker_cmd, args.container, args.timeout,
55
+ fail_on_error=not args.ignore_stopped)
56
+
57
+
58
+ def _stop_container(docker_cmd: str, container_name: str, timeout: int,
59
+ fail_on_error: bool):
60
+ """
61
+ Stop an active ybox container.
45
62
 
46
63
  :param docker_cmd: the podman/docker executable to use
47
64
  :param container_name: name of the container
65
+ :param timeout: seconds to wait for container to stop before killing the container
48
66
  :param fail_on_error: if True then show error message on failure to stop else ignore
49
67
  """
50
68
  if check_active_ybox(docker_cmd, container_name):
51
69
  print_color(f"Stopping ybox container '{container_name}'", fg=fgcolor.cyan)
52
- run_command([docker_cmd, "container", "stop", container_name], error_msg="container stop")
53
- for _ in range(120):
70
+ run_command([docker_cmd, "container", "stop", "-t", str(timeout), container_name],
71
+ error_msg="container stop")
72
+ for _ in range(timeout * 2):
54
73
  time.sleep(0.5)
55
74
  if get_ybox_state(docker_cmd, container_name, ("exited", "stopped"),
56
75
  exit_on_error=False, state_msg=" stopped"):
@@ -63,6 +82,31 @@ def stop_container(docker_cmd: str, container_name: str, fail_on_error: bool):
63
82
  print_color(f"No active ybox container '{container_name}' found", fg=fgcolor.cyan)
64
83
 
65
84
 
85
+ def restart_container(docker_cmd: str, args: argparse.Namespace):
86
+ """
87
+ Restart an existing ybox container.
88
+
89
+ :param docker_cmd: the podman/docker executable to use
90
+ :param args: arguments having all attributes passed by the user
91
+ """
92
+ _stop_container(docker_cmd, args.container, timeout=int(args.timeout / 2), fail_on_error=False)
93
+ start_container(docker_cmd, args)
94
+
95
+
96
+ def show_container_status(docker_cmd: str, args: argparse.Namespace) -> None:
97
+ """
98
+ Show container status which will be a string like running/exited.
99
+
100
+ :param docker_cmd: the podman/docker executable to use
101
+ :param args: arguments having all attributes passed by the user
102
+ """
103
+ container_name = args.container
104
+ if status := get_ybox_state(docker_cmd, container_name, (), exit_on_error=False):
105
+ print(status[0])
106
+ else:
107
+ print_error(f"No ybox container '{container_name}' found")
108
+
109
+
66
110
  def main_argv(argv: list[str]) -> None:
67
111
  """
68
112
  Main entrypoint of `ybox-control` that takes a list of arguments which are usually the
@@ -73,19 +117,7 @@ def main_argv(argv: list[str]) -> None:
73
117
  """
74
118
  args = parse_args(argv)
75
119
  docker_cmd = get_docker_command()
76
- container_name = args.container_name
77
- if args.action == "start":
78
- start_container(docker_cmd, container_name)
79
- elif args.action == "stop":
80
- stop_container(docker_cmd, container_name, fail_on_error=True)
81
- elif args.action == "restart":
82
- stop_container(docker_cmd, container_name, fail_on_error=False)
83
- start_container(docker_cmd, container_name)
84
- elif args.action == "status":
85
- if status := get_ybox_state(docker_cmd, container_name, (), exit_on_error=False):
86
- print(status[0])
87
- else:
88
- print_error(f"No ybox container '{container_name}' found")
120
+ args.func(docker_cmd, args)
89
121
 
90
122
 
91
123
  def parse_args(argv: list[str]) -> argparse.Namespace:
@@ -96,7 +128,42 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
96
128
  :return: the result of parsing using the `argparse` library as a :class:`argparse.Namespace`
97
129
  """
98
130
  parser = argparse.ArgumentParser(description="control ybox containers")
99
- parser.add_argument("action", choices=["start", "stop", "restart", "status"],
100
- help="action to perform")
101
- parser.add_argument("container_name", help="name of the ybox")
131
+ operations = parser.add_subparsers(title="Operations", required=True, metavar="OPERATION",
132
+ help="DESCRIPTION")
133
+
134
+ start = operations.add_parser("start", help="start a ybox container")
135
+ _add_subparser_args(start, 60, "time in seconds to wait for a container to start")
136
+ start.set_defaults(func=start_container)
137
+
138
+ stop = operations.add_parser("stop", help="stop a ybox container")
139
+ _add_subparser_args(stop, 10,
140
+ "time in seconds to wait for a container to stop before killing it")
141
+ stop.add_argument("--ignore-stopped", action="store_true",
142
+ help="don't fail on an already stopped container")
143
+ stop.set_defaults(func=stop_container)
144
+
145
+ restart = operations.add_parser("restart", help="restart a ybox container")
146
+ _add_subparser_args(restart, 60, "time in seconds to wait for a container to restart")
147
+ restart.set_defaults(func=restart_container)
148
+
149
+ status = operations.add_parser("status", help="show status of a ybox container")
150
+ _add_subparser_args(status, 0, "")
151
+ status.set_defaults(func=show_container_status)
152
+
153
+ parser_version_check(parser, argv)
102
154
  return parser.parse_args(argv)
155
+
156
+
157
+ def _add_subparser_args(subparser: argparse.ArgumentParser, timeout_default: int,
158
+ timeout_help: str) -> None:
159
+ """
160
+ Add arguments for the sub-operation of the ybox-control command.
161
+
162
+ :param subparser: the :class:`argparse.ArgumentParser` object for the sub-command
163
+ :param timeout_default: default value for the -t/--timeout argument, or 0 to skip the argument
164
+ :param timeout_help: help string for the -t/--timeout argument
165
+ """
166
+ if timeout_default != 0:
167
+ subparser.add_argument("-t", "--timeout", type=int, default=timeout_default,
168
+ help=timeout_help)
169
+ subparser.add_argument("container", help="name of the ybox")
ybox/run/create.py CHANGED
@@ -18,13 +18,16 @@ from pathlib import Path
18
18
  from textwrap import dedent
19
19
  from typing import Optional
20
20
 
21
- from ybox.cmd import PkgMgr, RepoCmd, YboxLabel, check_ybox_exists, run_command
21
+ from ybox import __version__ as product_version
22
+ from ybox.cmd import (PkgMgr, RepoCmd, YboxLabel, check_ybox_exists,
23
+ parser_version_check, run_command)
22
24
  from ybox.config import Consts, StaticConfiguration
23
25
  from ybox.env import Environ, NotSupportedError, PathName
24
26
  from ybox.filelock import FileLock
25
27
  from ybox.pkg.inst import install_package, wrap_container_files
26
28
  from ybox.print import (bgcolor, fgcolor, print_color, print_error, print_info,
27
- print_warn)
29
+ print_notice, print_warn)
30
+ from ybox.run.destroy import get_all_containers, remove_orphans_from_db
28
31
  from ybox.run.graphics import (add_env_option, add_mount_option, enable_dri,
29
32
  enable_nvidia, enable_wayland, enable_x11)
30
33
  from ybox.run.pkg import parse_args as pkg_parse_args
@@ -181,19 +184,24 @@ def main_argv(argv: list[str]) -> None:
181
184
 
182
185
  # set up the final container with all the required arguments
183
186
  print_info(f"Initializing container for '{distro}' using '{profile}'")
184
- start_container(docker_full_args, current_user, shared_root, shared_root_dirs, conf)
187
+ run_container(docker_full_args, current_user, shared_root, shared_root_dirs, conf)
185
188
  print_info("Waiting for the container to initialize (see "
186
189
  f"'ybox-logs -f {box_name}' for detailed progress)")
187
190
  # wait for container to initialize while printing out its progress from conf.status_file
188
- wait_for_ybox_container(docker_cmd, conf)
191
+ wait_for_ybox_container(docker_cmd, conf, 600)
189
192
 
190
193
  # remove distribution specific scripts and restart container the final time
191
- print_info(f"Restarting the final container '{box_name}'")
194
+ print_info(f"Starting the final container '{box_name}'")
192
195
  Path(f"{conf.scripts_dir}/{Consts.entrypoint_init_done_file()}").touch(mode=0o644)
193
- restart_container(docker_cmd, conf)
194
- print_info("Waiting for the container to be ready (see "
195
- f"'ybox-logs -f {box_name}' for detailed progress)")
196
- wait_for_ybox_container(docker_cmd, conf)
196
+ wait_msg = ("Waiting for the container to be ready "
197
+ f"(see ybox-logs -f {box_name}' for detailed progress)")
198
+ if args.systemd_service and (sys_path := os.pathsep.join(Consts.sys_bin_dirs())) and (
199
+ systemctl := shutil.which("systemctl", path=sys_path)):
200
+ create_and_start_service(box_name, env, systemctl, sys_path, wait_msg)
201
+ else:
202
+ start_container(docker_cmd, conf)
203
+ print_info(wait_msg)
204
+ wait_for_ybox_container(docker_cmd, conf, 120)
197
205
  # truncate the app.list and config.list files so that those actions are skipped if the
198
206
  # container is restarted later
199
207
  if os.access(conf.app_list, os.W_OK):
@@ -201,11 +209,17 @@ def main_argv(argv: list[str]) -> None:
201
209
  if os.access(conf.config_list, os.W_OK):
202
210
  truncate_file(conf.config_list)
203
211
 
212
+ # check and remove any dangling container references in state database
213
+ valid_containers = set(get_all_containers(docker_cmd))
214
+
204
215
  # finally add the state and register the installed packages that were reassigned to this
205
216
  # container (because the previously destroyed one has the same configuration and shared root)
206
217
  with YboxStateManagement(env) as state:
218
+ state.begin_transaction()
219
+ remove_orphans_from_db(valid_containers, state)
207
220
  owned_packages = state.register_container(box_name, distro, shared_root, box_conf,
208
221
  args.force_own_orphans)
222
+ state.commit()
209
223
  # create wrappers for owned_packages
210
224
  if owned_packages:
211
225
  list_cmd = pkgmgr[PkgMgr.LIST_FILES.value]
@@ -254,6 +268,9 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
254
268
  parser.add_argument("-n", "--name", type=str,
255
269
  help="name of the ybox; default is ybox-<distribution>_<profile> "
256
270
  "if not provided (removing the .ini suffix from <profile> file)")
271
+ parser.add_argument("-S", "--systemd-service", action="store_true",
272
+ help="create/overwrite user systemd service file for the container in "
273
+ "~/.config/systemd/user and enable it by default")
257
274
  parser.add_argument("-F", "--force-own-orphans", action="store_true",
258
275
  help="force ownership of orphan packages on the same shared root even "
259
276
  "if container configuration does not match, meaning the packages "
@@ -262,7 +279,14 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
262
279
  "container regardless of the container configuration")
263
280
  parser.add_argument("-C", "--distribution-config", type=str,
264
281
  help="path to distribution configuration file to use instead of the "
265
- "`distro.ini` from user/system configuration paths")
282
+ "'distro.ini' from user/system configuration paths")
283
+ parser.add_argument("--distribution-image", type=str,
284
+ help="custom container image to use that overrides the one specified in "
285
+ "the distribution's 'distro.ini'; note that the distribution "
286
+ "configuration scripts make assumptions on the available utilities "
287
+ "in the image so you should ensure that the provided image is "
288
+ "compatible with and a superset of the base image specified in the "
289
+ "builtin profile of the distribution in the installed version")
266
290
  parser.add_argument("-q", "--quiet", action="store_true",
267
291
  help="proceed without asking any questions using defaults where possible; "
268
292
  "this should usually be used with explicit specification of "
@@ -281,6 +305,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
281
305
  "optional and user is presented with a selection menu of the "
282
306
  "available profiles in the user or system profiles directory "
283
307
  "whichever is found (in that order)")
308
+ parser_version_check(parser, argv)
284
309
  return parser.parse_args(argv)
285
310
 
286
311
 
@@ -363,7 +388,7 @@ def select_profile(args: argparse.Namespace, env: Environ) -> PathName:
363
388
  if len(profiles) == 1:
364
389
  print_info(f"Using profile '{profiles[0]}'")
365
390
  return profiles[0]
366
- if len(profiles) == 0:
391
+ if not profiles:
367
392
  print_error(f"No valid profile found in '{profiles_dir}'")
368
393
  sys.exit(1)
369
394
 
@@ -491,6 +516,12 @@ def read_distribution_config(args: argparse.Namespace,
491
516
  distro_conf_file, only_sys_conf=True), env_interpolation)
492
517
  distro_base_section = distro_config["base"]
493
518
  image_name = distro_base_section["image"] # should always exist
519
+ if args.distribution_image:
520
+ print()
521
+ print_notice(f"Overriding distribution's container image '{image_name}' with the one "
522
+ f"provided on the command-line: {args.distribution_image}")
523
+ print()
524
+ image_name = args.distribution_image
494
525
  shared_root_dirs = distro_base_section["shared_root_dirs"] # should always exist
495
526
  secondary_groups = distro_base_section["secondary_groups"] # should always exist
496
527
  return image_name, shared_root_dirs, secondary_groups, distro_config
@@ -759,7 +790,7 @@ def process_configs_section(configs_section: SectionProxy, config_hardlinks: boo
759
790
  # this is refreshed on every container start
760
791
 
761
792
  # always recreate the directory to pick up any changes
762
- if os.path.exists(conf.configs_dir):
793
+ if os.path.isdir(conf.configs_dir):
763
794
  shutil.rmtree(conf.configs_dir)
764
795
  os.makedirs(conf.configs_dir, mode=Consts.default_directory_mode(), exist_ok=True)
765
796
  if config_hardlinks:
@@ -777,19 +808,36 @@ def process_configs_section(configs_section: SectionProxy, config_hardlinks: boo
777
808
  raise NotSupportedError("Incorrect value format in [configs] section for "
778
809
  f"'{key}'. Required: '{{src}} -> {{dest}}'")
779
810
  src_path = os.path.realpath(f_val[:split_idx].strip())
780
- dest_path = f"{conf.configs_dir}/{f_val[split_idx + 2:].strip()}"
811
+ dest_rel_path = f_val[split_idx + 2:].strip()
812
+ dest_path = f"{conf.configs_dir}/{dest_rel_path}"
781
813
  if os.access(src_path, os.R_OK):
782
- os.makedirs(os.path.dirname(dest_path),
783
- mode=Consts.default_directory_mode(), exist_ok=True)
814
+ if os.path.exists(dest_path):
815
+ if os.path.isdir(dest_path):
816
+ shutil.rmtree(dest_path)
817
+ else:
818
+ os.unlink(dest_path)
819
+ else:
820
+ os.makedirs(os.path.dirname(dest_path),
821
+ mode=Consts.default_directory_mode(), exist_ok=True)
784
822
  if os.path.isdir(src_path):
785
823
  copytree(src_path, dest_path, hardlink=config_hardlinks)
786
824
  else:
787
825
  if config_hardlinks:
788
- os.link(os.path.realpath(src_path), dest_path, follow_symlinks=True)
826
+ try:
827
+ os.link(os.path.realpath(src_path), dest_path, follow_symlinks=True)
828
+ except OSError:
829
+ # in case of error (likely due to cross-device link) fallback to copy
830
+ shutil.copy2(src_path, dest_path, follow_symlinks=True)
789
831
  else:
790
832
  shutil.copy2(src_path, dest_path, follow_symlinks=True)
791
- config_list_fd.write(val)
792
- config_list_fd.write("\n")
833
+ # - if key has ":copy", then indicate creation of copies in the target
834
+ # - if key has ":dir", then indicate replication of directory structure with links
835
+ # for individual files
836
+ # - else a symlink should be created
837
+ # handled by "replicate_config_files" function in entrypoint.sh
838
+ prefix = "COPY" if key.endswith(":copy") else (
839
+ "LINK_DIR" if key.endswith(":dir") else "LINK")
840
+ config_list_fd.write(f"{prefix}:{dest_rel_path}\n")
793
841
  else:
794
842
  print_warn(f"Skipping inaccessible configuration path '{src_path}'")
795
843
  print_info("DONE.")
@@ -822,7 +870,7 @@ def process_apps_section(apps_section: SectionProxy, conf: StaticConfiguration,
822
870
  :return: dictionary of package names mapped to their list of dependencies as specified
823
871
  in the `[apps]` section
824
872
  """
825
- if len(apps_section) == 0:
873
+ if not apps_section:
826
874
  return {}
827
875
  quiet_flag = pkgmgr[PkgMgr.QUIET_FLAG.value]
828
876
  opt_dep_flag = pkgmgr[PkgMgr.OPT_DEP_FLAG.value]
@@ -859,37 +907,73 @@ def process_apps_section(apps_section: SectionProxy, conf: StaticConfiguration,
859
907
 
860
908
 
861
909
  # The shutil.copytree(...) method does not work correctly for "symlinks=False" (or at least
862
- # not like 'cp -rL' or 'cp -rlL') where it does not create the source symlinked file rather
863
- # only the target one in the destination directory.
864
- # This is a simplified version using os.walk(...) that works correctly that always has:
865
- # a. follow_symlinks=True, and b. ignore_dangling_symlinks=True
866
- def copytree(src: str, dest: str, hardlink: bool = False) -> None:
910
+ # not like 'cp -aL' or 'cp -alL') where it does not create the source symlinked file rather
911
+ # only the target one in the destination directory, and neither does it provide the option to
912
+ # create hardlinks.
913
+ #
914
+ # This is a simplified version using recursive os.scandir(...) that works correctly so that the
915
+ # copy will continue to work even if source disappears in all cases but still avoid making copies
916
+ # for all symlinks. So it behaves like follow_symlinks=True if the symlink destination is outside
917
+ # the "src_path" else it is False.
918
+ def copytree(src_path: str, dest: str, hardlink: bool = False,
919
+ src_root: Optional[str] = None) -> None:
867
920
  """
868
921
  Copy or create hard links to a source directory tree in the given destination directory.
869
922
  Since hard links to directories are not supported, the destination will mirror the directories
870
923
  of the source while the files inside will be either copies or hard links to the source.
924
+ Symlinks are copied as such if the source ones point within the tree, else the target is
925
+ followed and copied recursively.
871
926
 
872
- :param src: the source directory tree
927
+ Note: this function only handles regular files and directories (and hard/symbolic links to
928
+ them) and will skip special files like device files, fifos etc.
929
+
930
+ :param src_path: the resolved source directory (using `os.path.realpath` or `Path.resolve`)
873
931
  :param dest: the destination directory which should exist
874
932
  :param hardlink: if True then create hard links to the files in the source (so it should
875
933
  be in the same filesystem) else copy the files, defaults to False
934
+ :param src_root: the resolved root source directory (same as `src_path` if `None`)
876
935
  """
877
- for src_dir, _, src_files in os.walk(src, followlinks=True):
878
- # substitute 'src' prefix with 'dest'
879
- dest_dir = f"{dest}{src_dir[len(src):]}"
880
- os.mkdir(dest_dir, mode=stat.S_IMODE(os.stat(src_dir).st_mode))
881
- for src_file in src_files:
882
- src_path = f"{src_dir}/{src_file}"
883
- if os.path.exists(src_path):
884
- if hardlink:
885
- try:
886
- os.link(os.path.realpath(src_path), f"{dest_dir}/{src_file}",
887
- follow_symlinks=True)
936
+ src_root = src_root or src_path
937
+ os.mkdir(dest, mode=stat.S_IMODE(os.stat(src_path).st_mode))
938
+ # follow symlink if it leads to outside the "src" tree, else copy as a symlink which
939
+ # ensures that all destination files are always accessible regardless of source going
940
+ # away (for example), and also reduces the size with hardlink=False as much as possible
941
+ with os.scandir(src_path) as src_it:
942
+ for entry in src_it:
943
+ entry_path = ""
944
+ entry_st_mode = 0
945
+ dest_path = f"{dest}/{entry.name}"
946
+ try:
947
+ if entry.is_symlink():
948
+ # check if entry is a symlink inside the tree or outside
949
+ l_name = os.readlink(entry.path)
950
+ if "/" not in l_name: # shortcut check for links in the same directory
951
+ os.symlink(l_name, dest_path)
888
952
  continue
889
- except OSError:
890
- # in case of error (likely due to cross-device link) fallback to copying
891
- pass
892
- shutil.copy2(src_path, f"{dest_dir}/{src_file}", follow_symlinks=True)
953
+ entry_path = os.path.realpath(entry.path)
954
+ if entry_path.startswith(src_root) and entry_path[len(src_root)] == "/":
955
+ rpath = entry_path[len(src_root) + 1:]
956
+ os.symlink(("../" * rpath.count("/")) + rpath, dest_path)
957
+ continue
958
+ entry_st_mode = os.stat(entry_path).st_mode
959
+ entry_path = entry_path or entry.path
960
+ if stat.S_ISREG(entry_st_mode) or (entry_st_mode == 0 and entry.is_file()):
961
+ if hardlink:
962
+ try:
963
+ os.link(entry_path, dest_path)
964
+ continue
965
+ except OSError:
966
+ # in case of error (likely due to cross-device link) fallback to copy
967
+ pass
968
+ shutil.copy2(entry_path, dest_path)
969
+ elif stat.S_ISDIR(entry_st_mode) or (entry_st_mode == 0 and entry.is_dir()):
970
+ copytree(entry_path, dest_path, hardlink,
971
+ entry_path if entry_st_mode else src_root)
972
+ else:
973
+ print_warn(f"Skipping copy/link of special file (fifo/dev/...) '{entry_path}'")
974
+ except OSError as err:
975
+ # ignore permission and related errors and continue
976
+ print_warn(f"Skipping copy/link of '{entry_path}' due to error: {err}")
893
977
 
894
978
 
895
979
  def setup_ybox_scripts(conf: StaticConfiguration, distro_config: ConfigParser) -> None:
@@ -902,7 +986,7 @@ def setup_ybox_scripts(conf: StaticConfiguration, distro_config: ConfigParser) -
902
986
  distribution's `distro.ini`
903
987
  """
904
988
  # first create local mount directory having entrypoint and other scripts
905
- if os.path.exists(conf.scripts_dir):
989
+ if os.path.isdir(conf.scripts_dir):
906
990
  shutil.rmtree(conf.scripts_dir)
907
991
  os.makedirs(conf.scripts_dir, exist_ok=True)
908
992
  # allow for read/execute permissions for all since non-root user needs access with docker
@@ -1030,8 +1114,8 @@ def remove_image(docker_cmd: str, image_name: str) -> None:
1030
1114
  error_msg="image remove")
1031
1115
 
1032
1116
 
1033
- def start_container(docker_full_cmd: list[str], current_user: str, shared_root: str,
1034
- shared_root_dirs: str, conf: StaticConfiguration) -> None:
1117
+ def run_container(docker_full_cmd: list[str], current_user: str, shared_root: str,
1118
+ shared_root_dirs: str, conf: StaticConfiguration) -> None:
1035
1119
  """
1036
1120
  Create and start the final ybox container applying all the provided configuration.
1037
1121
  The following characteristics of the container are noteworthy:
@@ -1054,9 +1138,8 @@ def start_container(docker_full_cmd: list[str], current_user: str, shared_root:
1054
1138
  programs from less secure containers; the `ybox-pkg` tool provided a convenient high-level
1055
1139
  package manager that users should use for managing packages in the containers which will
1056
1140
  help in exposing packages only in designated containers
1057
- * systemd user service file can be generated for podman to start the container automatically
1058
- on user login; docker installations run a background user service in any case which starts
1059
- up the container without any additional setup
1141
+ * systemd user service file can be generated for podman/docker to start the container
1142
+ automatically on user login when -S/--systemd-service option has been provided
1060
1143
 
1061
1144
  :param docker_full_cmd: the `docker`/`podman run -itd` command with all the options filled
1062
1145
  in from the container profile specification as a list of string
@@ -1089,8 +1172,10 @@ def start_container(docker_full_cmd: list[str], current_user: str, shared_root:
1089
1172
  if conf.env.uses_podman:
1090
1173
  docker_full_cmd.append(f"--user={user_uid}")
1091
1174
  docker_full_cmd.append("--userns=keep-id")
1175
+ docker_full_cmd.append(f"-e=USER={current_user}")
1092
1176
  else:
1093
1177
  docker_full_cmd.append("--user=0")
1178
+ docker_full_cmd.append("-e=USER=root")
1094
1179
  docker_full_cmd.append(f"-e=YBOX_HOST_UID={user_uid}")
1095
1180
  docker_full_cmd.append(f"-e=YBOX_HOST_GID={user_gid}")
1096
1181
  docker_full_cmd.append(conf.box_image(bool(shared_root)))
@@ -1108,8 +1193,61 @@ def start_container(docker_full_cmd: list[str], current_user: str, shared_root:
1108
1193
  sys.exit(code)
1109
1194
 
1110
1195
 
1111
- def restart_container(docker_cmd: str, conf: StaticConfiguration) -> None:
1112
- """restart a stopped podman/docker container"""
1196
+ def create_and_start_service(box_name: str, env: Environ, systemctl: str, sys_path: str,
1197
+ wait_msg: str) -> None:
1198
+ """
1199
+ Create, enable and start systemd service for a ybox container.
1200
+
1201
+ :param box_name: name of the ybox container
1202
+ :param env: an instance of the current :class:`Environ`
1203
+ :param systemctl: resolved path to the `systemctl` utility
1204
+ :param sys_path: PATH used for searching system utilities
1205
+ :param wait_msg: message to output before waiting for the service to start
1206
+ """
1207
+ svc_file = env.search_config_path("resources/ybox-systemd.template", only_sys_conf=True)
1208
+ with svc_file.open("r", encoding="utf-8") as svc_fd:
1209
+ svc_tmpl = svc_fd.read()
1210
+ pid_file = ""
1211
+ if env.uses_podman:
1212
+ manager_name = "Podman"
1213
+ docker_requires = ""
1214
+ res = run_command([env.docker_cmd, "container", "inspect", "--format={{.ConmonPidFile}}",
1215
+ box_name], capture_output=True, exit_on_error=False)
1216
+ if isinstance(res, str):
1217
+ pid_file = f"PIDFile={res}"
1218
+ else:
1219
+ manager_name = "Docker"
1220
+ docker_requires = "After=docker.service\nRequires=docker.service\n"
1221
+ systemd_dir = f"{env.home}/.config/systemd/user"
1222
+ ybox_svc = f"ybox-{box_name}.service"
1223
+ ybox_env = f"{systemd_dir}/.ybox-{box_name}.env"
1224
+ formatted_now = env.now.astimezone().strftime("%a %d %b %Y %H:%M:%S %Z")
1225
+ svc_content = svc_tmpl.format(name=box_name, version=product_version, date=formatted_now,
1226
+ manager_name=manager_name, docker_requires=docker_requires,
1227
+ env_file=ybox_env, pid_file=pid_file)
1228
+ env_content = f"""
1229
+ PATH={sys_path}:{env.home}/.local/bin
1230
+ SLEEP_SECS={{sleep_secs}}
1231
+ # set the container manager to the one configured during ybox-create
1232
+ YBOX_CONTAINER_MANAGER={env.docker_cmd}
1233
+ """
1234
+ os.makedirs(systemd_dir, Consts.default_directory_mode(), exist_ok=True)
1235
+ print_color(f"Generating user systemd service '{ybox_svc}' and reloading daemon", fgcolor.cyan)
1236
+ with open(f"{systemd_dir}/{ybox_svc}", "w", encoding="utf-8") as svc_fd:
1237
+ svc_fd.write(svc_content)
1238
+ with open(ybox_env, "w", encoding="utf-8") as env_fd:
1239
+ env_fd.write(dedent(env_content.format(sleep_secs=0))) # don't sleep for the start below
1240
+ run_command([systemctl, "--user", "daemon-reload"], exit_on_error=False)
1241
+ run_command([systemctl, "--user", "enable", ybox_svc], exit_on_error=True)
1242
+ print_info(wait_msg)
1243
+ run_command([systemctl, "--user", "start", ybox_svc], exit_on_error=True)
1244
+ # change SLEEP_SECS to 7 for subsequent starts
1245
+ with open(ybox_env, "w", encoding="utf-8") as env_fd:
1246
+ env_fd.write(dedent(env_content.format(sleep_secs=7)))
1247
+
1248
+
1249
+ def start_container(docker_cmd: str, conf: StaticConfiguration) -> None:
1250
+ """start a stopped podman/docker container"""
1113
1251
  if (code := int(run_command([docker_cmd, "container", "start", conf.box_name],
1114
1252
  exit_on_error=False, error_msg="container restart"))) != 0:
1115
1253
  print_error(f"Also check 'ybox-logs {conf.box_name}' for details")