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/state.py ADDED
@@ -0,0 +1,914 @@
1
+ """
2
+ Classes and methods for bookkeeping the state of ybox containers including the packages
3
+ installed on each container explicitly.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import sqlite3
10
+ from configparser import ConfigParser
11
+ from contextlib import closing
12
+ from dataclasses import dataclass
13
+ from enum import Enum, IntFlag, auto
14
+ from importlib.resources import files
15
+ from io import StringIO
16
+ from typing import Iterable, Iterator, Optional, Union
17
+ from uuid import uuid4
18
+
19
+ from packaging.version import Version
20
+ from packaging.version import parse as parse_version
21
+
22
+ from ybox import __version__ as product_version
23
+
24
+ from .config import Consts, StaticConfiguration
25
+ from .env import Environ, PathName
26
+ from .print import print_color, print_warn
27
+ from .util import ini_file_reader, resolve_inc_path, write_ybox_version
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RuntimeConfiguration:
32
+ """
33
+ Holds runtime configuration details of a container.
34
+
35
+ Attributes:
36
+ name: name of the container
37
+ distribution: the Linux distribution used when creating the container
38
+ shared_root: the local shared root directory for the container (see `shared_root` key
39
+ in ybox/conf/profiles/basic.ini)
40
+ ini_config: the resolved configuration of the container in INI format as a string or
41
+ a `ConfigParser` object
42
+ """
43
+ name: str
44
+ distribution: str
45
+ shared_root: str
46
+ ini_config: Union[str, ConfigParser]
47
+
48
+
49
+ class CopyType(IntFlag):
50
+ """
51
+ Different types of local wrappers created for container desktop/executable files which
52
+ is used in the `local_copy_type` field of the `packages` table.
53
+ """
54
+ DESKTOP = auto()
55
+ EXECUTABLE = auto()
56
+
57
+
58
+ class DependencyType(str, Enum):
59
+ """
60
+ Different types of package dependencies. Used in `dep_type` field of the `package_deps` table.
61
+ """
62
+ REQUIRED = "required"
63
+ OPTIONAL = "optional"
64
+ SUGGESTION = "suggestion"
65
+
66
+
67
+ class YboxStateManagement:
68
+ """
69
+ Maintain the state of all ybox containers. This includes:
70
+
71
+ 1. The full configuration used for the creation of a container.
72
+ 2. The packages installed explicitly on each of the containers (though all
73
+ packages may be visible on all containers having the same `shared_root`)
74
+ 3. Cleanup state of containers removed explicitly or those that got stopped/removed.
75
+
76
+ Expected usage is using a `with` statement to ensure proper cleanup other the database
77
+ may be left in a locked state.
78
+
79
+ NOTE: This class is not thread-safe and concurrent operations on the same object by multiple
80
+ threads can lead to an indeterminate state.
81
+
82
+ The latest schema is now maintained as a SQL file `init.sql` in `ybox.schema` package.
83
+ This is executed using `executescript` method of `sqlite3.Cursor`, so it supports normal
84
+ multi-line SQL. In addition, support for including other files using `SOURCE '...'` has been
85
+ provided as described below.
86
+
87
+ Migration scripts: the code supports schema evolution using SQL migration scripts
88
+ in `ybox.schema.migrate` package. The file name must follow `<old version>:<new version>.sql`
89
+ naming convention (e.g. `0.9.0:0.9.1.sql`). The scripts are sorted by <old version> and
90
+ executed, so at most one script for each version is expected. Version numbers follow
91
+ the standard python convention, so there can be any number of alpha/beta/dev releases
92
+ (see https://packaging.python.org/en/latest/specifications/version-specifiers).
93
+
94
+ The schema and migration scripts support inclusion of other SQL files using `SOURCE '...';`
95
+ directive similar to MariaDB/MySQL, so you can split out common portions in other files.
96
+ However, do not place such files in `ybox.schema.migrate` package since the ones there
97
+ are all required to be migration scripts that are executed in order, but you can use a
98
+ sub-package inside it or elsewhere then use path relative to the schema/migration script.
99
+ """
100
+
101
+ # last version when versioning and schema migration did not exist
102
+ _PRE_SCHEMA_VERSION = parse_version("0.9.0")
103
+ # last version when container versioning did not exist
104
+ _PRE_CONTAINER_VERSION = parse_version("0.9.5")
105
+ # pattern to match "source '<file>'" in SQL script -- doesn't allow a quote in file name
106
+ _SOURCE_SQLCMD_RE = re.compile(r"^\s*source\s*'([^']+)'\s*;\s*", re.IGNORECASE)
107
+ # SQL to start an EXCLUSIVE transaction
108
+ _BEGIN_EX_TXN_SQL = "BEGIN EXCLUSIVE TRANSACTION"
109
+
110
+ # when comparing two container configurations, delete the sections mentioned below and the
111
+ # keys in the [base] section (specifically log-file in log-opts will change)
112
+ # (note that "includes" can be safely removed here since the provided configurations to state
113
+ # have already processed all the inclusions)
114
+ _CONFIG_NORMALIZE_DEL_SECTIONS = ["mounts", "configs", "env", "apps", "app_flags", "startup"]
115
+ _CONFIG_NORMALIZE_DEL_BASE_KEYS = ["name", "includes", "home", "config_hardlinks",
116
+ "nvidia", "nvidia_ctk", "shm_size", "pids_limit",
117
+ "log_driver", "log_opts"]
118
+
119
+ def __init__(self, env: Environ, connect_timeout: float = 60.0):
120
+ """
121
+ Initialize connection to database and create tables+indexes if not present. If the
122
+ product version has upgraded that needs updated schema, then also run the required
123
+ schema migration scripts.
124
+
125
+ :param env: an instance of the current :class:`Environ`
126
+ :param connect_timeout: database connection timeout in seconds as a `float`, default = 60.0
127
+ """
128
+ # explicitly control transaction begin (in exclusive mode) since SERIALIZABLE isolation
129
+ # level is required while sqlite3 module will not start transactions before reads
130
+ os.makedirs(env.data_dir, mode=Consts.default_directory_mode(), exist_ok=True)
131
+ self._conn = sqlite3.connect(f"{env.data_dir}/state.db", timeout=connect_timeout,
132
+ isolation_level=None)
133
+ self._explicit_transaction = False
134
+ # create the initial tables
135
+ with closing(cursor := self._conn.cursor()):
136
+ self._begin_transaction(cursor)
137
+ self._conn.create_function("REGEXP", 2, self.regexp, deterministic=True)
138
+ self._conn.create_function("JSON_FROM_CSV", 1, self.json_from_csv, deterministic=True)
139
+ self._conn.create_function("EQUIV_CONFIG", 2, self.equivalent_configuration,
140
+ deterministic=True)
141
+ self._version = parse_version(product_version)
142
+ self._init_schema(cursor)
143
+ self._internal_commit()
144
+
145
+ @staticmethod
146
+ def regexp(val: str, pattern: str) -> int:
147
+ """callable for the user-defined SQL REGEXP function"""
148
+ # rely on python regex caching for efficient repeated calls
149
+ return 1 if re.fullmatch(pattern, val) else 0
150
+
151
+ @staticmethod
152
+ def json_from_csv(val: str) -> str:
153
+ """callable for the user-defined SQL JSON_FROM_CSV function"""
154
+ return json.dumps(val.split(","))
155
+
156
+ @staticmethod
157
+ def equivalent_configuration(conf_str1: str, conf_str2: str) -> int:
158
+ """
159
+ Callable for the user-defined EQUIV_CONFIG function. Checking equivalence consists
160
+ of deleting sections and keys that don't affect behavior of the container in terms
161
+ of running the apps. Specifically the `log_opts` key from the `[base]` section has to
162
+ be removed because the log-file name, when set based on time, will change in every run.
163
+ """
164
+ with StringIO(conf_str1) as conf_io1:
165
+ config1 = ini_file_reader(conf_io1, interpolation=None, case_sensitive=True)
166
+ YboxStateManagement.normalize_configuration(config1)
167
+
168
+ with StringIO(conf_str2) as conf_io2:
169
+ config2 = ini_file_reader(conf_io2, interpolation=None, case_sensitive=True)
170
+ YboxStateManagement.normalize_configuration(config2)
171
+
172
+ return int(config1 == config2)
173
+
174
+ @staticmethod
175
+ def normalize_configuration(config: ConfigParser) -> None:
176
+ """
177
+ Normalize a configuration by deleting sections/keys that do not affect its overall
178
+ behavior in running applications in the container.
179
+ """
180
+ for del_section in YboxStateManagement._CONFIG_NORMALIZE_DEL_SECTIONS:
181
+ config.remove_section(del_section)
182
+ for del_key in YboxStateManagement._CONFIG_NORMALIZE_DEL_BASE_KEYS:
183
+ config.remove_option("base", del_key)
184
+
185
+ def _init_schema(self, cursor: sqlite3.Cursor) -> None:
186
+ """
187
+ Initialize the required database objects or migrate from previous version.
188
+
189
+ :param cursor: the `Cursor` object to use for execution
190
+ """
191
+ schema_pkg = files("ybox").joinpath("schema")
192
+ version = self._version
193
+ # full initialization if empty database, else migrate and update the version if required
194
+ # ('containers' table exists in all versions)
195
+ if self._table_exists("containers", cursor):
196
+ # check version and run migration scripts if required
197
+ if self._table_exists("schema", cursor): # version > 0.9.0
198
+ cursor.execute("SELECT version FROM schema")
199
+ old_version = parse_version(cursor.fetchone()[0])
200
+ else: # version = 0.9.0
201
+ old_version = self._PRE_SCHEMA_VERSION
202
+ if version != old_version:
203
+ # run appropriate SQL migration scripts for product version change
204
+ for script in self._filter_and_sort_files_by_version(
205
+ schema_pkg.joinpath("migrate").iterdir(), old_version, version, ".sql"):
206
+ self._execute_sql_script(script, cursor)
207
+ # finally update the version in the database
208
+ cursor.execute("UPDATE schema SET version = ?", (str(version),))
209
+ else:
210
+ self._execute_sql_script(schema_pkg.joinpath("init.sql"), cursor)
211
+ cursor.execute("INSERT INTO schema VALUES (?)", (str(version),))
212
+
213
+ @staticmethod
214
+ def _filter_and_sort_files_by_version(file_iter: Iterator[PathName], old_version: Version,
215
+ new_version: Version, suffix: str) -> list[PathName]:
216
+ """
217
+ Given the previous and new version of the product, and files having names of the form
218
+ `<version1>:<version2><suffix>` filter out the files having `<version1>` and `<version2>`
219
+ between the two versions (and thus should be executed for product migration) and then
220
+ sort on `<version1>`.
221
+
222
+ :param file_iter: list of files to be filtered and sorted
223
+ :param old_version: previous version of the product to compare against
224
+ :param new_version: current version of the product
225
+ :param suffix: suffix of the files
226
+ :return: filtered and sorted list of files that need to be run for product migration
227
+ """
228
+ sep = ":"
229
+
230
+ # determine the migration scripts that need to be run by comparing versions
231
+ def check_version(file: str) -> bool:
232
+ """
233
+ Check if the versions in the given migration script <ver1_1>[-<ver1_2>]:<ver2>.<suffix>
234
+ are within the stored schema version and current schema version
235
+ """
236
+ if not file.endswith(suffix):
237
+ return False
238
+ part1, _, part2 = file.removesuffix(suffix).partition(sep)
239
+ part1_1, _, part1_2 = part1.partition("-")
240
+ if part1_2:
241
+ return parse_version(part1_1) <= old_version <= parse_version(part1_2) < \
242
+ parse_version(part2) <= new_version
243
+ return old_version <= parse_version(part1) < parse_version(part2) <= new_version
244
+
245
+ version_files = [file for file in file_iter if file.is_file() and check_version(file.name)]
246
+ if len(version_files) > 1:
247
+ version_files.sort(key=lambda f: parse_version(f.name[:f.name.find(sep)]))
248
+ return version_files
249
+
250
+ @staticmethod
251
+ def _table_exists(name: str, cursor: sqlite3.Cursor) -> bool:
252
+ """
253
+ Check if a given table exists.
254
+
255
+ :param name: name of the table
256
+ :param cursor: the `Cursor` object to use for execution
257
+ :return: True if the table exists and False otherwise
258
+ """
259
+ cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table' "
260
+ "AND name = ?", (name,))
261
+ return cursor.fetchone() is not None
262
+
263
+ @staticmethod
264
+ def _execute_sql_script(sql_file: PathName, cursor: sqlite3.Cursor) -> None:
265
+ """
266
+ Execute a SQL script having one or more SQL commands. It also supports `source <file>`
267
+ command (like MariaDB/MySQL) to include other SQL script files which can be a path
268
+ relative to the original SQL script or an absolute path.
269
+
270
+ :param sql_file: the SQL script file either as a `Path` or resource file from
271
+ importlib (`Traversable`)
272
+ :param cursor: the `Cursor` object to use for execution
273
+ """
274
+
275
+ # process all the "source" directives and include file contents recursively
276
+ def process_source(file: PathName, output_lines: list[str]) -> None:
277
+ with file.open("r", encoding="utf-8") as sql_fd:
278
+ while sql := sql_fd.readline():
279
+ if match := YboxStateManagement._SOURCE_SQLCMD_RE.fullmatch(sql):
280
+ inc_file = resolve_inc_path(match.group(1), file)
281
+ process_source(inc_file, output_lines)
282
+ else:
283
+ output_lines.append(sql)
284
+ # readline() will convert all line endings to \n except possibly the last
285
+ # one, so add a newline which might be missing in the middle of file
286
+ # in recursive "source" include
287
+ if sql[-1] != "\n":
288
+ output_lines.append("\n")
289
+
290
+ sql_lines: list[str] = []
291
+ process_source(sql_file, sql_lines)
292
+ cursor.executescript("".join(sql_lines))
293
+
294
+ def _begin_transaction(self, cursor: sqlite3.Cursor) -> None:
295
+ """
296
+ Begin an EXCLUSIVE transaction used internally by the methods of this class to ensure
297
+ atomicity of a group of reads and writes. This will be skipped if an explicit transaction
298
+ was started by invoking :meth:`begin_transaction`.
299
+
300
+ :param cursor: the `Cursor` object to use for execution
301
+ """
302
+ if not self._explicit_transaction:
303
+ cursor.execute(self._BEGIN_EX_TXN_SQL)
304
+
305
+ def _internal_commit(self) -> None:
306
+ """
307
+ COMMIT the current transaction if it is not an explicit user transaction (using
308
+ :meth:`begin_transaction`). All methods of this class should use this to commit
309
+ any changes made in the method.
310
+ """
311
+ if not self._explicit_transaction:
312
+ self._conn.commit()
313
+
314
+ def begin_transaction(self) -> None:
315
+ """
316
+ Begin an EXCLUSIVE transaction explicitly to ensure atomicity of a group of methods of
317
+ this class. Note that each public method of this class already runs all the required
318
+ database reads and writes within an EXCLUSIVE transaction, so use this only if you need
319
+ to run a transaction across multiple methods, or need to keep an open transaction for
320
+ longer.
321
+
322
+ The transaction will be automatically committed (or rolled back in case of exceptions)
323
+ when the class object is cleaned up at the end of the associated `with` statement.
324
+ Callers can also invoke :meth:`commit` and :meth:`rollback` for explicit cleanup.
325
+ """
326
+ if not self._explicit_transaction:
327
+ self._conn.execute(self._BEGIN_EX_TXN_SQL).close()
328
+ self._explicit_transaction = True
329
+
330
+ def migrate_container(self, container_version: str, conf: StaticConfiguration,
331
+ distro_config: ConfigParser) -> None:
332
+ """
333
+ Run migration scripts for an existing container, if required, so that current product code
334
+ will continue to work on it with expected semantics.
335
+
336
+ :param container_version: version string as recorded in the container
337
+ :param conf: the :class:`StaticConfiguration` for the container
338
+ :param distro_config: an object of :class:`ConfigParser` from parsing the Linux
339
+ distribution's `distro.ini`
340
+ """
341
+ # pylint: disable=exec-used
342
+ old_version = parse_version(container_version) if container_version \
343
+ else self._PRE_CONTAINER_VERSION
344
+ if self._version == old_version:
345
+ return
346
+ # run appropriate SQL migration scripts for product version change
347
+ if not (scripts := self._filter_and_sort_files_by_version(
348
+ files("ybox").joinpath("migrate").iterdir(), old_version, self._version, ".py")):
349
+ return
350
+ for script in scripts:
351
+ print_color(f"Running migration script '{script}' for container version upgrade from "
352
+ f"{old_version} to {self._version}")
353
+ with script.open("r", encoding="utf-8") as py_fd:
354
+ exec(py_fd.read(), {}, {"conf": conf, "distro_config": distro_config})
355
+ # finally write the current version to "version" file in scripts directory of the container
356
+ write_ybox_version(conf)
357
+
358
+ def register_container(self, container_name: str, distribution: str, shared_root: str,
359
+ parser: ConfigParser, force_own_orphans: bool = True) -> \
360
+ dict[str, tuple[CopyType, dict[str, str]]]:
361
+ """
362
+ Register information of a ybox container including its name, distribution and
363
+ configuration. In addition to the registration, this will also check for orphaned packages
364
+ on the same `shared_root` (if applicable), and reassign them to this container if
365
+ they were originally installed on a container having an :meth:`equivalent_configuration`.
366
+
367
+ :param container_name: name of the container
368
+ :param distribution: the Linux distribution used when creating the container
369
+ :param shared_root: the local shared root directory if `shared_root` is provided
370
+ for the container
371
+ :param parser: parser object for the configuration file used for creating the container
372
+ :param force_own_orphans: if True, then force the ownership of orphan packages on the
373
+ same shared root to this container even if the container
374
+ configuration is not equivalent to the original container
375
+ configuration under which those packages were installed
376
+ :return: dictionary of previously installed packages (as the key) that got reassigned to
377
+ this container mapped to the `CopyType` for the wrapper files of those packages
378
+ (which can be used to recreate wrappers for container desktop/executable files)
379
+ and the dictionary of `app_flags`
380
+ """
381
+ packages: dict[str, tuple[CopyType, dict[str, str]]] = {}
382
+ # build the ini string from parser
383
+ with StringIO() as config:
384
+ parser.write(config)
385
+ config.flush()
386
+ config_str = config.getvalue()
387
+ with closing(cursor := self._conn.cursor()):
388
+ self._begin_transaction(cursor)
389
+ # the ybox container may have been destroyed from outside ybox tools, so unregister
390
+ self._unregister_container(container_name, cursor)
391
+ cursor.execute("INSERT INTO containers VALUES (?, ?, ?, ?, false)",
392
+ (container_name, distribution, shared_root, config_str))
393
+ # Find the orphan packages with the same shared_root and assign to this container
394
+ # but only if the destroyed container had the same shared root and configuration.
395
+ if shared_root:
396
+ cursor.execute("SELECT dc.name FROM containers dc WHERE dc.destroyed = true AND "
397
+ f"dc.shared_root = ? AND ({force_own_orphans} OR "
398
+ "EQUIV_CONFIG(dc.configuration, ?))", (shared_root, config_str))
399
+ equiv_destroyed = [row[0] for row in cursor.fetchall()]
400
+ if equiv_destroyed:
401
+ in_args = ", ".join(["?" for _ in equiv_destroyed])
402
+ pkg_args = [container_name]
403
+ pkg_args.extend(equiv_destroyed)
404
+ # reassign packages to this container having matching destroyed container
405
+ cursor.execute("UPDATE packages SET container = ? WHERE container IN "
406
+ f"({in_args}) RETURNING name, local_copy_type, flags", pkg_args)
407
+ packages = {name: (CopyType(cp_type), json.loads(flags)) for
408
+ (name, cp_type, flags) in cursor.fetchall()}
409
+ cursor.execute(
410
+ f"UPDATE package_deps SET container = ? WHERE container IN ({in_args})",
411
+ pkg_args)
412
+ # get rid of destroyed containers whose packages all got reassigned
413
+ cursor.execute(f"DELETE FROM containers WHERE name IN ({in_args})",
414
+ equiv_destroyed)
415
+ self._internal_commit()
416
+ return packages
417
+
418
+ def unregister_container(self, container_name: str) -> bool:
419
+ """
420
+ Unregister information of a ybox container. This also clears any registered packages
421
+ for the container if 'shared_root' is false for the container. However, if 'shared_root'
422
+ is true for the container, its packages are marked as "orphan" (i.e. owner was destroyed)
423
+ if no other container refers to them. This is because the packages will still be visible
424
+ in all other containers having the same `shared_root`.
425
+
426
+ :param container_name: name of the container
427
+ :return: true if container was found in the database and removed
428
+ """
429
+ with closing(cursor := self._conn.cursor()):
430
+ self._begin_transaction(cursor)
431
+ result = self._unregister_container(container_name, cursor)
432
+ self._internal_commit()
433
+ return result
434
+
435
+ @staticmethod
436
+ def _unregister_container(container_name: str, cursor: sqlite3.Cursor) -> bool:
437
+ """
438
+ The real workhorse of `unregister_container`.
439
+
440
+ :param container_name: name of the container
441
+ :param cursor: the `Cursor` object to use for execution
442
+ :return: true if container was found in the database and removed
443
+ """
444
+ cursor.execute("DELETE FROM containers WHERE name = ? RETURNING distribution, "
445
+ "shared_root, configuration", (container_name,))
446
+ # if the container has 'shared_root', then packages will continue to exist, but update
447
+ # the entry in `containers` with a new unique name (else there can be clashes later)
448
+ # and update the container name in package tables
449
+ row = cursor.fetchone()
450
+ # check if there are any packages registered for the container
451
+ cursor.execute("SELECT 1 FROM packages WHERE container = ?", (container_name,))
452
+ if not cursor.fetchone():
453
+ return row is not None
454
+
455
+ distro, shared_root, config = row or (None, None, None)
456
+ if shared_root:
457
+ new_name = str(uuid4()) # generate a unique name
458
+ insert_done = False
459
+ while not insert_done:
460
+ try:
461
+ cursor.execute("INSERT INTO containers VALUES (?, ?, ?, ?, true)",
462
+ (new_name, distro, shared_root, config))
463
+ insert_done = True
464
+ except sqlite3.IntegrityError:
465
+ # retry if unlucky (or buggy) to generate a UUID already generated in the past
466
+ new_name = str(uuid4())
467
+ # UPDATE ... RETURNING gives the old value, hence getting local_copies separately
468
+ # and then update both container name and empty local_copies in a single update
469
+ cursor.execute("SELECT local_copies FROM packages WHERE container = ?",
470
+ (container_name,))
471
+ local_copies = YboxStateManagement._extract_local_copies(cursor.fetchall())
472
+ # update container name to the new one for destroyed container and clear local_copies
473
+ # but if a package is already registered with another valid container then delete it
474
+ cursor.execute("DELETE FROM packages AS pkgs WHERE container = ? AND EXISTS "
475
+ "(SELECT 1 FROM packages AS p WHERE p.name = pkgs.name GROUP BY p.name"
476
+ " HAVING COUNT(*) > 1) RETURNING name", (container_name,))
477
+ if rows := cursor.fetchall():
478
+ cursor.executemany("DELETE FROM package_deps WHERE name = ? AND container = ?",
479
+ [(row[0], container_name) for row in rows])
480
+ # if no package is "orphaned" with updated container name, then entry for the
481
+ # destroyed container should be removed from containers table
482
+ cursor.execute("UPDATE packages SET container = ?, local_copies = '[]' "
483
+ "WHERE container = ?", (new_name, container_name))
484
+ if cursor.rowcount and cursor.rowcount > 0:
485
+ cursor.execute("UPDATE package_deps SET container = ? WHERE container = ?",
486
+ (new_name, container_name))
487
+ else:
488
+ # remove the destroyed container entry since there is no entry in packages table
489
+ cursor.execute("DELETE FROM containers WHERE name = ?", (new_name,))
490
+ else:
491
+ cursor.execute("DELETE FROM packages WHERE container = ? RETURNING local_copies",
492
+ (container_name,))
493
+ local_copies = YboxStateManagement._extract_local_copies(cursor.fetchall())
494
+ cursor.execute("DELETE FROM package_deps WHERE container = ?", (container_name,))
495
+ # remove the local wrapper files in both cases
496
+ YboxStateManagement._remove_local_copies(local_copies)
497
+ return row is not None
498
+
499
+ def get_container_configuration(self, name: str) -> Optional[RuntimeConfiguration]:
500
+ """
501
+ Get the configuration details of the container which includes its Linux distribution name,
502
+ shared root path (or empty if not using shared root), and its resolved configuration in
503
+ INI format as a string.
504
+
505
+ :param name: name of the container
506
+ :return: configuration of the container as a `RuntimeConfiguration` object
507
+ """
508
+ with closing(cursor := self._conn.execute(
509
+ "SELECT distribution, shared_root, configuration FROM containers WHERE name = ?",
510
+ (name,))):
511
+ row = cursor.fetchone()
512
+ return RuntimeConfiguration(name=name, distribution=row[0], shared_root=row[1],
513
+ ini_config=row[2]) if row else None
514
+
515
+ def get_containers(self, name: Optional[str] = None, distribution: Optional[str] = None,
516
+ shared_root: Optional[str] = None,
517
+ include_destroyed: bool = False) -> list[str]:
518
+ """
519
+ Get the containers matching the given name, distribution and/or shared root location.
520
+
521
+ :param name: name of the container (optional)
522
+ :param distribution: the Linux distribution used when creating the container (optional)
523
+ :param shared_root: the local shared root directory to search for a package (optional)
524
+ :param include_destroyed: if True then include `destroyed` containers else skip them
525
+ :return: list of containers matching the given criteria
526
+ """
527
+ predicates = ["1=1"] if include_destroyed else ["NOT destroyed"]
528
+ args: list[str] = []
529
+ if name:
530
+ predicates.append("name = ?")
531
+ args.append(name)
532
+ if distribution:
533
+ predicates.append("distribution = ?")
534
+ args.append(distribution)
535
+ if shared_root:
536
+ predicates.append("shared_root = ?")
537
+ args.append(shared_root)
538
+ predicate = " AND ".join(predicates)
539
+ with closing(cursor := self._conn.execute(
540
+ f"SELECT name FROM containers WHERE {predicate} ORDER BY name ASC", args)):
541
+ rows = cursor.fetchall()
542
+ return [str(row[0]) for row in rows]
543
+
544
+ def get_other_shared_containers(self, container_name: str, shared_root: str) -> list[str]:
545
+ """
546
+ Get other containers sharing the same shared_root as the given container.
547
+
548
+ :param container_name: name of the container
549
+ :param shared_root: the local shared root directory if `shared_root` is provided
550
+ for the container
551
+ :return: list of containers sharing the same shared root with the given container
552
+ """
553
+ if not shared_root:
554
+ return []
555
+ shared_containers = self.get_containers(shared_root=shared_root)
556
+ try:
557
+ shared_containers.remove(container_name)
558
+ except ValueError:
559
+ pass
560
+ return shared_containers
561
+
562
+ def register_package(self, container_name: str, package: str, local_copies: list[str],
563
+ copy_type: CopyType, app_flags: dict[str, str], shared_root: str,
564
+ dep_type: Optional[DependencyType], dep_of: str,
565
+ skip_if_exists: bool = False) -> None:
566
+ """
567
+ Register a package as being owned by a container.
568
+
569
+ :param container_name: name of the container
570
+ :param package: the package to be registered
571
+ :param local_copies: list of locally wrapped files for the package (typically desktop
572
+ files and binary executables that invoke container ones)
573
+ :param copy_type: the type of files (one of `CopyType`s or CopyType(0)) in `local_copies`
574
+ :param app_flags: the flags from [app_flags] section and --app-flags option to add to
575
+ executable invocation in the local wrappers (`local_copies`)
576
+ :param shared_root: the local shared root directory if `shared_root` is provided
577
+ for the container
578
+ :param dep_type: the `DependencyType` for the package, or None if not a dependency
579
+ :param dep_of: if `dep_type` is not None, then this is the package that has this one
580
+ as a dependency of that type
581
+ :param skip_if_exists: if True them skip if package is already registered else replace
582
+ with the given information
583
+ """
584
+ with closing(cursor := self._conn.cursor()):
585
+ self._begin_transaction(cursor)
586
+ # if there is an entry for an orphaned package in the same shared root, then remove it
587
+ if shared_root:
588
+ # EXISTS query is always faster than IN query in sqlite
589
+ cursor.execute("""
590
+ DELETE from packages WHERE name = ? AND EXISTS (
591
+ SELECT 1 FROM containers dc WHERE dc.destroyed = true AND
592
+ dc.shared_root = ? AND packages.container = dc.name
593
+ ) RETURNING container""", (package, shared_root))
594
+ if rows := cursor.fetchall():
595
+ cursor.executemany("DELETE from package_deps WHERE name = ? AND container = ?",
596
+ [(package, row[0]) for row in rows])
597
+ self._clean_destroyed_containers(cursor)
598
+ insert_clause = "INSERT OR IGNORE INTO" if skip_if_exists else "INSERT OR REPLACE INTO"
599
+ cursor.execute(f"{insert_clause} packages VALUES (?, ?, ?, ?, ?)",
600
+ (package, container_name, json.dumps(local_copies), copy_type.value,
601
+ json.dumps(app_flags)))
602
+ if dep_type:
603
+ self._register_dependency(container_name, dep_of, package, dep_type, cursor)
604
+ self._internal_commit()
605
+
606
+ def register_dependency(self, container_name: str, package: str, dependency: str,
607
+ dep_type: DependencyType) -> None:
608
+ """
609
+ Register a package dependency.
610
+
611
+ :param container_name: name of the container
612
+ :param package: the package whose dependency has to be registered
613
+ :param dependency: the dependency to be registered
614
+ :param dep_type: the `DependencyType` of the `dependency`
615
+ """
616
+ with closing(cursor := self._conn.cursor()):
617
+ self._begin_transaction(cursor)
618
+ self._register_dependency(container_name, package, dependency, dep_type, cursor)
619
+ self._internal_commit()
620
+
621
+ @staticmethod
622
+ def _register_dependency(container_name: str, package: str, dependency: str,
623
+ dep_type: DependencyType, cursor: sqlite3.Cursor) -> None:
624
+ """
625
+ Internal method to register a package dependency.
626
+
627
+ :param container_name: name of the container
628
+ :param package: the package whose dependency has to be registered
629
+ :param dependency: the dependency to be registered
630
+ :param dep_type: the `DependencyType` of the `dependency`
631
+ :param cursor: the `Cursor` object to use for execution
632
+ """
633
+ cursor.execute("INSERT OR REPLACE INTO package_deps VALUES (?, ?, ?, ?)",
634
+ (package, container_name, dependency, dep_type.value))
635
+
636
+ def register_repository(self, name: str, container_or_shared_root: str, urls: str, key: str,
637
+ options: str, with_source_repo: bool, update: bool) -> bool:
638
+ """
639
+ Register a new package repository.
640
+
641
+ :param name: name of the package repository to be registered
642
+ :param container_or_shared_root: name of the container where the repository is being
643
+ added or the shared root if container is using one
644
+ :param urls: comma separated server URLs for the repository
645
+ :param key: key used for verifying packages fetched from the repository
646
+ :param options: additional options to be set for the repository (or empty if none)
647
+ :param with_source_repo: True when source code repository has also been enabled
648
+ :param update: if True then update existing entry with the new values
649
+ :return: True if the package repository was successfully registered and False if the
650
+ `name` already exists
651
+ """
652
+ with closing(cursor := self._conn.cursor()):
653
+ self._begin_transaction(cursor)
654
+ try:
655
+ insert_clause = "INSERT OR REPLACE INTO" if update else "INSERT INTO"
656
+ cursor.execute(
657
+ f"{insert_clause} package_repos VALUES (?, ?, ?, ?, ?, ?)",
658
+ (name, container_or_shared_root, urls, key, options, with_source_repo))
659
+ return True
660
+ except sqlite3.IntegrityError:
661
+ return False
662
+ finally:
663
+ self._internal_commit()
664
+
665
+ def unregister_package(self, container_name: str, package: str,
666
+ shared_root: str) -> dict[str, DependencyType]:
667
+ """
668
+ Unregister a package for a given container and return its orphaned dependencies.
669
+
670
+ :param container_name: name of the container
671
+ :param package: the package to be unregistered
672
+ :param shared_root: the local shared root directory if `shared_root` is provided
673
+ for the container
674
+ :return: dictionary of orphaned dependencies having name mapped to `DependencyType`
675
+ """
676
+ with closing(cursor := self._conn.cursor()):
677
+ self._begin_transaction(cursor)
678
+ # Query below determines dependent packages that have been orphaned as follows:
679
+ # 1. select the dependencies of the package in the container/shared root
680
+ # 2. select dependencies of all other packages having either the same shared root
681
+ # OR same container (latter if container is not on a shared root)
682
+ # Select dependencies from 1 that do not exist in 2, i.e. no one else refers to them.
683
+ # An equivalent query can be created using left outer join with null check, but it was
684
+ # tested to be slower. An alternative query can be formed using window function
685
+ # by partitioning on `dependency` column and applying an aggregate like count to
686
+ # filter out those having only this package as the dependent. However, this
687
+ # alternative is much slower probably due to sorting the entire table.
688
+ # For reference, the no shared root query using window function looks like this:
689
+ # SELECT dependency, dep_type FROM (SELECT dependency, dep_type, COUNT()
690
+ # FILTER (WHERE name <> ?) OVER (PARTITION BY dependency)
691
+ # AS dep_counts FROM package_deps WHERE container = ?) WHERE dep_counts = 0
692
+ o_deps_container_query = """
693
+ SELECT 1 FROM package_deps d WHERE d.name <> ? AND d.container = ?"""
694
+ o_deps_shared_root_query = """
695
+ SELECT 1 FROM package_deps d INNER JOIN containers c
696
+ ON (d.container = c.name AND d.name <> ?) WHERE c.shared_root = ?"""
697
+ # EXISTS subquery for the containers with the same shared root as this container
698
+ # which includes any destroyed container entries
699
+ sr_exists = ("SELECT 1 FROM containers c WHERE c.shared_root = ? AND "
700
+ "p.container = c.name")
701
+ pkgs_container_query = "container = ?"
702
+ pkgs_shared_root_query = f"EXISTS ({sr_exists})"
703
+ orphans_query = """
704
+ SELECT dependency, dep_type FROM package_deps p WHERE name = ? AND {pkgs_loc_query}
705
+ AND NOT EXISTS ({o_deps_query} AND p.dependency = d.dependency)"""
706
+ if shared_root:
707
+ cursor.execute(orphans_query.format(pkgs_loc_query=pkgs_shared_root_query,
708
+ o_deps_query=o_deps_shared_root_query),
709
+ (package, shared_root, package, shared_root))
710
+ else:
711
+ cursor.execute(orphans_query.format(pkgs_loc_query=pkgs_container_query,
712
+ o_deps_query=o_deps_container_query),
713
+ (package, container_name, package, container_name))
714
+ orphans = {dep: DependencyType(dep_type) for (dep, dep_type) in cursor.fetchall()}
715
+
716
+ # for the case of common shared root, delete package regardless of the container
717
+ if shared_root:
718
+ # delete from the packages table
719
+ cursor.execute("DELETE FROM packages AS p WHERE name = ? AND EXISTS "
720
+ f"({sr_exists}) RETURNING local_copies", (package, shared_root))
721
+ local_copies = self._extract_local_copies(cursor.fetchall())
722
+ # and from the package_deps table (including dependency entries for the package)
723
+ cursor.execute("DELETE FROM package_deps AS p WHERE "
724
+ f"(name = ? OR dependency = ?) AND EXISTS ({sr_exists})",
725
+ (package, package, shared_root))
726
+ self._clean_destroyed_containers(cursor)
727
+ else:
728
+ # delete from the packages and package_deps tables
729
+ cursor.execute("DELETE FROM packages WHERE name = ? AND container = ? "
730
+ "RETURNING local_copies", (package, container_name))
731
+ local_copies = self._extract_local_copies(cursor.fetchall())
732
+ cursor.execute("DELETE FROM package_deps WHERE (name = ? OR dependency = ?) "
733
+ "AND container = ?", (package, package, container_name))
734
+ self._internal_commit()
735
+ # delete all the files created locally for the container
736
+ self._remove_local_copies(local_copies)
737
+ return orphans
738
+
739
+ def unregister_dependency(self, container_name: str, package: str, dependency: str) -> bool:
740
+ """
741
+ Unregister a dependency of a package (or those matching a pattern) for a given container.
742
+
743
+ :param container_name: name of the container
744
+ :param package: the package or LIKE pattern whose dependency is to be unregistered
745
+ :param dependency: the dependency to be unregistered
746
+ :return: true if the dependency was found and removed and false otherwise
747
+ """
748
+ with closing(cursor := self._conn.cursor()):
749
+ self._begin_transaction(cursor)
750
+ cursor.execute(
751
+ "DELETE FROM package_deps WHERE dependency = ? AND container = ? AND name LIKE ?",
752
+ (dependency, container_name, package))
753
+ result = bool(cursor.rowcount and cursor.rowcount > 0)
754
+ self._internal_commit()
755
+ return result
756
+
757
+ def unregister_repository(self, name: str,
758
+ container_or_shared_root: str) -> Optional[tuple[str, bool]]:
759
+ """
760
+ Unregister a previously registered package repository (using :meth:`register_repository`).
761
+
762
+ :param name: name of the package repository to be unregistered
763
+ :param container_or_shared_root: name of the container where the repository is being
764
+ removed or the shared root if container is using one
765
+ :return: if the package repository was successfully unregistered then return a tuple
766
+ where the first element is the `key` field for the repository as provided during
767
+ registration and the second element is a boolean to indicate whether source code
768
+ repository was enabled during registration, else `None` is returned
769
+ """
770
+ with closing(cursor := self._conn.cursor()):
771
+ self._begin_transaction(cursor)
772
+ cursor.execute("DELETE FROM package_repos WHERE name = ? AND "
773
+ "container_or_shared_root = ? RETURNING key, with_source_repo",
774
+ (name, container_or_shared_root))
775
+ result = cursor.fetchone()
776
+ self._internal_commit()
777
+ return (str(result[0]), bool(result[1])) if result else None
778
+
779
+ @staticmethod
780
+ def _clean_destroyed_containers(cursor: sqlite3.Cursor) -> None:
781
+ """remove destroyed containers if there are no remaining packages for them"""
782
+ cursor.execute("""
783
+ DELETE FROM containers AS dc WHERE destroyed = true AND NOT EXISTS (
784
+ SELECT 1 FROM packages p WHERE dc.name = p.container
785
+ )""")
786
+
787
+ @staticmethod
788
+ def _extract_local_copies(rows: list[str], lc_idx: int = 0) -> list[str]:
789
+ """
790
+ Get a flattened list of local wrapper files from multiple rows having `local_copies`.
791
+
792
+ :param rows: rows from `Cursor.fetchall()` or equivalent with `local_copies`
793
+ :param lc_idx: index of the row having the `local_copies` field
794
+ :return: flattened list of all the local wrapper files from the `local_copies` fields
795
+ """
796
+ # split local_copies field into an array using json then flatten
797
+ return [file for row in rows if row[lc_idx] for file in json.loads(row[lc_idx]) if file]
798
+
799
+ @staticmethod
800
+ def _remove_local_copies(local_copies: list[str]) -> None:
801
+ """remove the files created locally to run container executables"""
802
+ for file in local_copies:
803
+ try:
804
+ os.unlink(file)
805
+ print_warn(f"Removed local wrapper/link {file}")
806
+ except OSError:
807
+ pass
808
+
809
+ def get_packages(self, container_name: str, regex: str = ".*",
810
+ dependency_type: str = ".*") -> list[str]:
811
+ """
812
+ Get the list of registered packages. This can be filtered for a specific container
813
+ and using a (python) regular expression pattern as well as a regex for `DependencyType`.
814
+
815
+ :param container_name: name of the container to filter packages
816
+ :param regex: regular expression pattern to match against package names (optional)
817
+ :param dependency_type: regular expression pattern to match against `dep_type` field if
818
+ the package is a dependency of another package (optional)
819
+ :return: list of registered packages matching the given criteria
820
+ """
821
+ predicate = ""
822
+ args: list[str] = []
823
+ if container_name:
824
+ predicate = "container = ? AND "
825
+ args.append(container_name)
826
+ if regex != ".*":
827
+ predicate += "REGEXP(name, ?) AND "
828
+ args.append(regex)
829
+ if dependency_type == ".*":
830
+ predicate += "1=1"
831
+ elif not dependency_type:
832
+ predicate += ("NOT EXISTS (SELECT 1 FROM package_deps WHERE "
833
+ "packages.container = container AND packages.name = dependency)")
834
+ else:
835
+ predicate += ("EXISTS (SELECT 1 FROM package_deps WHERE REGEXP(dep_type, ?) AND "
836
+ "packages.container = container AND packages.name = dependency)")
837
+ args.append(dependency_type)
838
+ with closing(cursor := self._conn.cursor()):
839
+ cursor.execute(
840
+ f"SELECT DISTINCT(name) FROM packages WHERE {predicate} ORDER BY name ASC", args)
841
+ return [str(row[0]) for row in cursor.fetchall()]
842
+
843
+ def check_packages(self, container_name: str, packages: Iterable[str]) -> list[str]:
844
+ """
845
+ Check if given set of packages are in the state database, and return the list of
846
+ the existing ones.
847
+
848
+ :param container_name: name of the container to filter packages
849
+ :param packages: list of packages to be checked
850
+ :return: list of packages that are recorded in the state database
851
+ """
852
+ if not packages:
853
+ return []
854
+ in_list = ", ".join(["?" for _ in packages])
855
+ args = [container_name]
856
+ for pkg in packages:
857
+ args.append(pkg)
858
+ with closing(cursor := self._conn.cursor()):
859
+ cursor.execute("SELECT name FROM packages pkgs "
860
+ f"WHERE pkgs.container = ? AND pkgs.name IN ({in_list})", args)
861
+ return [str(row[0]) for row in cursor.fetchall()]
862
+
863
+ def get_repositories(self,
864
+ container_or_shared_root: str) -> list[tuple[str, str, str, str, bool]]:
865
+ """
866
+ Get the list of externally registered repositories using :meth:`register_repository`
867
+
868
+ :param container_or_shared_root: if container uses shared root, then the shared root path
869
+ else name of the container to search for repositories
870
+ :return: list of tuples having: name of repository, comma-separated list of server URLs,
871
+ verification key, additional options, and boolean set to True if source code
872
+ repository is enabled
873
+ """
874
+ if not container_or_shared_root:
875
+ return []
876
+ with closing(cursor := self._conn.cursor()):
877
+ cursor.execute("SELECT name, urls, key, options, with_source_repo FROM package_repos "
878
+ "WHERE container_or_shared_root = ? ORDER BY name ASC",
879
+ (container_or_shared_root,))
880
+ return [(row[0], row[1], row[2], row[3], bool(row[4])) for row in cursor.fetchall()]
881
+
882
+ def commit(self) -> None:
883
+ """
884
+ Invoke an explicit COMMIT on the underlying database connection.
885
+
886
+ The recommended usage of this class is using a `with` statement for automatic resource
887
+ management which will automatically commit or rollback any pending transaction and close
888
+ the connection, so this method is normally not required.
889
+ """
890
+ self._conn.commit()
891
+ self._explicit_transaction = False
892
+
893
+ def rollback(self) -> None:
894
+ """
895
+ Invoke an explicit ROLLBACK on the underlying database connection.
896
+
897
+ The recommended usage of this class is using a `with` statement for automatic resource
898
+ management which will automatically commit or rollback any pending transaction and close
899
+ the connection, so this method is normally not required.
900
+ """
901
+ self._conn.rollback()
902
+ self._explicit_transaction = False
903
+
904
+ def __enter__(self):
905
+ return self
906
+
907
+ def __exit__(self, ex_type, ex_value, ex_traceback): # type: ignore
908
+ try:
909
+ if ex_type:
910
+ self.rollback()
911
+ else:
912
+ self.commit()
913
+ finally:
914
+ self._conn.close()