ybox 0.9.8__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.
Files changed (76) hide show
  1. ybox/__init__.py +2 -0
  2. ybox/cmd.py +307 -0
  3. ybox/conf/completions/ybox.fish +93 -0
  4. ybox/conf/distros/arch/add-gpg-key.sh +29 -0
  5. ybox/conf/distros/arch/distro.ini +192 -0
  6. ybox/conf/distros/arch/init-base.sh +10 -0
  7. ybox/conf/distros/arch/init-user.sh +35 -0
  8. ybox/conf/distros/arch/init.sh +82 -0
  9. ybox/conf/distros/arch/list_fmt_long.py +76 -0
  10. ybox/conf/distros/arch/pkgdeps.py +276 -0
  11. ybox/conf/distros/deb-generic/check-package.sh +77 -0
  12. ybox/conf/distros/deb-generic/distro.ini +190 -0
  13. ybox/conf/distros/deb-generic/fetch-gpg-key-id.sh +30 -0
  14. ybox/conf/distros/deb-generic/init-base.sh +11 -0
  15. ybox/conf/distros/deb-generic/init-user.sh +3 -0
  16. ybox/conf/distros/deb-generic/init.sh +136 -0
  17. ybox/conf/distros/deb-generic/list_fmt_long.py +114 -0
  18. ybox/conf/distros/deb-generic/pkgdeps.py +208 -0
  19. ybox/conf/distros/deb-oldstable/distro.ini +21 -0
  20. ybox/conf/distros/deb-stable/distro.ini +21 -0
  21. ybox/conf/distros/supported.list +5 -0
  22. ybox/conf/distros/ubuntu2204/distro.ini +21 -0
  23. ybox/conf/distros/ubuntu2404/distro.ini +21 -0
  24. ybox/conf/profiles/apps.ini +26 -0
  25. ybox/conf/profiles/basic.ini +310 -0
  26. ybox/conf/profiles/dev.ini +25 -0
  27. ybox/conf/profiles/games.ini +39 -0
  28. ybox/conf/resources/entrypoint-base.sh +170 -0
  29. ybox/conf/resources/entrypoint-common.sh +23 -0
  30. ybox/conf/resources/entrypoint-cp.sh +32 -0
  31. ybox/conf/resources/entrypoint-root.sh +20 -0
  32. ybox/conf/resources/entrypoint-user.sh +21 -0
  33. ybox/conf/resources/entrypoint.sh +249 -0
  34. ybox/conf/resources/prime-run +13 -0
  35. ybox/conf/resources/run-in-dir +60 -0
  36. ybox/conf/resources/run-user-bash-cmd +14 -0
  37. ybox/config.py +255 -0
  38. ybox/env.py +205 -0
  39. ybox/filelock.py +77 -0
  40. ybox/migrate/0.9.0-0.9.7:0.9.8.py +33 -0
  41. ybox/pkg/__init__.py +0 -0
  42. ybox/pkg/clean.py +33 -0
  43. ybox/pkg/info.py +40 -0
  44. ybox/pkg/inst.py +638 -0
  45. ybox/pkg/list.py +191 -0
  46. ybox/pkg/mark.py +68 -0
  47. ybox/pkg/repair.py +150 -0
  48. ybox/pkg/repo.py +251 -0
  49. ybox/pkg/search.py +52 -0
  50. ybox/pkg/uninst.py +92 -0
  51. ybox/pkg/update.py +56 -0
  52. ybox/print.py +121 -0
  53. ybox/run/__init__.py +0 -0
  54. ybox/run/cmd.py +54 -0
  55. ybox/run/control.py +102 -0
  56. ybox/run/create.py +1116 -0
  57. ybox/run/destroy.py +64 -0
  58. ybox/run/graphics.py +367 -0
  59. ybox/run/logs.py +57 -0
  60. ybox/run/ls.py +64 -0
  61. ybox/run/pkg.py +445 -0
  62. ybox/schema/0.9.1-added.sql +27 -0
  63. ybox/schema/0.9.6-added.sql +18 -0
  64. ybox/schema/init.sql +39 -0
  65. ybox/schema/migrate/0.9.0:0.9.1.sql +42 -0
  66. ybox/schema/migrate/0.9.1:0.9.2.sql +8 -0
  67. ybox/schema/migrate/0.9.2:0.9.3.sql +2 -0
  68. ybox/schema/migrate/0.9.5:0.9.6.sql +2 -0
  69. ybox/state.py +914 -0
  70. ybox/util.py +351 -0
  71. ybox-0.9.8.dist-info/LICENSE +19 -0
  72. ybox-0.9.8.dist-info/METADATA +533 -0
  73. ybox-0.9.8.dist-info/RECORD +76 -0
  74. ybox-0.9.8.dist-info/WHEEL +5 -0
  75. ybox-0.9.8.dist-info/entry_points.txt +8 -0
  76. ybox-0.9.8.dist-info/top_level.txt +1 -0
ybox/run/create.py ADDED
@@ -0,0 +1,1116 @@
1
+ """
2
+ Code for the `ybox-create` script that is used to create and configure a new ybox container.
3
+ """
4
+
5
+ import argparse
6
+ import getpass
7
+ import grp
8
+ import os
9
+ import pwd
10
+ import re
11
+ import shutil
12
+ import stat
13
+ import subprocess
14
+ import sys
15
+ from collections import defaultdict
16
+ from configparser import ConfigParser, SectionProxy
17
+ from pathlib import Path
18
+ from textwrap import dedent
19
+ from typing import Optional
20
+
21
+ from ybox.cmd import PkgMgr, RepoCmd, YboxLabel, check_ybox_exists, run_command
22
+ from ybox.config import Consts, StaticConfiguration
23
+ from ybox.env import Environ, NotSupportedError, PathName
24
+ from ybox.filelock import FileLock
25
+ from ybox.pkg.inst import install_package, wrap_container_files
26
+ from ybox.print import (bgcolor, fgcolor, print_color, print_error, print_info,
27
+ print_warn)
28
+ from ybox.run.graphics import (add_env_option, add_mount_option, enable_dri,
29
+ enable_nvidia, enable_wayland, enable_x11)
30
+ from ybox.run.pkg import parse_args as pkg_parse_args
31
+ from ybox.state import RuntimeConfiguration, YboxStateManagement
32
+ from ybox.util import (EnvInterpolation, config_reader,
33
+ copy_ybox_scripts_to_container, ini_file_reader,
34
+ select_item_from_menu, truncate_file,
35
+ wait_for_ybox_container, write_ybox_version)
36
+
37
+ _EXTRACT_PARENS_NAME = re.compile(r"^.*\(([^)]+)\)$")
38
+ _DEP_SUFFIX = re.compile(r"^(.*):dep\((.*)\)$")
39
+ _WS_RE = re.compile(r"\s+")
40
+
41
+
42
+ # Note: deliberately not using os.path.join for joining paths since the code only works on
43
+ # Linux/POSIX systems where path separator will always be "/" and explicitly forcing the same.
44
+ #
45
+ # Configuration files should be in $HOME/.config/ybox or ybox package installation directory.
46
+
47
+ def main() -> None:
48
+ """main function for `ybox-create` script"""
49
+ main_argv(sys.argv[1:])
50
+
51
+
52
+ def main_argv(argv: list[str]) -> None:
53
+ """
54
+ Main entrypoint of `ybox-create` that takes a list of arguments which are usually the
55
+ command-line arguments of the `main()` function. Pass ["-h"]/["--help"] to see all the
56
+ available arguments with help message for each.
57
+
58
+ :param argv: arguments to the function (main function passes `sys.argv[1:]`)
59
+ """
60
+ args = parse_args(argv)
61
+ env = Environ()
62
+ docker_cmd = env.docker_cmd
63
+
64
+ # use provided distribution else let user select from available ones
65
+ distro = select_distribution(args, env)
66
+ # the profile used to build the podman/docker command-line which is either provided
67
+ # on command-line or else let user select from available ones in standard locations
68
+ profile = select_profile(args, env)
69
+
70
+ box_name = process_args(args, distro, profile)
71
+ print_color(f"Creating ybox container named '{box_name}' for distribution '{distro}' "
72
+ f"using profile '{profile}'", fg=fgcolor.green)
73
+ if check_ybox_exists(docker_cmd, box_name):
74
+ print_error(f"ybox container '{box_name}' already exists.")
75
+ sys.exit(1)
76
+
77
+ conf = StaticConfiguration(env, distro, box_name)
78
+ # read the distribution specific configuration
79
+ base_image_name, shared_root_dirs, secondary_groups, distro_config = read_distribution_config(
80
+ args, conf)
81
+ # setup entrypoint and related scripts to share with the container on a mount point
82
+ setup_ybox_scripts(conf, distro_config)
83
+
84
+ docker_full_args = [docker_cmd, "run", "-itd", f"--name={box_name}"]
85
+ # process the profile before any actions to ensure it is in proper shape
86
+ pkgmgr = distro_config["pkgmgr"]
87
+ shared_root, box_conf, apps_with_deps = process_sections(profile, conf, pkgmgr,
88
+ docker_full_args)
89
+ process_distribution_config(distro_config, docker_full_args)
90
+ current_user = getpass.getuser()
91
+
92
+ # The sequence for container creation and run is thus:
93
+ # 1) First start a basic container with the smallest upstream distro image (important to save
94
+ # space when `base.shared_root` is provided) with "entrypoint-base.sh" as the entrypoint
95
+ # script giving user/group arguments to be same as the user as on the host machine.
96
+ # 2) Next do a podman/docker commit and save the stopped container as local image which
97
+ # will be used henceforth. The main point of doing #1 is to ensure that a sudo enabled
98
+ # user is available which matches the current host user so that "--userns" option
99
+ # will not try to remap the image that can substantially increase the size of image.
100
+ # Either way, the user created by "--userns" in the container does not have sudo
101
+ # permissions, so temporarily need to run such a container as root user in any case.
102
+ # Hence, step 1 uses a cleaner and better option that also creates separate
103
+ # container-specific images that can be enhanced with more container-specific stuff
104
+ # later if required. Also change the USER and WORKDIR of the commit to point to the
105
+ # user added in step 1).
106
+ # 3) Start up the saved image of step 2 and run the distro specific entrypoint script
107
+ # that will do basic configuration and package installation (e.g. git/neovim/... on arch)
108
+ # followed by the generic "entrypoint.sh" script which will create the configuration
109
+ # file links (from [configs] section), install required apps (from [apps] section),
110
+ # followed by invoking the startup scripts from the [startup] section.
111
+ # 4) The container is now ready to use so 'ybox-cmd' will only do a podman/docker exec
112
+ # of /bin/bash to open a shell (or any given command).
113
+ # 5) Mounts and environment variables are set up for step 3 which are automatically also
114
+ # available in step 4, and hence no special setup is required in step 4.
115
+ #
116
+ # If `base.shared_root` is provided, the above sequence has the following changes:
117
+ # 1) First acquire a file/process lock so that no other container creation can interfere.
118
+ # 2) Once the lock has been acquired, check if the shared container image already exists.
119
+ # If it does exist, then skip to step 7.
120
+ # 3) If not, then start the basic container like in step 1) of previous sequence.
121
+ # 4) Like step 2) of the previous sequence, commit the container but with a temporary name.
122
+ # 5) Unlike step 3) of the previous sequence, do a temporary restart of the previous
123
+ # committed image with "--userns=keep-id" option (podman), and then copy the shared root
124
+ # directories to the shared mount point. This copying cannot be done in step 4) above
125
+ # because the file permissions are different with and without the --userns option.
126
+ # For docker that does not support the "--userns=keep-id" option, the container image
127
+ # needs to be run as root user that maps to host's user.
128
+ # 6) Stop the container and commit the image again with the final shared image name.
129
+ # Delete the previous temporary image. Release the file lock acquired in step 1).
130
+ # 7) Now start the shared image like in step 3) of the previous sequence but with the
131
+ # additional root directory mounts that were copied in step 5 above.
132
+ # Finally, continue with step 4) onwards of previous sequence.
133
+
134
+ # handle the shared_root case: acquire file lock and check if shared container image exists
135
+ if shared_root:
136
+ os.makedirs(os.path.dirname(shared_root),
137
+ mode=Consts.default_directory_mode(), exist_ok=True)
138
+ with FileLock(f"{shared_root}-image.lock"):
139
+ # if image already exists, then skip the subsequent steps
140
+ if subprocess.run([docker_cmd, "inspect", "--type=image",
141
+ "--format={{.Id}}", conf.box_image(True)], check=False,
142
+ stdout=subprocess.DEVNULL,
143
+ stderr=subprocess.DEVNULL).returncode != 0:
144
+ # run the "base" container with appropriate arguments for the current user to
145
+ # the 'entrypoint-base.sh' script to create the user and group in the container
146
+ run_base_container(base_image_name, current_user, secondary_groups, docker_cmd,
147
+ conf)
148
+ # commit the stopped container with a temporary name, then remove the container;
149
+ # keeping a separate tmp_image helps reduce size of final image a bit because
150
+ # this one is without --userns while the final shared image is with --userns
151
+ tmp_image = f"{conf.box_image(False)}__ybox_tmp"
152
+ commit_container(docker_cmd, tmp_image, conf)
153
+ # start a container using the temporary image with "--userns" option to make
154
+ # a copy of the container root directories to the shared location
155
+ run_shared_copy_container(docker_cmd, tmp_image, shared_root, shared_root_dirs,
156
+ conf, args.quiet)
157
+ # finally commit this container with the name of the shared image
158
+ commit_container(docker_cmd, conf.box_image(True), conf)
159
+ remove_image(docker_cmd, tmp_image)
160
+ # in case a shared root directory is not present but shared image is present,
161
+ # need to run the container to copy to shared root
162
+ elif any((not os.path.exists(f"{shared_root}{s_dir}") for s_dir in
163
+ shared_root_dirs.split(","))):
164
+ run_shared_copy_container(docker_cmd, conf.box_image(True), shared_root,
165
+ shared_root_dirs, conf, args.quiet)
166
+ remove_container(docker_cmd, conf)
167
+ else:
168
+ # for no shared_root case, its best to refresh the local image
169
+ run_command([docker_cmd, "pull", base_image_name],
170
+ error_msg="fetching container base image")
171
+ # run the "base" container with appropriate arguments for the current user to the
172
+ # 'entrypoint-base.sh' script to create the user and group in the container
173
+ run_base_container(base_image_name, current_user, secondary_groups, docker_cmd, conf)
174
+ # commit the stopped container, remove it, then start new container with the
175
+ # "--userns=keep-id" option (podman) for the required container state
176
+ commit_container(docker_cmd, conf.box_image(False), conf)
177
+
178
+ # there is one additional stop/start below because all packages are upgraded by the
179
+ # entrypoint script to pick up any important fixes which can lead to system libraries
180
+ # getting upgraded, so it's best to restart container for those to take effect properly
181
+
182
+ # set up the final container with all the required arguments
183
+ print_info(f"Initializing container for '{distro}' using '{profile}'")
184
+ start_container(docker_full_args, current_user, shared_root, shared_root_dirs, conf)
185
+ print_info("Waiting for the container to initialize (see "
186
+ f"'ybox-logs -f {box_name}' for detailed progress)")
187
+ # wait for container to initialize while printing out its progress from conf.status_file
188
+ wait_for_ybox_container(docker_cmd, conf)
189
+
190
+ # remove distribution specific scripts and restart container the final time
191
+ print_info(f"Restarting the final container '{box_name}'")
192
+ 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)
197
+ # truncate the app.list and config.list files so that those actions are skipped if the
198
+ # container is restarted later
199
+ if os.access(conf.app_list, os.W_OK):
200
+ truncate_file(conf.app_list)
201
+ if os.access(conf.config_list, os.W_OK):
202
+ truncate_file(conf.config_list)
203
+
204
+ # finally add the state and register the installed packages that were reassigned to this
205
+ # container (because the previously destroyed one has the same configuration and shared root)
206
+ with YboxStateManagement(env) as state:
207
+ owned_packages = state.register_container(box_name, distro, shared_root, box_conf,
208
+ args.force_own_orphans)
209
+ # create wrappers for owned_packages
210
+ if owned_packages:
211
+ list_cmd = pkgmgr[PkgMgr.LIST_FILES.value]
212
+ for package, (copy_type, app_flags) in owned_packages.items():
213
+ # skip packages already scheduled to be installed
214
+ if package in apps_with_deps:
215
+ continue
216
+ # skip all questions for -q/--quiet (equivalent to -qq to `ybox-pkg install`)
217
+ quiet = 2 if args.quiet else 0
218
+ # box_conf can be skipped in new state.db but not for pre 0.9.3 having empty flags
219
+ if local_copies := wrap_container_files(package, copy_type, app_flags, list_cmd,
220
+ docker_cmd, conf, box_conf, shared_root,
221
+ quiet):
222
+ # register the package again with the local_copies (no change to package_deps)
223
+ state.register_package(box_name, package, local_copies, copy_type, app_flags,
224
+ shared_root, dep_type=None, dep_of="")
225
+ if apps_with_deps:
226
+ runtime_conf = RuntimeConfiguration(box_name, distro, shared_root, box_conf)
227
+ for app, deps in apps_with_deps.items():
228
+ pkg_args = ["install", "-z", box_name, "-o", "-c"]
229
+ if args.quiet:
230
+ pkg_args.append("-qq")
231
+ if deps:
232
+ pkg_args.append("-w")
233
+ pkg_args.append(",".join(deps))
234
+ pkg_args.append(app)
235
+ parsed_args = pkg_parse_args(pkg_args)
236
+ install_package(parsed_args, pkgmgr, docker_cmd, conf, runtime_conf, state)
237
+
238
+
239
+ def parse_args(argv: list[str]) -> argparse.Namespace:
240
+ """
241
+ Parse command-line arguments for the program and return the result :class:`argparse.Namespace`.
242
+
243
+ :param argv: the list of arguments to be parsed
244
+ :return: the result of parsing using the `argparse` library as a :class:`argparse.Namespace`
245
+ """
246
+ parser = argparse.ArgumentParser(
247
+ description="""Create a new ybox container for given Linux distribution and configured
248
+ with given file in INI format. It allows for set up of various aspects of
249
+ the ybox including support for X11, Wayland, audio, video acceleration,
250
+ NVIDIA, dbus among others. It also allows controlling various parameters
251
+ of the container including directories to be shared, logging etc.
252
+ See src/ybox/conf/profiles/basic.ini in the distribution for all available
253
+ options with examples and comments having the explanations.""")
254
+ parser.add_argument("-n", "--name", type=str,
255
+ help="name of the ybox; default is ybox-<distribution>_<profile> "
256
+ "if not provided (removing the .ini suffix from <profile> file)")
257
+ parser.add_argument("-F", "--force-own-orphans", action="store_true",
258
+ help="force ownership of orphan packages on the same shared root even "
259
+ "if container configuration does not match, meaning the packages "
260
+ "on the shared root directory that got orphaned due to their "
261
+ "owner container being destroyed will be assigned to this new "
262
+ "container regardless of the container configuration")
263
+ parser.add_argument("-C", "--distribution-config", type=str,
264
+ help="path to distribution configuration file to use instead of the "
265
+ "`distro.ini` from user/system configuration paths")
266
+ parser.add_argument("-q", "--quiet", action="store_true",
267
+ help="proceed without asking any questions using defaults where possible; "
268
+ "this should usually be used with explicit specification of "
269
+ "distribution and profile arguments else the operation will fail if "
270
+ "there more than one of them available")
271
+ parser.add_argument("distribution", nargs="?", type=str,
272
+ help="short name of the distribution as listed in distros/supported.list "
273
+ "(either in ~/.config/ybox or package's ybox/conf); it is optional "
274
+ "and user is presented with selection menu if there are multiple "
275
+ "listed in the first supported.list file that is found")
276
+ parser.add_argument("profile", nargs="?", type=str,
277
+ help="the profile defined in INI file to use for creating the ybox "
278
+ "(can be a relative or absolute path, or be in user or system "
279
+ "configuration directory which are $HOME/.config/ybox/profiles and "
280
+ "package's ybox/conf/profiles directory respectively); it is "
281
+ "optional and user is presented with a selection menu of the "
282
+ "available profiles in the user or system profiles directory "
283
+ "whichever is found (in that order)")
284
+ return parser.parse_args(argv)
285
+
286
+
287
+ def quick_config_read(file: PathName) -> ConfigParser:
288
+ """
289
+ Quickly read an INI file without processing `includes` or applying any value interpolation.
290
+
291
+ :param file: a `Path` or resource file from importlib (`Traversable`) for the configuration
292
+ :return: an object of :class:`ConfigParser` from parsing the configuration file
293
+ """
294
+ with file.open("r", encoding="utf-8") as profile_fd:
295
+ return ini_file_reader(profile_fd, None)
296
+
297
+
298
+ def select_distribution(args: argparse.Namespace, env: Environ) -> str:
299
+ """
300
+ Interactively select a Linux distribution from a menu among the ones supported by this
301
+ installation of ybox, or if there is only one supported distribution, then return its name.
302
+ User can also provide one explicitly on the command-line which will be returned if valid.
303
+
304
+ :param args: the parsed arguments passed to the invoking `ybox-create` script
305
+ :param env: an instance of the current :class:`Environ`
306
+ :raises ValueError: unexpected internal error in the name of the distribution
307
+ :return: name of the selected or provided distribution
308
+ """
309
+ support_list = env.search_config_path("distros/supported.list", only_sys_conf=True)
310
+ with support_list.open("r", encoding="utf-8") as supp_file:
311
+ supported_distros = supp_file.read().splitlines()
312
+ if distro := args.distribution:
313
+ # check that the distribution is in supported.list
314
+ if distro in supported_distros:
315
+ return str(distro)
316
+ print_error(f"Distribution '{distro}' not supported in {support_list}")
317
+ sys.exit(1)
318
+ if len(supported_distros) == 1:
319
+ print_info(f"Using distribution '{supported_distros[0]}'")
320
+ return supported_distros[0]
321
+ if args.quiet:
322
+ print_error(
323
+ f"Expected one supported distribution but found: {', '.join(supported_distros)}")
324
+ sys.exit(1)
325
+
326
+ # show a menu to choose from if the number of supported distributions exceeds 1
327
+ distro_names: list[str] = []
328
+ for distro in supported_distros:
329
+ distro_config = quick_config_read(env.search_config_path(
330
+ StaticConfiguration.distribution_config(distro), only_sys_conf=True))
331
+ distro_names.append(f"{distro_config['base']['name']} ({distro})") # should always exist
332
+ print_info("Please select the distribution to use for the container:", file=sys.stderr)
333
+ if (distro_name := select_item_from_menu(distro_names)) is None:
334
+ sys.exit(1)
335
+ if match := _EXTRACT_PARENS_NAME.match(distro_name):
336
+ return match.group(1)
337
+ raise ValueError(f"Unexpected distribution name string: {distro_name}")
338
+
339
+
340
+ def select_profile(args: argparse.Namespace, env: Environ) -> PathName:
341
+ """
342
+ Interactively select a profile for ybox container to use for its setup from a menu among the
343
+ ones provided by this installation of ybox, or those setup in user's configuration.
344
+ If there is only one available profile, then return its name. User can also provide one
345
+ explicitly on the command-line which will be returned if valid.
346
+
347
+ :param args: the parsed arguments passed to the invoking `ybox-create` script
348
+ :param env: an instance of the current :class:`Environ`
349
+ :raises ValueError: unexpected internal error in the name of the profile
350
+ :return: name of the selected or provided profile
351
+ """
352
+ # the profile used to build the podman/docker command-line
353
+ if profile_arg := args.profile:
354
+ if os.access(profile_arg, os.R_OK):
355
+ return Path(profile_arg)
356
+ return env.search_config_path(f"profiles/{profile_arg}")
357
+
358
+ # search for available profiles in standard locations and provide a selection menu
359
+ # for the user
360
+ profile_names: list[str] = []
361
+ profiles_dir = env.search_config_path("profiles")
362
+ profiles = [file for file in profiles_dir.iterdir() if file.is_file()]
363
+ if len(profiles) == 1:
364
+ print_info(f"Using profile '{profiles[0]}'")
365
+ return profiles[0]
366
+ if len(profiles) == 0:
367
+ print_error(f"No valid profile found in '{profiles_dir}'")
368
+ sys.exit(1)
369
+
370
+ profiles.sort(key=str)
371
+ if args.quiet:
372
+ print_error(
373
+ f"Expected one configured profile but found: {', '.join([p.name for p in profiles])}")
374
+ sys.exit(1)
375
+
376
+ for profile in profiles:
377
+ profile_config = quick_config_read(profile)
378
+ profile_names.append(f"{profile_config['base']['name']} ({profile.name})")
379
+ print_info("Please select the profile to use for the container:", file=sys.stderr)
380
+ if (profile_name := select_item_from_menu(profile_names)) is None:
381
+ sys.exit(1)
382
+ if match := _EXTRACT_PARENS_NAME.match(profile_name):
383
+ return profiles_dir.joinpath(match.group(1))
384
+ raise ValueError(f"Unexpected profile name string: {profile_name}")
385
+
386
+
387
+ def process_args(args: argparse.Namespace, distro: str, profile: PathName) -> str:
388
+ """
389
+ Initial processing of the provided command-line arguments to form the desired name of the
390
+ ybox container.
391
+
392
+ :param args: the parsed arguments passed to the invoking `ybox-create` script
393
+ :param distro: the Linux distribution name returned by :func:`select_distribution` to use for
394
+ the ybox container
395
+ :param profile: the profile file returned by :func:`select_profile` to use for ybox container
396
+ configuration as a `Path` or resource file from importlib (`Traversable`)
397
+ :return: the ybox container name to use
398
+ """
399
+ ini_suffix = ".ini"
400
+ if args.name:
401
+ box_name = args.name
402
+ else:
403
+ def_name = profile.name
404
+ if def_name.endswith(ini_suffix):
405
+ def_name = def_name[:-len(ini_suffix)]
406
+ def_name = f"ybox-{distro}_{def_name}"
407
+ box_name = def_name if args.quiet else input(
408
+ f"Name of the container to create (default: {def_name}): ").strip()
409
+ if not box_name:
410
+ box_name = def_name
411
+
412
+ # don't allow spaces or weird characters in the name
413
+ if not re.fullmatch(r"[\w.\-]+", box_name):
414
+ print_error(f"Invalid container name '{box_name}' -- only alphanumeric, underscore and "
415
+ "hyphen characters are accepted")
416
+ sys.exit(1)
417
+ return box_name
418
+
419
+
420
+ def process_sections(profile: PathName, conf: StaticConfiguration, pkgmgr: SectionProxy,
421
+ docker_args: list[str]) -> tuple[str, ConfigParser, dict[str, list[str]]]:
422
+ """
423
+ Process all the sections in the given profile file to return a tuple having:
424
+ * shared root to use for the container (if any)
425
+ * :class:`ConfigParser` object from parsing the ini format profile, and
426
+ * dictionary having the packages to be installed as specified in the `[apps]` section
427
+ of the profile mapped to list of dependent packages for each application.
428
+
429
+ :param profile: the profile file returned by :func:`select_profile` to use for ybox container
430
+ configuration as a `Path` or resource file from importlib (`Traversable`)
431
+ :param conf: the :class:`StaticConfiguration` for the container
432
+ :param pkgmgr: the `[pkgmgr]` section from `distro.ini` configuration file of the distribution
433
+ :param docker_args: list of arguments to be provided to podman/docker command for creating the
434
+ final ybox container which is populated with required options as per
435
+ the configuration in the given profile
436
+ :raises NotSupportedError: if there is an unknown section or key in the ini format profile
437
+ :return: tuple of container's shared root, :class:`ConfigParser` object from parsing the
438
+ profile, and dictionary of apps with dependencies to be installed in the container
439
+ from the `[apps]` section of the profile
440
+ """
441
+ # Read the config file, recursively reading the includes if present,
442
+ # then replace the environment variables and the special ${NOW:...} from all values.
443
+ # Skip environment variable substitution for the "configs" section since the values
444
+ # there have to be written as is to 'config.list' file for the container (since the
445
+ # $HOME variable can be different inside the container).
446
+ env_interpolation = EnvInterpolation(conf.env, ["configs"])
447
+ config = config_reader(profile, env_interpolation)
448
+ # [base] section should always be present
449
+ if not config.has_section("base"):
450
+ raise NotSupportedError(f"Missing [base] section in profile '{profile}'")
451
+ shared_root, config_hardlinks = process_base_section(config["base"], profile, conf,
452
+ docker_args)
453
+ apps_with_deps: dict[str, list[str]] = {}
454
+ # finally process all the sections and the keys forming the podman/docker command-line
455
+ for section in config.sections():
456
+ if section == "security":
457
+ process_security_section(config["security"], profile, docker_args)
458
+ elif section == "mounts":
459
+ process_mounts_section(config["mounts"], docker_args)
460
+ elif section == "env":
461
+ process_env_section(config["env"], docker_args)
462
+ elif section == "configs":
463
+ if config_hardlinks is not None:
464
+ process_configs_section(config["configs"], config_hardlinks, conf, docker_args)
465
+ elif section == "apps":
466
+ apps_with_deps = process_apps_section(config["apps"], conf, pkgmgr)
467
+ elif section not in ("base", "app_flags", "startup"):
468
+ raise NotSupportedError(f"Unknown section [{section}] in '{profile}' "
469
+ "or one of its includes")
470
+ return shared_root, config, apps_with_deps
471
+
472
+
473
+ def read_distribution_config(args: argparse.Namespace,
474
+ conf: StaticConfiguration) -> tuple[str, str, str, ConfigParser]:
475
+ """
476
+ Read and parse the Linux distribution's `distro.ini` file and return a tuple having:
477
+ * the container image name
478
+ * comma-separate list of directories shared if `shared_root` is provided for the container
479
+ * secondary groups of the user, and
480
+ * the result of parsing the `distro.ini` as an object of :class:`ConfigParser`.
481
+
482
+ :param args: the parsed arguments passed to the invoking `ybox-create` script
483
+ :param conf: the :class:`StaticConfiguration` for the container
484
+ :return: a tuple of image name, shared root directories, secondary groups and an object of
485
+ :class:`ConfigParser` for the `distro.ini`
486
+ """
487
+ env = conf.env
488
+ env_interpolation = EnvInterpolation(env, [])
489
+ distro_conf_file = args.distribution_config or conf.distribution_config(conf.distribution)
490
+ distro_config = config_reader(env.search_config_path(
491
+ distro_conf_file, only_sys_conf=True), env_interpolation)
492
+ distro_base_section = distro_config["base"]
493
+ image_name = distro_base_section["image"] # should always exist
494
+ shared_root_dirs = distro_base_section["shared_root_dirs"] # should always exist
495
+ secondary_groups = distro_base_section["secondary_groups"] # should always exist
496
+ return image_name, shared_root_dirs, secondary_groups, distro_config
497
+
498
+
499
+ def process_distribution_config(distro_config: ConfigParser, docker_args: list[str]) -> None:
500
+ """
501
+ Process the Linux distribution's `distro.ini` file and populate relevant podman/docker options
502
+ in the given `docker_args` list.
503
+
504
+ :param distro_config: an object of :class:`ConfigParser` from parsing the Linux
505
+ distribution's `distro.ini`
506
+ :param docker_args: list of arguments to be provided to podman/docker command for creating the
507
+ final ybox container which is populated with required options
508
+ """
509
+ if distro_config.getboolean("base", "configure_fastest_mirrors", fallback=False):
510
+ add_env_option(docker_args, "CONFIGURE_FASTEST_MIRRORS", "1")
511
+ if distro_config.has_section("packages"):
512
+ packages_section = distro_config["packages"]
513
+ for key, env_var in (("required", "REQUIRED_PKGS"), ("recommended", "RECOMMENDED_PKGS"),
514
+ ("suggested", "SUGGESTED_PKGS"), ("required_deps", "REQUIRED_DEPS"),
515
+ ("recommended_deps", "RECOMMENDED_DEPS"),
516
+ ("suggested_deps", "SUGGESTED_DEPS"), ("extra", "EXTRA_PKGS")):
517
+ if value := packages_section.get(key):
518
+ add_env_option(docker_args, env_var, _WS_RE.sub(" ", value))
519
+ key_server = distro_config.get("repo", RepoCmd.DEFAULT_GPG_KEY_SERVER.value,
520
+ fallback=Consts.default_key_server())
521
+ add_env_option(docker_args, "DEFAULT_GPG_KEY_SERVER", key_server)
522
+
523
+
524
+ def process_base_section(base_section: SectionProxy, profile: PathName, conf: StaticConfiguration,
525
+ docker_args: list[str]) -> tuple[str, Optional[bool]]:
526
+ """
527
+ Process the `[base]` section in the container profile to append required podman/docker
528
+ options in the list that has been passed, and return a tuple having the shared root to use for
529
+ the container (if any), and the value of `config_hardlinks` key in the section.
530
+
531
+ :param base_section: an object of :class:`SectionProxy` from parsing the `[base]` section
532
+ :param profile: the profile file returned by :func:`select_profile` to use for ybox container
533
+ configuration as a `Path` or resource file from importlib (`Traversable`)
534
+ :param conf: the :class:`StaticConfiguration` for the container
535
+ :param docker_args: list of podman/docker arguments to which required options as per the
536
+ configuration in the `[base]` section are appended
537
+ :raises NotSupportedError: if there is an unknown key in the `[base]` section
538
+ :return: tuple of container's shared root and the value of `config_hardlinks` key
539
+ """
540
+ env = conf.env
541
+ # shared root is disabled by default
542
+ shared_root = ""
543
+ # hard links are false by default (value of None means skip the [configs] section entirely)
544
+ config_hardlinks: Optional[bool] = False
545
+ # configure locale by default
546
+ config_locale = True
547
+ # DRI will be force enabled if NVIDIA support is enabled
548
+ dri = False
549
+ # NVIDIA is disabled by default
550
+ nvidia = False
551
+ nvidia_ctk = False
552
+ for key, val in base_section.items():
553
+ if key == "home":
554
+ if val:
555
+ # create the source directory if it does not exist
556
+ os.makedirs(val, mode=Consts.default_directory_mode(), exist_ok=True)
557
+ add_mount_option(docker_args, val, env.target_home)
558
+ elif key == "shared_root":
559
+ shared_root = val or ""
560
+ elif key == "config_hardlinks":
561
+ if val:
562
+ config_hardlinks = _get_boolean(val)
563
+ else:
564
+ config_hardlinks = None
565
+ elif key == "config_locale":
566
+ config_locale = _get_boolean(val)
567
+ elif key == "x11":
568
+ if _get_boolean(val):
569
+ enable_x11(docker_args, env)
570
+ elif key == "wayland":
571
+ if _get_boolean(val):
572
+ enable_wayland(docker_args, env)
573
+ elif key == "pulseaudio":
574
+ if _get_boolean(val):
575
+ enable_pulse(docker_args, env)
576
+ elif key == "dbus":
577
+ if _get_boolean(val):
578
+ enable_dbus(docker_args, base_section.getboolean("dbus_sys", fallback=False), env)
579
+ elif key == "dri":
580
+ dri = _get_boolean(val)
581
+ elif key == "nvidia":
582
+ nvidia = _get_boolean(val)
583
+ elif key == "nvidia_ctk":
584
+ nvidia_ctk = _get_boolean(val)
585
+ elif key == "shm_size":
586
+ if val:
587
+ docker_args.append(f"--shm-size={val}")
588
+ elif key == "pids_limit":
589
+ if val:
590
+ docker_args.append(f"--pids-limit={val}")
591
+ elif key == "log_driver":
592
+ if val:
593
+ docker_args.append(f"--log-driver={val}")
594
+ elif key == "log_opts":
595
+ add_multi_opt(docker_args, "log-opt", val)
596
+ # create the log directory if required
597
+ log_dirs = [mt.group(1) for mt in
598
+ (re.match("^--log-opt=path=(.*)/.*$", path) for path in docker_args) if mt]
599
+ for log_dir in log_dirs:
600
+ os.makedirs(log_dir, mode=Consts.default_directory_mode(), exist_ok=True)
601
+ elif key not in ("name", "dbus_sys", "includes"):
602
+ raise NotSupportedError(f"Unknown key '{key}' in the [base] of {profile} "
603
+ "or its includes")
604
+ if config_locale:
605
+ for lang_var in ("LANG", "LANGUAGE"):
606
+ add_env_option(docker_args, lang_var)
607
+ if dri or nvidia or nvidia_ctk:
608
+ enable_dri(docker_args)
609
+ if nvidia_ctk: # takes precedence over "nvidia" option
610
+ docker_args.append("--device=nvidia.com/gpu=all")
611
+ elif nvidia:
612
+ enable_nvidia(docker_args, conf)
613
+ return shared_root, config_hardlinks
614
+
615
+
616
+ def _get_boolean(value: str) -> bool:
617
+ """
618
+ Convert a string to boolean else raise a `ValueError` if the value is not a boolean.
619
+ Recognizes the following values and is case-insensitive: 0/1, false/true, no/yes, off/on.
620
+ """
621
+ if (result := ConfigParser.BOOLEAN_STATES.get(value.lower())) is not None:
622
+ return result
623
+ raise ValueError(f"Not a boolean: {value}")
624
+
625
+
626
+ def enable_pulse(docker_args: list[str], env: Environ) -> None:
627
+ """
628
+ Append options to podman/docker arguments to share host machine's pulse/pipewire audio server
629
+ with the new ybox container.
630
+
631
+ :param docker_args: list of podman/docker arguments to which the options have to be appended
632
+ :param env: an instance of the current :class:`Environ`
633
+ """
634
+ cookie = f"{env.home}/.config/pulse/cookie"
635
+ if os.access(cookie, os.R_OK):
636
+ add_mount_option(docker_args, cookie, f"{env.target_home}/.config/pulse/cookie", "ro")
637
+ if env.xdg_rt_dir:
638
+ pulse_native = f"{env.xdg_rt_dir}/pulse/native"
639
+ if os.access(pulse_native, os.W_OK):
640
+ add_mount_option(docker_args, pulse_native, f"{env.target_xdg_rt_dir}/pulse/native")
641
+ for pwf in [f for f in os.listdir(env.xdg_rt_dir) if re.match("pipewire-[0-9]+$", f)]:
642
+ pipewire_path = f"{env.xdg_rt_dir}/{pwf}"
643
+ if os.access(pipewire_path, os.W_OK):
644
+ add_mount_option(docker_args, pipewire_path, f"{env.target_xdg_rt_dir}/{pwf}")
645
+
646
+
647
+ def enable_dbus(docker_args: list[str], sys_enable: bool, env: Environ) -> None:
648
+ """
649
+ Append options to podman/docker arguments to share host machine's dbus message bus
650
+ with the new ybox container.
651
+
652
+ :param docker_args: list of podman/docker arguments to which the options have to be appended
653
+ :param sys_enable: if True then also share host machine's system dbus message bus in addition
654
+ to the user dbus message bus
655
+ :param env: an instance of the current :class:`Environ`
656
+ """
657
+ def replace_target_dir(src: str) -> str:
658
+ return src.replace(f"{env.xdg_rt_dir}/", f"{env.target_xdg_rt_dir}/")
659
+ if dbus_session := os.environ.get("DBUS_SESSION_BUS_ADDRESS"):
660
+ dbus_user = dbus_session[dbus_session.find("=") + 1:]
661
+ if (dbus_opts_idx := dbus_user.find(",")) != -1:
662
+ dbus_user = dbus_user[:dbus_opts_idx]
663
+ add_mount_option(docker_args, dbus_user, replace_target_dir(dbus_user))
664
+ add_env_option(docker_args, "DBUS_SESSION_BUS_ADDRESS", replace_target_dir(dbus_session))
665
+ if sys_enable:
666
+ dbus_sys = "/run/dbus/system_bus_socket"
667
+ dbus_sys2 = "/var/run/dbus/system_bus_socket"
668
+ if os.access(dbus_sys, os.W_OK):
669
+ add_mount_option(docker_args, dbus_sys, dbus_sys)
670
+ elif os.access(dbus_sys2, os.W_OK):
671
+ add_mount_option(docker_args, dbus_sys2, dbus_sys)
672
+
673
+
674
+ def add_multi_opt(docker_args: list[str], opt: str, val: Optional[str]) -> None:
675
+ """
676
+ Append a comma-separated value in the profile as multiple options to podman/docker arguments.
677
+
678
+ :param docker_args: list of podman/docker arguments to which the options have to be appended
679
+ :param val: the comma-separated value
680
+ :param opt: the option name which is added as `--{opt}=...` to podman/docker arguments
681
+ """
682
+ if val:
683
+ for opt_val in val.split(","):
684
+ docker_args.append(f"--{opt}={opt_val}")
685
+
686
+
687
+ def process_security_section(sec_section: SectionProxy, profile: PathName,
688
+ docker_args: list[str]) -> None:
689
+ """
690
+ Process the `[security]` section in the container profile to append required podman/docker
691
+ options in the list that has been passed.
692
+
693
+ :param sec_section: an object of :class:`SectionProxy` from parsing the `[security]` section
694
+ :param profile: the profile file returned by :func:`select_profile` to use for ybox container
695
+ configuration as a `Path` or resource file from importlib (`Traversable`)
696
+ :param docker_args: list of podman/docker arguments to which required options as per the
697
+ configuration in the `[security]` section are appended
698
+ :raises NotSupportedError: if there is an unknown key in the `[security]` section
699
+ """
700
+ sec_options = {"label", "apparmor", "seccomp", "mask", "umask", "proc_opts"}
701
+ single_options = {"seccomp_policy", "ipc", "cgroup_parent", "cgroupns", "cgroups"}
702
+ multi_options = {"caps_add": "cap-add", "caps_drop": "cap-drop", "ulimits": "ulimit",
703
+ "cgroup_confs": "cgroup-conf", "device_cgroup_rules": "device-cgroup-rule",
704
+ "secrets": "secret"}
705
+ for key, val in sec_section.items():
706
+ if key in sec_options:
707
+ if val:
708
+ docker_args.append(f"--security-opt={key.replace('_', '-')}={val}")
709
+ elif opt := multi_options.get(key):
710
+ add_multi_opt(docker_args, opt, val)
711
+ elif key in single_options:
712
+ if val:
713
+ docker_args.append(f"--{key.replace('_', '-')}={val}")
714
+ elif key == "no_new_privileges":
715
+ if _get_boolean(val):
716
+ docker_args.append("--security-opt=no-new-privileges")
717
+ else:
718
+ raise NotSupportedError(f"Unknown key '{key}' in the [security] of {profile} "
719
+ "or its includes")
720
+
721
+
722
+ def process_mounts_section(mounts_section: SectionProxy, docker_args: list[str]) -> None:
723
+ """
724
+ Process the `[mounts]` section in the container profile to append required podman/docker
725
+ options in the list that has been passed.
726
+
727
+ :param mounts_section: an object of :class:`SectionProxy` from parsing the `[mounts]` section
728
+ :param docker_args: list of podman/docker arguments to which required options as per the
729
+ configuration in the `[mounts]` section are appended
730
+ """
731
+ # keys here are only symbolic names and serve no purpose other than allowing
732
+ # later profile files to override previous ones
733
+ for _, val in mounts_section.items():
734
+ if val:
735
+ if "=" in val or "," in val:
736
+ docker_args.append(f"--mount={val}")
737
+ else:
738
+ docker_args.append(f"-v={val}")
739
+
740
+
741
+ def process_configs_section(configs_section: SectionProxy, config_hardlinks: bool,
742
+ conf: StaticConfiguration, docker_args: list[str]) -> None:
743
+ """
744
+ Process the `[configs]` section in the container profile to append required podman/docker
745
+ options in the list that has been passed. This method also makes hard-links or copies of the
746
+ configuration files in local user's ybox data directory to mount inside the ybox container so
747
+ that the selected configuration files from host are available in the container.
748
+
749
+ :param configs_section: an object of :class:`SectionProxy` from parsing the `[configs]` section
750
+ :param config_hardlinks: the value of `config_hardlinks` key from the `[base]` section that
751
+ indicates whether the configuration files from host have to be made
752
+ available by creating hard-links to them or by making copies
753
+ :param conf: the :class:`StaticConfiguration` for the container
754
+ :param docker_args: list of podman/docker arguments to which required options as per the
755
+ configuration in the `[configs]` section are appended
756
+ """
757
+ # copy or link the mentioned files in [configs] section which can be either files
758
+ # or directories (recursively copy/link in the latter case)
759
+ # this is refreshed on every container start
760
+
761
+ # always recreate the directory to pick up any changes
762
+ if os.path.exists(conf.configs_dir):
763
+ shutil.rmtree(conf.configs_dir)
764
+ os.makedirs(conf.configs_dir, mode=Consts.default_directory_mode(), exist_ok=True)
765
+ if config_hardlinks:
766
+ print_info("Creating hard links to paths specified in [configs] ...")
767
+ else:
768
+ print_info("Creating a copy of paths specified in [configs] ...")
769
+ # write the links to be created in a file that will be passed to container
770
+ # entrypoint to create symlinks from container user's home to the mounted config files
771
+ with open(conf.config_list, "w", encoding="utf-8") as config_list_fd:
772
+ for key, val in configs_section.items():
773
+ # perform environment variable substitution now which was skipped earlier
774
+ f_val = os.path.expandvars(val)
775
+ split_idx = f_val.find("->")
776
+ if split_idx == -1:
777
+ raise NotSupportedError("Incorrect value format in [configs] section for "
778
+ f"'{key}'. Required: '{{src}} -> {{dest}}'")
779
+ src_path = os.path.realpath(f_val[:split_idx].strip())
780
+ dest_path = f"{conf.configs_dir}/{f_val[split_idx + 2:].strip()}"
781
+ 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)
784
+ if os.path.isdir(src_path):
785
+ copytree(src_path, dest_path, hardlink=config_hardlinks)
786
+ else:
787
+ if config_hardlinks:
788
+ os.link(os.path.realpath(src_path), dest_path, follow_symlinks=True)
789
+ else:
790
+ shutil.copy2(src_path, dest_path, follow_symlinks=True)
791
+ config_list_fd.write(val)
792
+ config_list_fd.write("\n")
793
+ else:
794
+ print_warn(f"Skipping inaccessible configuration path '{src_path}'")
795
+ print_info("DONE.")
796
+ # finally mount the configs directory to corresponding directory in the target container
797
+ add_mount_option(docker_args, conf.configs_dir, conf.target_configs_dir, "ro")
798
+
799
+
800
+ def process_env_section(env_section: SectionProxy, docker_args: list[str]) -> None:
801
+ """
802
+ Process the `[env]` section in the container profile to append required podman/docker
803
+ options in the list that has been passed.
804
+
805
+ :param env_section: an object of :class:`SectionProxy` from parsing the `[env]` section
806
+ :param docker_args: list of podman/docker arguments to which required options as per the
807
+ configuration in the `[env]` section are appended
808
+ """
809
+ for key, val in env_section.items():
810
+ add_env_option(docker_args, key, val)
811
+
812
+
813
+ def process_apps_section(apps_section: SectionProxy, conf: StaticConfiguration,
814
+ pkgmgr: SectionProxy) -> dict[str, list[str]]:
815
+ """
816
+ Process the `[apps]` section in the container profile to return a dictionary having packages
817
+ to be installed mapped to the list of dependencies to be installed along with.
818
+
819
+ :param apps_section: an object of :class:`SectionProxy` from parsing the `[apps]` section
820
+ :param conf: the :class:`StaticConfiguration` for the container
821
+ :param pkgmgr: the `[pkgmgr]` section from `distro.ini` configuration file of the distribution
822
+ :return: dictionary of package names mapped to their list of dependencies as specified
823
+ in the `[apps]` section
824
+ """
825
+ if len(apps_section) == 0:
826
+ return {}
827
+ quiet_flag = pkgmgr[PkgMgr.QUIET_FLAG.value]
828
+ opt_dep_flag = pkgmgr[PkgMgr.OPT_DEP_FLAG.value]
829
+ install_cmd = pkgmgr[PkgMgr.INSTALL.value].format(quiet=quiet_flag, opt_dep="")
830
+ clean_cmd = pkgmgr[PkgMgr.CLEAN_QUIET.value]
831
+ if not install_cmd:
832
+ print_color("Skipping app installation since no 'pkgmgr.install' has "
833
+ "been defined in distro.ini or is empty",
834
+ fg=fgcolor.lightgray, bg=bgcolor.red)
835
+ return {}
836
+ # write pkgmgr.conf for entrypoint.sh
837
+ with open(f"{conf.scripts_dir}/pkgmgr.conf", "w", encoding="utf-8") as pkg_fd:
838
+ pkg_fd.write(f"PKGMGR_INSTALL='{install_cmd}'\n")
839
+ pkg_fd.write(f"PKGMGR_CLEAN='{clean_cmd}'\n")
840
+ apps_with_deps = defaultdict[str, list[str]](list[str])
841
+
842
+ def capture_dep(match: re.Match[str]) -> str:
843
+ dep = match.group(1)
844
+ apps_with_deps[match.group(2)].append(dep)
845
+ return dep
846
+
847
+ with open(conf.app_list, "w", encoding="utf-8") as apps_fd:
848
+ for _, val in apps_section.items():
849
+ apps = [app.strip() for app in val.split(",")]
850
+ deps = [capture_dep(match) for dep in apps if (match := _DEP_SUFFIX.match(dep))]
851
+ if deps:
852
+ apps = [app for app in apps if not _DEP_SUFFIX.match(app)]
853
+ apps_fd.write(f"{opt_dep_flag} {' '.join(deps)}\n")
854
+ if apps:
855
+ apps_fd.write(f"{' '.join(apps)}\n")
856
+ for app in apps:
857
+ assert apps_with_deps[app] is not None # insert with empty list if absent
858
+ return apps_with_deps
859
+
860
+
861
+ # 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:
867
+ """
868
+ Copy or create hard links to a source directory tree in the given destination directory.
869
+ Since hard links to directories are not supported, the destination will mirror the directories
870
+ of the source while the files inside will be either copies or hard links to the source.
871
+
872
+ :param src: the source directory tree
873
+ :param dest: the destination directory which should exist
874
+ :param hardlink: if True then create hard links to the files in the source (so it should
875
+ be in the same filesystem) else copy the files, defaults to False
876
+ """
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)
888
+ 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)
893
+
894
+
895
+ def setup_ybox_scripts(conf: StaticConfiguration, distro_config: ConfigParser) -> None:
896
+ """
897
+ Create/copy various scripts required for the ybox container including entrypoint scripts,
898
+ Linux distribution specific scripts and other required executables (e.g. `run-in-dir`).
899
+
900
+ :param conf: the :class:`StaticConfiguration` for the container
901
+ :param distro_config: an object of :class:`ConfigParser` from parsing the Linux
902
+ distribution's `distro.ini`
903
+ """
904
+ # first create local mount directory having entrypoint and other scripts
905
+ if os.path.exists(conf.scripts_dir):
906
+ shutil.rmtree(conf.scripts_dir)
907
+ os.makedirs(conf.scripts_dir, exist_ok=True)
908
+ # allow for read/execute permissions for all since non-root user needs access with docker
909
+ os.chmod(conf.scripts_dir, mode=0o755)
910
+ copy_ybox_scripts_to_container(conf, distro_config)
911
+ # finally write the current version to "version" file in scripts directory of the container
912
+ write_ybox_version(conf)
913
+
914
+
915
+ def run_base_container(image_name: str, current_user: str, secondary_groups: str, docker_cmd: str,
916
+ conf: StaticConfiguration) -> None:
917
+ """
918
+ Start a minimal container for the selected Linux distribution with the smallest upstream image
919
+ (important to save space when `base.shared_root` is provided) with `entrypoint-base.sh` as the
920
+ entrypoint script giving user/group arguments to be same as the user as on the host machine.
921
+
922
+ :param image_name: distribution image to use for the container as specified in `distro.ini`
923
+ :param current_user: the current user executing the `ybox-create` script
924
+ :param secondary_groups: secondary groups for the container user as specified in `distro.ini`
925
+ :param docker_cmd: the podman/docker executable to use
926
+ :param conf: the :class:`StaticConfiguration` for the container
927
+ """
928
+ # get current user and group details to pass to the entrypoint script
929
+ user_entry = pwd.getpwnam(current_user)
930
+ group_entry = grp.getgrgid(user_entry.pw_gid)
931
+ print_warn(f"Creating container specific image having sudo user '{current_user}'")
932
+ docker_run = [docker_cmd, "run", f"--name={conf.box_name}",
933
+ f"-v={conf.scripts_dir}:{conf.target_scripts_dir}:ro",
934
+ f"--label={YboxLabel.CONTAINER_BASE.value}",
935
+ f"--entrypoint={conf.target_scripts_dir}/{Consts.entrypoint_base()}",
936
+ image_name, "-u", current_user, "-U", str(user_entry.pw_uid),
937
+ "-n", user_entry.pw_gecos, "-g", group_entry.gr_name,
938
+ "-G", str(group_entry.gr_gid), "-s", secondary_groups]
939
+ if conf.localtime:
940
+ docker_run.append("-l")
941
+ docker_run.append(conf.localtime)
942
+ if conf.timezone:
943
+ docker_run.append("-z")
944
+ docker_run.append(conf.timezone)
945
+ run_command(docker_run, error_msg="running container with base image")
946
+
947
+
948
+ def run_shared_copy_container(docker_cmd: str, image_name: str, shared_root: str,
949
+ shared_root_dirs: str, conf: StaticConfiguration,
950
+ quiet: bool) -> None:
951
+ """
952
+ Start a container from a base distribution image (with minimal configuration) when
953
+ `shared_root` has been provided for the container and copy a configured set of directories
954
+ (`shared_root_dirs` in `distro.ini`) from the container to the `shared_root` directory.
955
+ This directory is then used as the root directory for all the final containers that share the
956
+ same `shared_root`.
957
+
958
+ If the provided `shared_root` directory already exists, then user is interactively asked
959
+ whether to delete the existing directory before proceeding.
960
+
961
+ :param docker_cmd: the podman/docker executable to use
962
+ :param image_name: distribution image to use for the container as specified in `distro.ini`
963
+ :param shared_root: the shared root directory to use for the container
964
+ :param shared_root_dirs: comma-separate list of directories shared between containers having
965
+ the same `shared_root`
966
+ :param conf: the :class:`StaticConfiguration` for the container
967
+ :param quiet: if True then don't ask for user confirmation but assume a `no`
968
+ """
969
+ # if shared root copy exists locally, then prompt user to delete it or else exit
970
+ if os.path.exists(shared_root):
971
+ input_msg = f"""
972
+ The shared root directory for '{conf.distribution}' already exists in:
973
+ {shared_root}
974
+ However, the corresponding ybox container image for '{conf.distribution}' does not
975
+ exist. This can happen if an old copy of shared root directory is lying around and
976
+ is usually safe to remove, but you should be sure that no other ybox is running
977
+ for '{conf.distribution}' with 'shared_root' configuration that is using that
978
+ directory. Should the root directory be removed (y/N): """
979
+ response = "N" if quiet else input(dedent(input_msg))
980
+ if response.strip().lower() == "y":
981
+ try:
982
+ shutil.rmtree(shared_root)
983
+ except OSError:
984
+ # try with sudo
985
+ run_command(["/usr/bin/sudo", "/bin/rm", "-rf", shared_root],
986
+ error_msg="deleting directory")
987
+ else:
988
+ print_error(f"Aborting creation of ybox container '{conf.box_name}'")
989
+ # remove the temporary image before exit
990
+ remove_image(docker_cmd, image_name)
991
+ sys.exit(1)
992
+ os.makedirs(shared_root, mode=Consts.default_directory_mode())
993
+ # the entrypoint-cp.sh script requires two arguments: first is the comma separated
994
+ # list of directories to be copied, and second the target directory
995
+ docker_full_cmd = [docker_cmd, "run", f"--name={conf.box_name}",
996
+ f"-v={conf.scripts_dir}:{conf.target_scripts_dir}:ro",
997
+ f"-v={shared_root}:{Consts.shared_root_mount_dir()}",
998
+ f"--label={YboxLabel.CONTAINER_COPY.value}", "--user=0",
999
+ f"--entrypoint={conf.target_scripts_dir}/{Consts.entrypoint_cp()}"]
1000
+ if conf.env.uses_podman:
1001
+ docker_full_cmd.append("--userns=keep-id")
1002
+ docker_full_cmd.extend((image_name, shared_root_dirs, Consts.shared_root_mount_dir()))
1003
+ run_command(docker_full_cmd, error_msg="running container for copying shared root")
1004
+
1005
+
1006
+ def commit_container(docker_cmd: str, image_name: str, conf: StaticConfiguration) -> None:
1007
+ """
1008
+ Commit the contents of a container as a new image. This also sets up the `USER` and `WORKDIR`
1009
+ properties of the image to those of target user's name and home respectively (as detected in
1010
+ the `Environ` object).
1011
+
1012
+ :param docker_cmd: the podman/docker executable to use
1013
+ :param image_name: name of the image to create
1014
+ :param conf: the :class:`StaticConfiguration` for the container
1015
+ """
1016
+ run_command([docker_cmd, "commit", "--change", f"USER {conf.env.target_user}",
1017
+ "--change", f"WORKDIR {conf.env.target_home}", conf.box_name, image_name],
1018
+ error_msg="container commit")
1019
+ remove_container(docker_cmd, conf)
1020
+
1021
+
1022
+ def remove_container(docker_cmd: str, conf: StaticConfiguration) -> None:
1023
+ """remove a stopped podman/docker container"""
1024
+ run_command([docker_cmd, "container", "rm", conf.box_name], error_msg="container rm")
1025
+
1026
+
1027
+ def remove_image(docker_cmd: str, image_name: str) -> None:
1028
+ """remove an unused podman/docker image"""
1029
+ run_command([docker_cmd, "image", "rm", image_name], exit_on_error=False,
1030
+ error_msg="image remove")
1031
+
1032
+
1033
+ def start_container(docker_full_cmd: list[str], current_user: str, shared_root: str,
1034
+ shared_root_dirs: str, conf: StaticConfiguration) -> None:
1035
+ """
1036
+ Create and start the final ybox container applying all the provided configuration.
1037
+ The following characteristics of the container are noteworthy:
1038
+ * uses docker or podman to create the container that are required to be in `rootless` mode
1039
+ * maps the host environment user ID to the same UID in the container (`--userns=keep-id`)
1040
+ when using podman else maps to the root user for docker
1041
+ * sets the container user to host environment user using `--user=...` option but does not
1042
+ enforce the primary group in that option so that the container user can belong to other
1043
+ secondary groups that is required for some applications
1044
+ * as a result of above, applications that need user namespace support (like Steam) need
1045
+ to be started with explicit elevated capabilities e.g. using `setpriv --ambient-caps -all`
1046
+ (this can be specified in `[app_flags]` section of the container profile or when installing
1047
+ the application using `ybox-pkg install` which will add the same to the wrapper executable
1048
+ and desktop files)
1049
+ * containers with the same configured `shared_root` will share the system installation
1050
+ so any changes to system directories in one container will reflect in all others;
1051
+ such a configuration reduces memory and disk overheads of multiple containers significantly
1052
+ but users should also keep in mind that packages installed in any container will be
1053
+ visible across all containers so care should be taken to not use potentially risky
1054
+ programs from less secure containers; the `ybox-pkg` tool provided a convenient high-level
1055
+ package manager that users should use for managing packages in the containers which will
1056
+ 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
1060
+
1061
+ :param docker_full_cmd: the `docker`/`podman run -itd` command with all the options filled
1062
+ in from the container profile specification as a list of string
1063
+ :param current_user: the current user executing the `ybox-create` script
1064
+ :param shared_root: the shared root directory to use for the container
1065
+ :param shared_root_dirs: comma-separate list of directories shared between containers having
1066
+ the same `shared_root`
1067
+ :param conf: the :class:`StaticConfiguration` for the container
1068
+ """
1069
+ add_mount_option(docker_full_cmd, conf.scripts_dir, conf.target_scripts_dir, "ro")
1070
+ # touch the status file and mount it
1071
+ status_path = Path(conf.status_file)
1072
+ status_path.unlink(missing_ok=True)
1073
+ status_path.touch(mode=0o600, exist_ok=False)
1074
+ add_mount_option(docker_full_cmd, conf.status_file, Consts.status_target_file())
1075
+
1076
+ if shared_root:
1077
+ for shared_dir in shared_root_dirs.split(","):
1078
+ add_mount_option(docker_full_cmd, f"{shared_root}{shared_dir}", shared_dir)
1079
+ docker_full_cmd.append(f"-e=XDG_RUNTIME_DIR={conf.env.target_xdg_rt_dir}")
1080
+ docker_full_cmd.append("-e=YBOX_TARGET_SCRIPTS_DIR") # pass this along for container scripts
1081
+ docker_full_cmd.append(f"--label={YboxLabel.CONTAINER_PRIMARY.value}")
1082
+ docker_full_cmd.append(f"--label={YboxLabel.CONTAINER_DISTRIBUTION.value}={conf.distribution}")
1083
+ docker_full_cmd.append(f"--entrypoint={conf.target_scripts_dir}/{Consts.entrypoint()}")
1084
+ # bubblewrap and thereby programs like steam do not work without --user
1085
+ # (https://github.com/containers/bubblewrap/issues/380#issuecomment-648169485)
1086
+ user_entry = pwd.getpwnam(current_user)
1087
+ user_uid = user_entry.pw_uid
1088
+ user_gid = user_entry.pw_gid
1089
+ if conf.env.uses_podman:
1090
+ docker_full_cmd.append(f"--user={user_uid}")
1091
+ docker_full_cmd.append("--userns=keep-id")
1092
+ else:
1093
+ docker_full_cmd.append("--user=0")
1094
+ docker_full_cmd.append(f"-e=YBOX_HOST_UID={user_uid}")
1095
+ docker_full_cmd.append(f"-e=YBOX_HOST_GID={user_gid}")
1096
+ docker_full_cmd.append(conf.box_image(bool(shared_root)))
1097
+ if os.access(conf.config_list, os.R_OK):
1098
+ docker_full_cmd.extend(["-c", f"{conf.target_scripts_dir}/config.list",
1099
+ "-d", conf.target_configs_dir])
1100
+ if os.access(conf.app_list, os.R_OK):
1101
+ docker_full_cmd.append("-a")
1102
+ docker_full_cmd.append(f"{conf.target_scripts_dir}/app.list")
1103
+ docker_full_cmd.append(conf.box_name)
1104
+
1105
+ if (code := int(run_command(docker_full_cmd, exit_on_error=False,
1106
+ error_msg="container launch"))) != 0:
1107
+ print_error(f"Also check 'ybox-logs {conf.box_name}' for details")
1108
+ sys.exit(code)
1109
+
1110
+
1111
+ def restart_container(docker_cmd: str, conf: StaticConfiguration) -> None:
1112
+ """restart a stopped podman/docker container"""
1113
+ if (code := int(run_command([docker_cmd, "container", "start", conf.box_name],
1114
+ exit_on_error=False, error_msg="container restart"))) != 0:
1115
+ print_error(f"Also check 'ybox-logs {conf.box_name}' for details")
1116
+ sys.exit(code)