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.
- ybox/__init__.py +2 -0
- ybox/cmd.py +307 -0
- ybox/conf/completions/ybox.fish +93 -0
- ybox/conf/distros/arch/add-gpg-key.sh +29 -0
- ybox/conf/distros/arch/distro.ini +192 -0
- ybox/conf/distros/arch/init-base.sh +10 -0
- ybox/conf/distros/arch/init-user.sh +35 -0
- ybox/conf/distros/arch/init.sh +82 -0
- ybox/conf/distros/arch/list_fmt_long.py +76 -0
- ybox/conf/distros/arch/pkgdeps.py +276 -0
- ybox/conf/distros/deb-generic/check-package.sh +77 -0
- ybox/conf/distros/deb-generic/distro.ini +190 -0
- ybox/conf/distros/deb-generic/fetch-gpg-key-id.sh +30 -0
- ybox/conf/distros/deb-generic/init-base.sh +11 -0
- ybox/conf/distros/deb-generic/init-user.sh +3 -0
- ybox/conf/distros/deb-generic/init.sh +136 -0
- ybox/conf/distros/deb-generic/list_fmt_long.py +114 -0
- ybox/conf/distros/deb-generic/pkgdeps.py +208 -0
- ybox/conf/distros/deb-oldstable/distro.ini +21 -0
- ybox/conf/distros/deb-stable/distro.ini +21 -0
- ybox/conf/distros/supported.list +5 -0
- ybox/conf/distros/ubuntu2204/distro.ini +21 -0
- ybox/conf/distros/ubuntu2404/distro.ini +21 -0
- ybox/conf/profiles/apps.ini +26 -0
- ybox/conf/profiles/basic.ini +310 -0
- ybox/conf/profiles/dev.ini +25 -0
- ybox/conf/profiles/games.ini +39 -0
- ybox/conf/resources/entrypoint-base.sh +170 -0
- ybox/conf/resources/entrypoint-common.sh +23 -0
- ybox/conf/resources/entrypoint-cp.sh +32 -0
- ybox/conf/resources/entrypoint-root.sh +20 -0
- ybox/conf/resources/entrypoint-user.sh +21 -0
- ybox/conf/resources/entrypoint.sh +249 -0
- ybox/conf/resources/prime-run +13 -0
- ybox/conf/resources/run-in-dir +60 -0
- ybox/conf/resources/run-user-bash-cmd +14 -0
- ybox/config.py +255 -0
- ybox/env.py +205 -0
- ybox/filelock.py +77 -0
- ybox/migrate/0.9.0-0.9.7:0.9.8.py +33 -0
- ybox/pkg/__init__.py +0 -0
- ybox/pkg/clean.py +33 -0
- ybox/pkg/info.py +40 -0
- ybox/pkg/inst.py +638 -0
- ybox/pkg/list.py +191 -0
- ybox/pkg/mark.py +68 -0
- ybox/pkg/repair.py +150 -0
- ybox/pkg/repo.py +251 -0
- ybox/pkg/search.py +52 -0
- ybox/pkg/uninst.py +92 -0
- ybox/pkg/update.py +56 -0
- ybox/print.py +121 -0
- ybox/run/__init__.py +0 -0
- ybox/run/cmd.py +54 -0
- ybox/run/control.py +102 -0
- ybox/run/create.py +1116 -0
- ybox/run/destroy.py +64 -0
- ybox/run/graphics.py +367 -0
- ybox/run/logs.py +57 -0
- ybox/run/ls.py +64 -0
- ybox/run/pkg.py +445 -0
- ybox/schema/0.9.1-added.sql +27 -0
- ybox/schema/0.9.6-added.sql +18 -0
- ybox/schema/init.sql +39 -0
- ybox/schema/migrate/0.9.0:0.9.1.sql +42 -0
- ybox/schema/migrate/0.9.1:0.9.2.sql +8 -0
- ybox/schema/migrate/0.9.2:0.9.3.sql +2 -0
- ybox/schema/migrate/0.9.5:0.9.6.sql +2 -0
- ybox/state.py +914 -0
- ybox/util.py +351 -0
- ybox-0.9.8.dist-info/LICENSE +19 -0
- ybox-0.9.8.dist-info/METADATA +533 -0
- ybox-0.9.8.dist-info/RECORD +76 -0
- ybox-0.9.8.dist-info/WHEEL +5 -0
- ybox-0.9.8.dist-info/entry_points.txt +8 -0
- 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()
|