pyrixos-lattice 0.9.0__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 (51) hide show
  1. lattice/__init__.py +166 -0
  2. lattice/api.py +453 -0
  3. lattice/cache/__init__.py +8 -0
  4. lattice/cache/archive.py +194 -0
  5. lattice/cache/base.py +77 -0
  6. lattice/cache/cvmfs.py +100 -0
  7. lattice/cache/http.py +143 -0
  8. lattice/cache/metadata.py +147 -0
  9. lattice/cache/provenance.py +190 -0
  10. lattice/cache/push.py +200 -0
  11. lattice/cache/signing.py +107 -0
  12. lattice/cache/substitute.py +341 -0
  13. lattice/cli.py +568 -0
  14. lattice/client.py +438 -0
  15. lattice/derivation.py +280 -0
  16. lattice/errors.py +76 -0
  17. lattice/events.py +194 -0
  18. lattice/kmod/__init__.py +29 -0
  19. lattice/kmod/modinfo.py +168 -0
  20. lattice/kmod/syscalls.py +51 -0
  21. lattice/kmod/system.py +113 -0
  22. lattice/models.py +142 -0
  23. lattice/procscan.py +118 -0
  24. lattice/profiles/__init__.py +42 -0
  25. lattice/profiles/activation.py +254 -0
  26. lattice/profiles/generations.py +138 -0
  27. lattice/profiles/slots.py +64 -0
  28. lattice/protocol.py +256 -0
  29. lattice/py.typed +0 -0
  30. lattice/sandbox.py +281 -0
  31. lattice/server.py +268 -0
  32. lattice/settings.py +337 -0
  33. lattice/stdlib.py +181 -0
  34. lattice/store/__init__.py +36 -0
  35. lattice/store/builder.py +500 -0
  36. lattice/store/combine.py +199 -0
  37. lattice/store/database.py +429 -0
  38. lattice/store/fetch.py +97 -0
  39. lattice/store/gc.py +118 -0
  40. lattice/store/layout.py +123 -0
  41. lattice/store/lock.py +60 -0
  42. lattice/store/realise.py +172 -0
  43. lattice/store/references.py +70 -0
  44. lattice/store/verify.py +143 -0
  45. lattice/transport.py +412 -0
  46. pyrixos_lattice-0.9.0.dist-info/METADATA +357 -0
  47. pyrixos_lattice-0.9.0.dist-info/RECORD +51 -0
  48. pyrixos_lattice-0.9.0.dist-info/WHEEL +5 -0
  49. pyrixos_lattice-0.9.0.dist-info/entry_points.txt +2 -0
  50. pyrixos_lattice-0.9.0.dist-info/licenses/LICENSE +339 -0
  51. pyrixos_lattice-0.9.0.dist-info/top_level.txt +1 -0
lattice/__init__.py ADDED
@@ -0,0 +1,166 @@
1
+ # SPDX-License-Identifier: GPL-2.0
2
+ """Lattice, the PyrixOS package manager.
3
+
4
+ Lattice evaluates PyBonsai files with the ``pybonsai`` package, passing
5
+ its own standard library as the host (RFC 0001 sections 7.6 and 8).
6
+
7
+ Programs use Lattice through the names exported here (RFC 0001 sections
8
+ 6.1 and 6.2). Lattice runs operations in the calling process, Client sends
9
+ them to ``lattice daemon`` over its Unix socket or the remote API, and
10
+ Daemon is the daemon itself. Lattice and Client both satisfy LatticeAPI.
11
+ The ``lattice`` command is a thin wrapper around them::
12
+
13
+ from lattice import Client, Lattice
14
+
15
+ result = Lattice().evaluate("hello.pyb")
16
+ result = Client().evaluate("hello.pyb")
17
+
18
+ Names in ``__all__`` follow semantic versioning from release 1.0. A name
19
+ is removed only after one minor release in which using it raises a
20
+ DeprecationWarning. Modules and names not listed here are internal.
21
+
22
+ Phase 1 provides derivations, the standard library and evaluation,
23
+ Phase 2 the build sandbox (``lattice.sandbox``, built on the Cloche
24
+ package), Phase 3 the store, the dependency database and the garbage
25
+ collector (``lattice.store``), and Phase 4 profiles, slots and kernel
26
+ modules (``lattice.profiles`` and ``lattice.kmod``). Phase 5 adds binary
27
+ caches.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from .api import Lattice
33
+ from .client import DEFAULT_URL, Client, LatticeAPI
34
+ from .derivation import Derivation
35
+ from .errors import (
36
+ ActivationFailed,
37
+ AuthenticationFailed,
38
+ BackendUnavailable,
39
+ BuildFailed,
40
+ EvaluationFailed,
41
+ LatticeError,
42
+ PermissionDenied,
43
+ ProtocolError,
44
+ SettingsError,
45
+ StoreError,
46
+ SubstitutionFailed,
47
+ )
48
+ from .events import (
49
+ ActivationDeferred,
50
+ BuildFinished,
51
+ BuildLog,
52
+ BuildStarted,
53
+ CacheSkipped,
54
+ CollectionFinished,
55
+ EvaluationFinished,
56
+ EvaluationStarted,
57
+ Event,
58
+ EventHandler,
59
+ KernelModuleLoaded,
60
+ KernelModuleUnloaded,
61
+ PathPushed,
62
+ ProfileSwitched,
63
+ SlotChanged,
64
+ SubstitutionFinished,
65
+ SubstitutionStarted,
66
+ )
67
+ from .kmod import KernelModules, LinuxModules, LoadedModule, ModuleInfo
68
+ from .models import (
69
+ BuildResult,
70
+ Evaluation,
71
+ GCResult,
72
+ Generation,
73
+ Identity,
74
+ PushResult,
75
+ Role,
76
+ SlotState,
77
+ SubstitutionResult,
78
+ )
79
+ from .sandbox import BackendStatus, OciConfig
80
+ from .settings import (
81
+ DEFAULT_SOCKET,
82
+ SANDBOX_BACKENDS,
83
+ CacheSettings,
84
+ ClientGrant,
85
+ DaemonSettings,
86
+ MicroVMSettings,
87
+ SandboxBackend,
88
+ SandboxSettings,
89
+ Settings,
90
+ ShallowRules,
91
+ StoreSettings,
92
+ TokenGrant,
93
+ )
94
+ from .store import BuildPlan, GCRootInfo, PlannedStep, StorePathInfo, VerifyResult
95
+ from .transport import Daemon
96
+
97
+ __version__ = "0.9.0"
98
+
99
+ __all__ = [
100
+ "ActivationDeferred",
101
+ "ActivationFailed",
102
+ "KernelModuleLoaded",
103
+ "KernelModuleUnloaded",
104
+ "KernelModules",
105
+ "LinuxModules",
106
+ "LoadedModule",
107
+ "ModuleInfo",
108
+ "ProfileSwitched",
109
+ "SlotChanged",
110
+ "SlotState",
111
+ "VerifyResult",
112
+ "StoreSettings",
113
+ "StorePathInfo",
114
+ "PlannedStep",
115
+ "GCRootInfo",
116
+ "CollectionFinished",
117
+ "BuildStarted",
118
+ "BuildPlan",
119
+ "BuildLog",
120
+ "BuildFinished",
121
+ "DEFAULT_SOCKET",
122
+ "DEFAULT_URL",
123
+ "SANDBOX_BACKENDS",
124
+ "AuthenticationFailed",
125
+ "BackendStatus",
126
+ "BackendUnavailable",
127
+ "BuildFailed",
128
+ "BuildResult",
129
+ "Client",
130
+ "ClientGrant",
131
+ "Daemon",
132
+ "DaemonSettings",
133
+ "Derivation",
134
+ "Evaluation",
135
+ "EvaluationFailed",
136
+ "EvaluationFinished",
137
+ "EvaluationStarted",
138
+ "Event",
139
+ "EventHandler",
140
+ "GCResult",
141
+ "Generation",
142
+ "Identity",
143
+ "Lattice",
144
+ "LatticeAPI",
145
+ "LatticeError",
146
+ "MicroVMSettings",
147
+ "OciConfig",
148
+ "PermissionDenied",
149
+ "ProtocolError",
150
+ "Role",
151
+ "SandboxBackend",
152
+ "SandboxSettings",
153
+ "Settings",
154
+ "SettingsError",
155
+ "StoreError",
156
+ "SubstitutionFailed",
157
+ "TokenGrant",
158
+ "CacheSettings",
159
+ "CacheSkipped",
160
+ "PathPushed",
161
+ "PushResult",
162
+ "ShallowRules",
163
+ "SubstitutionFinished",
164
+ "SubstitutionResult",
165
+ "SubstitutionStarted",
166
+ ]
lattice/api.py ADDED
@@ -0,0 +1,453 @@
1
+ # SPDX-License-Identifier: GPL-2.0
2
+ """The Lattice library interface (RFC 0001 section 6.1).
3
+
4
+ Lattice is the class a program creates to use the package manager from
5
+ Python. The ``lattice`` command is a thin wrapper around it, so anything
6
+ the command does, a program can do too. A Lattice object holds only its
7
+ settings and event handler; it keeps no state between calls and reads no
8
+ settings from the machine on its own.
9
+
10
+ Methods return immutable result models (models.py) and raise LatticeError
11
+ subclasses. Client (client.py) has the same methods and talks to the
12
+ daemon instead; both satisfy the LatticeAPI protocol. Each method states
13
+ the phase of the development plan that implemented it.
14
+
15
+ Example::
16
+
17
+ from lattice import Lattice, Settings
18
+
19
+ lattice = Lattice(Settings(sandbox_backend="namespaces"))
20
+ result = lattice.evaluate("hello.pyb")
21
+ for name, drv in result.derivations.items():
22
+ print(name, drv.store_path)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import sqlite3
29
+ import time
30
+ from collections.abc import Iterable
31
+ from platform import machine
32
+ from typing import TYPE_CHECKING
33
+
34
+ import pybonsai
35
+ from pybonsai import FrozenMap, Host, PyBonsaiError
36
+
37
+ from . import profiles
38
+ from .derivation import BUILTIN_PROFILE, Derivation
39
+ from .errors import ActivationFailed, EvaluationFailed, StoreError
40
+ from .events import EvaluationFinished, EvaluationStarted, EventHandler, ProfileSwitched, emit
41
+ from .kmod import KernelModules, LinuxModules
42
+ from .models import (
43
+ BuildResult,
44
+ Evaluation,
45
+ GCResult,
46
+ Generation,
47
+ PushResult,
48
+ SlotState,
49
+ SubstitutionResult,
50
+ )
51
+ from .sandbox import BackendStatus, OciConfig, make_spec, oci_config
52
+ from .sandbox import backend_status as probe_backends
53
+ from .settings import SandboxBackend, Settings
54
+ from .stdlib import build_host
55
+ from .store import (
56
+ Builder,
57
+ BuildPlan,
58
+ GCRootInfo,
59
+ StoreLayout,
60
+ StorePathInfo,
61
+ VerifyResult,
62
+ check_store_path,
63
+ collect,
64
+ content_hash,
65
+ database,
66
+ lock,
67
+ )
68
+ from .store.builder import direct_inputs
69
+ from .store.combine import profile_manifest
70
+
71
+ if TYPE_CHECKING:
72
+ from .sandbox import Backend
73
+
74
+
75
+ class Lattice:
76
+ """Entry point for using Lattice from Python.
77
+
78
+ ``settings`` defaults to Settings(). ``on_event`` receives an Event for
79
+ each step of an operation (see events.py). ``backend`` is the sandbox
80
+ backend for builds; None chooses the one the settings name when the
81
+ first build needs it. ``kernel_modules`` loads and unloads the kernel
82
+ modules of stacks; None uses the running Linux kernel.
83
+
84
+ The store under ``settings.store.root`` is created by the first
85
+ operation that writes to it. Operations that only read it raise
86
+ StoreError while it does not exist.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ settings: Settings | None = None,
92
+ *,
93
+ on_event: EventHandler | None = None,
94
+ backend: Backend | None = None,
95
+ kernel_modules: KernelModules | None = None,
96
+ ) -> None:
97
+ """Store the settings, event handler and backends. Nothing is read or created."""
98
+ self.settings = settings if settings is not None else Settings()
99
+ self.on_event = on_event
100
+ self.backend = backend
101
+ self.kernel_modules = kernel_modules if kernel_modules is not None else LinuxModules()
102
+ self.layout = StoreLayout(root=self.settings.store.root, stacks=self.settings.store.stacks)
103
+ self._host: Host | None = None
104
+
105
+ def _get_host(self) -> Host:
106
+ if self._host is None:
107
+ self._host = build_host()
108
+ return self._host
109
+
110
+ def evaluate(self, path: str | os.PathLike[str]) -> Evaluation:
111
+ """Evaluate the PyBonsai file at ``path`` with the standard library. Phase 1.
112
+
113
+ Nothing is built. Raises EvaluationFailed if the file is rejected or
114
+ fails, and OSError if it cannot be read.
115
+ """
116
+ filename = os.fspath(path)
117
+ emit(self.on_event, EvaluationStarted(filename=filename))
118
+ start = time.perf_counter()
119
+ try:
120
+ names = pybonsai.evaluate_file(path, host=self._get_host())
121
+ except PyBonsaiError as error:
122
+ raise EvaluationFailed(error) from error
123
+ return self._finish(filename, names, start)
124
+
125
+ def evaluate_source(self, source: str, filename: str = "<string>") -> Evaluation:
126
+ """Evaluate PyBonsai source text, as evaluate does for a file. Phase 1."""
127
+ emit(self.on_event, EvaluationStarted(filename=filename))
128
+ start = time.perf_counter()
129
+ try:
130
+ names = pybonsai.evaluate_source(source, filename, host=self._get_host())
131
+ except PyBonsaiError as error:
132
+ raise EvaluationFailed(error) from error
133
+ return self._finish(filename, names, start)
134
+
135
+ def _finish(self, filename: str, names: FrozenMap, start: float) -> Evaluation:
136
+ derivations = {str(k): v for k, v in names.items() if isinstance(v, Derivation)}
137
+ result = Evaluation(
138
+ filename=filename, derivations=derivations, seconds=time.perf_counter() - start
139
+ )
140
+ emit(
141
+ self.on_event,
142
+ EvaluationFinished(
143
+ filename=filename, derivations=len(derivations), seconds=result.seconds
144
+ ),
145
+ )
146
+ return result
147
+
148
+ def _builder(self) -> Builder:
149
+ return Builder(self.layout, self.settings, backend=self.backend, on_event=self.on_event)
150
+
151
+ def build(self, drv: Derivation) -> BuildResult:
152
+ """Realise ``drv`` and its inputs and register the outputs. Phase 3.
153
+
154
+ Outputs already registered are reused, and from Phase 5 paths in
155
+ a trusted cache are substituted. The outputs are not made roots,
156
+ so the next collection removes them unless add_root keeps them.
157
+ Raises BuildFailed, BackendUnavailable or StoreError.
158
+ """
159
+ return self._builder().build(drv)
160
+
161
+ def check(self, drv: Derivation, backend: SandboxBackend | None = None) -> BuildResult:
162
+ """Rebuild the registered outputs of ``drv`` and compare them (section 16). Phase 3.
163
+
164
+ ``backend`` rebuilds on that sandbox backend instead of the
165
+ configured one. The rebuilt outputs are thrown away. Raises
166
+ StoreError if ``drv`` is not built and BuildFailed, naming the
167
+ files that differ, if the outputs are not the same.
168
+ """
169
+ return self._builder().check(drv, backend)
170
+
171
+ def plan(self, drv: Derivation) -> BuildPlan:
172
+ """List what building ``drv`` would download, build or reuse. Phase 3."""
173
+ return self._builder().plan(drv)
174
+
175
+ def _connect(self) -> sqlite3.Connection:
176
+ return database.connect(self.layout)
177
+
178
+ def path_info(self, path: str) -> StorePathInfo:
179
+ """Return what the store database records about ``path``. Phase 3.
180
+
181
+ Raises StoreError if ``path`` is not a registered store path.
182
+ """
183
+ check_store_path(path)
184
+ connection = self._connect()
185
+ try:
186
+ info = database.path_info(connection, path)
187
+ finally:
188
+ connection.close()
189
+ if info is None:
190
+ raise StoreError(f"{path} is not registered")
191
+ return info
192
+
193
+ def verify(self, path: str) -> VerifyResult:
194
+ """Hash the contents of ``path`` again and compare with the database (section 11).
195
+
196
+ Phase 3. A shallow path is read through its link, which fetches
197
+ every file from CernVM-FS (section 14.6). Raises StoreError if
198
+ ``path`` is not registered or its files are missing.
199
+ """
200
+ self.path_info(path)
201
+ with lock.shared(self.layout):
202
+ info = self.path_info(path)
203
+ physical = self.layout.physical(path)
204
+ if info.location == "shallow" and os.path.islink(physical):
205
+ physical = os.readlink(physical)
206
+ try:
207
+ digest, size = content_hash(physical)
208
+ except FileNotFoundError:
209
+ raise StoreError(f"{path} is registered but its files are missing") from None
210
+ return VerifyResult(
211
+ path=path,
212
+ ok=digest == info.content_sha256 and size == info.size,
213
+ expected_sha256=info.content_sha256,
214
+ actual_sha256=digest,
215
+ expected_size=info.size,
216
+ actual_size=size,
217
+ )
218
+
219
+ def sandbox_config(self, drv: Derivation) -> OciConfig:
220
+ """Describe the sandbox that would build ``drv`` as an OCI runtime configuration.
221
+
222
+ Uses the sandbox settings and this process's uid and gid; nothing
223
+ runs. When the inputs of ``drv`` are registered, the sandbox has
224
+ them and the paths they refer to, as a build would; otherwise it
225
+ has the outputs of every derivation ``drv`` depends on. Raises
226
+ BuildFailed if ``drv`` cannot be built in the sandbox and
227
+ BackendUnavailable if the machine is not supported. Phase 2.
228
+ """
229
+ spec = make_spec(
230
+ drv,
231
+ self.settings.sandbox,
232
+ uid=os.getuid(),
233
+ gid=os.getgid(),
234
+ machine=machine(),
235
+ inputs=self._registered_inputs(drv),
236
+ )
237
+ return oci_config(spec)
238
+
239
+ def _registered_inputs(self, drv: Derivation) -> tuple[str, ...] | None:
240
+ if not self.layout.exists():
241
+ return None
242
+ direct = direct_inputs(drv)
243
+ connection = self._connect()
244
+ try:
245
+ found = database.closure(connection, direct)
246
+ finally:
247
+ connection.close()
248
+ return tuple(sorted(found)) if set(direct) <= found else None
249
+
250
+ def backend_status(self) -> tuple[BackendStatus, ...]:
251
+ """Report which sandbox backends can run on this machine (section 9.7). Phase 2."""
252
+ return probe_backends(self.settings.sandbox, self.layout.store)
253
+
254
+ def gc(self) -> GCResult:
255
+ """Remove every store path no root or running process keeps (section 12). Phase 3."""
256
+ return collect(self.layout, self.on_event)
257
+
258
+ def add_root(self, name: str, path: str) -> GCRootInfo:
259
+ """Keep ``path`` and everything it refers to under the root ``name``. Phase 3.
260
+
261
+ A root of the same name is replaced. Raises StoreError if the name
262
+ is not valid or ``path`` is not registered.
263
+ """
264
+ check_store_path(path)
265
+ connection = database.connect(self.layout, create=True)
266
+ try:
267
+ # Shared, so that a collection that has already marked path dead cannot be running.
268
+ with lock.shared(self.layout):
269
+ return database.add_root(connection, name, path)
270
+ finally:
271
+ connection.close()
272
+
273
+ def remove_root(self, name: str) -> GCRootInfo:
274
+ """Remove the root ``name`` and return it. Phase 3.
275
+
276
+ Raises StoreError if there is no such root.
277
+ """
278
+ connection = self._connect()
279
+ try:
280
+ return database.remove_root(connection, name)
281
+ finally:
282
+ connection.close()
283
+
284
+ def roots(self) -> tuple[GCRootInfo, ...]:
285
+ """Return every root, sorted by name. Phase 3."""
286
+ if not self.layout.exists():
287
+ return ()
288
+ connection = self._connect()
289
+ try:
290
+ return database.roots(connection)
291
+ finally:
292
+ connection.close()
293
+
294
+ def switch(self, profile: str, drv: Derivation) -> Generation:
295
+ """Build ``drv`` and make it the current generation of ``profile``. Phase 4.
296
+
297
+ The new generation gets the next number and a GC root. For the
298
+ ``system`` profile the slots then change to the stacks it assigns
299
+ (section 13.3); if they cannot change now, an ActivationDeferred
300
+ event says why and they change at the next boot. Raises
301
+ ActivationFailed if a kernel module step fails, leaving the
302
+ previous generation current, and if a profile other than
303
+ ``system`` assigns stacks.
304
+ """
305
+ profiles.check_profile_name(profile)
306
+ if profile != profiles.SYSTEM and drv.builder == BUILTIN_PROFILE:
307
+ if profile_manifest(drv).stacks:
308
+ raise ActivationFailed(f"only the {profiles.SYSTEM} profile may assign stacks")
309
+ connection = database.connect(self.layout, create=True)
310
+ try:
311
+ # Shared from the build until the root exists, so collection cannot remove the output.
312
+ with lock.shared(self.layout):
313
+ path = self.build(drv).store_path
314
+ with lock.profiles(self.layout):
315
+ profiles.prepare(self.layout, profile)
316
+ number = max(profiles.numbers(self.layout, profile), default=0) + 1
317
+ database.add_root(connection, profiles.root_name(profile, number), path)
318
+ profiles.replace_link(profiles.link_path(self.layout, profile, number), path)
319
+ return self._make_current(profile, number, path)
320
+ finally:
321
+ connection.close()
322
+
323
+ def _make_current(self, profile: str, number: int, path: str) -> Generation:
324
+ """Activate generation ``number`` if it is ``system``'s, then point the profile at it."""
325
+ stacks = profiles.wanted(self.layout, path)
326
+ if profile == profiles.SYSTEM:
327
+ profiles.activate(self.layout, self.kernel_modules, stacks, self.on_event)
328
+ elif stacks:
329
+ raise ActivationFailed(f"only the {profiles.SYSTEM} profile may assign stacks")
330
+ generation_link = profiles.link_path(self.layout, profile, number)
331
+ profiles.replace_link(
332
+ profiles.link_path(self.layout, profile), os.path.basename(generation_link)
333
+ )
334
+ emit(self.on_event, ProfileSwitched(profile=profile, number=number, store_path=path))
335
+ return Generation(profile=profile, number=number, store_path=path, current=True)
336
+
337
+ def rollback(self, profile: str, generation: int | None = None) -> Generation:
338
+ """Make an existing generation of ``profile`` current again. Phase 4.
339
+
340
+ ``generation`` defaults to the newest one older than the current
341
+ generation. Activation is as for switch. Raises StoreError if the
342
+ generation does not exist.
343
+ """
344
+ profiles.check_profile_name(profile)
345
+ if not self.layout.exists():
346
+ raise StoreError(f"no store at {self.layout.root}")
347
+ with lock.profiles(self.layout):
348
+ if generation is None:
349
+ now = profiles.current(self.layout, profile)
350
+ earlier = [
351
+ n for n in profiles.numbers(self.layout, profile) if now is None or n < now
352
+ ]
353
+ if not earlier:
354
+ raise StoreError(f"profile {profile!r} has no earlier generation")
355
+ generation = earlier[-1]
356
+ path = profiles.store_path(self.layout, profile, generation)
357
+ return self._make_current(profile, generation, path)
358
+
359
+ def generations(self, profile: str) -> tuple[Generation, ...]:
360
+ """Return every generation of ``profile``, oldest first. Phase 4."""
361
+ profiles.check_profile_name(profile)
362
+ return profiles.generations(self.layout, profile)
363
+
364
+ def delete_generations(self, profile: str, keep: int) -> tuple[Generation, ...]:
365
+ """Delete all but the newest ``keep`` generations and the current one. Phase 4.
366
+
367
+ Their roots are removed, so the next collection can reclaim what
368
+ only they used. Returns the deleted generations.
369
+ """
370
+ profiles.check_profile_name(profile)
371
+ if keep < 0:
372
+ raise StoreError("keep must not be negative")
373
+ if not self.layout.exists():
374
+ return ()
375
+ connection = self._connect()
376
+ try:
377
+ with lock.profiles(self.layout):
378
+ existing = profiles.generations(self.layout, profile)
379
+ kept = (
380
+ {g.number for g in existing[max(len(existing) - keep, 0) :]} if keep else set()
381
+ )
382
+ deleted = tuple(g for g in existing if g.number not in kept and not g.current)
383
+ for generation in deleted:
384
+ os.unlink(profiles.link_path(self.layout, profile, generation.number))
385
+ try:
386
+ database.remove_root(
387
+ connection, profiles.root_name(profile, generation.number)
388
+ )
389
+ except StoreError:
390
+ pass
391
+ return deleted
392
+ finally:
393
+ connection.close()
394
+
395
+ def slots(self) -> tuple[SlotState, ...]:
396
+ """Return every slot that is active or that the current system generation wants. Phase 4."""
397
+ return profiles.states(self.layout)
398
+
399
+ def activate_boot(self) -> tuple[SlotState, ...]:
400
+ """Load the kernel modules and create the slots of the current system generation. Phase 4.
401
+
402
+ Runs once at boot, before any service starts. Library and command
403
+ only; the daemon does not offer it. Raises ActivationFailed after
404
+ setting every slot if a module did not load.
405
+ """
406
+ if self.layout.exists():
407
+ with lock.profiles(self.layout):
408
+ wanted = profiles.wanted(self.layout, profiles.system_environment(self.layout))
409
+ profiles.boot(self.layout, self.kernel_modules, wanted, self.on_event)
410
+ else:
411
+ profiles.boot(self.layout, self.kernel_modules, {}, self.on_event)
412
+ return profiles.states(self.layout)
413
+
414
+ def substitute(self, path: str, shallow: bool = False) -> SubstitutionResult:
415
+ """Fetch ``path`` and its missing references from the caches (section 14.1). Phase 5.
416
+
417
+ If ``shallow`` and the cache is a CernVM-FS repository, ``path``
418
+ becomes a link into it; its references are full copies. Raises
419
+ SubstitutionFailed if no trusted cache has a valid entry for a
420
+ path or a fetched path does not match its signed entry.
421
+ """
422
+ from .cache.substitute import substitute
423
+
424
+ check_store_path(path)
425
+ return substitute(self.layout, self.settings, path, shallow=shallow, on_event=self.on_event)
426
+
427
+ def materialise(self, path: str) -> StorePathInfo:
428
+ """Turn the shallow path ``path`` into a full copy (section 14.6). Phase 5.
429
+
430
+ A process that opens ``path`` while its link is replaced gets
431
+ ENOENT, so materialise a stack while its slot is not active.
432
+ Raises StoreError if ``path`` is not registered or its copy does
433
+ not match the registered hash.
434
+ """
435
+ from .cache.substitute import materialise
436
+
437
+ check_store_path(path)
438
+ return materialise(self.layout, path)
439
+
440
+ def push(self, paths: Iterable[str], cache: str) -> PushResult:
441
+ """Sign and publish store paths, their closures and provenance to ``cache``. Phase 5.
442
+
443
+ Paths the cache already has are skipped. Raises SettingsError if
444
+ no secret key is set, StoreError if a path was not built on this
445
+ machine or has changed since it was registered, and
446
+ SubstitutionFailed if the cache cannot be written (section 14.4).
447
+ """
448
+ from .cache.push import push
449
+
450
+ paths = tuple(paths)
451
+ for path in paths:
452
+ check_store_path(path)
453
+ return push(self.layout, self.settings, paths, cache, on_event=self.on_event)
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: GPL-2.0
2
+ """Binary caches: signed archives of store paths (RFC 0001 section 14).
3
+
4
+ archive.py makes and unpacks deterministic archives, signing.py holds the
5
+ Ed25519 keys, metadata.py the files a cache serves, provenance.py the
6
+ signed build records, http.py and cvmfs.py the two kinds of cache,
7
+ substitute.py fetches paths from caches and push.py uploads them.
8
+ """