|
4 | 4 |
|
5 | 5 | from __future__ import annotations |
6 | 6 |
|
| 7 | +import io |
7 | 8 | import re |
8 | 9 | from datetime import datetime, timedelta, timezone |
9 | 10 | from urllib.parse import urlparse |
10 | 11 |
|
11 | 12 | from pyinfra import host |
12 | | -from pyinfra.api import operation |
| 13 | +from pyinfra.api import OperationError, operation |
13 | 14 | from pyinfra.facts.apt import ( |
| 15 | + AptSourcesFile, |
14 | 16 | AptSources, |
15 | 17 | SimulateOperationWillChange, |
16 | 18 | noninteractive_apt, |
@@ -243,6 +245,121 @@ def repo(src: str, present=True, filename: str | None = None): |
243 | 245 | ) |
244 | 246 |
|
245 | 247 |
|
| 248 | +@operation() |
| 249 | +def sources_file( |
| 250 | + filename: str, |
| 251 | + types: list[str] | str = "deb", |
| 252 | + uris: list[str] | str = "", |
| 253 | + suites: list[str] | str = "", |
| 254 | + components: list[str] | str | None = None, |
| 255 | + architectures: list[str] | str | None = None, |
| 256 | + signed_by: str | None = None, |
| 257 | + present: bool = True, |
| 258 | +): |
| 259 | + """ |
| 260 | + Manage a deb822 ``.sources`` file under ``/etc/apt/sources.list.d/``. |
| 261 | +
|
| 262 | + Creates or removes a modern deb822-format sources file. Each field that |
| 263 | + accepts multiple values can be given as a list or a space-separated string. |
| 264 | +
|
| 265 | + + filename: base name for the file (without extension); the ``.sources`` |
| 266 | + extension is added automatically |
| 267 | + + types: repository type(s) — ``"deb"``, ``"deb-src"``, or both |
| 268 | + + uris: one or more repository URLs |
| 269 | + + suites: one or more suite/distribution names (e.g. ``"bookworm"``) |
| 270 | + + components: one or more components (e.g. ``["main", "contrib"]``) |
| 271 | + + architectures: restrict to specific architectures (e.g. ``"amd64"``) |
| 272 | + + signed_by: absolute path to the keyring file used for signature verification |
| 273 | + + present: whether the sources file should exist |
| 274 | +
|
| 275 | + **Example:** |
| 276 | +
|
| 277 | + .. code:: python |
| 278 | +
|
| 279 | + from pyinfra.operations import apt |
| 280 | +
|
| 281 | + apt.key( |
| 282 | + name="Add Docker GPG key", |
| 283 | + src="https://download.docker.com/linux/debian/gpg", |
| 284 | + dest="docker.gpg", |
| 285 | + ) |
| 286 | +
|
| 287 | + apt.sources_file( |
| 288 | + name="Add Docker apt repository (deb822)", |
| 289 | + filename="docker", |
| 290 | + types=["deb"], |
| 291 | + uris=["https://download.docker.com/linux/debian"], |
| 292 | + suites=["bookworm"], |
| 293 | + components=["stable"], |
| 294 | + architectures=["amd64"], |
| 295 | + signed_by="/etc/apt/keyrings/docker.gpg", |
| 296 | + ) |
| 297 | + """ |
| 298 | + dest = "/etc/apt/sources.list.d/{0}.sources".format(filename) |
| 299 | + |
| 300 | + # Removal path — just delete the file |
| 301 | + if not present: |
| 302 | + info = host.get_fact(File, path=dest) |
| 303 | + if info: |
| 304 | + yield from files.file._inner(path=dest, present=False) |
| 305 | + else: |
| 306 | + host.noop('apt sources file "{0}" does not exist'.format(dest)) |
| 307 | + return |
| 308 | + |
| 309 | + # Normalise list arguments |
| 310 | + def _as_list(val) -> list[str]: |
| 311 | + if val is None: |
| 312 | + return [] |
| 313 | + if isinstance(val, str): |
| 314 | + return val.split() |
| 315 | + return list(val) |
| 316 | + |
| 317 | + types_list = _as_list(types) or ["deb"] |
| 318 | + uris_list = _as_list(uris) |
| 319 | + suites_list = _as_list(suites) |
| 320 | + components_list = _as_list(components) |
| 321 | + architectures_list = _as_list(architectures) |
| 322 | + |
| 323 | + if not uris_list or not suites_list: |
| 324 | + raise OperationError("apt.sources_file requires at least one URI and one suite") |
| 325 | + |
| 326 | + # Build the AptSourcesFile object so we can expand it to AptRepo for idempotency check |
| 327 | + sources_entry = AptSourcesFile( |
| 328 | + types=types_list, |
| 329 | + uris=uris_list, |
| 330 | + suites=suites_list, |
| 331 | + components=components_list, |
| 332 | + architectures=architectures_list or None, |
| 333 | + signed_by=[signed_by] if signed_by else None, |
| 334 | + ) |
| 335 | + desired_repos = sources_entry.expand_to_repos() |
| 336 | + |
| 337 | + # Idempotency: if every expanded repo is already present, do nothing |
| 338 | + existing_sources = host.get_fact(AptSources) |
| 339 | + if desired_repos and all(repo in existing_sources for repo in desired_repos): |
| 340 | + host.noop('apt sources file "{0}" is already configured'.format(dest)) |
| 341 | + return |
| 342 | + |
| 343 | + # Build deb822 content |
| 344 | + lines = [] |
| 345 | + lines.append("Types: {0}".format(" ".join(types_list))) |
| 346 | + lines.append("URIs: {0}".format(" ".join(uris_list))) |
| 347 | + lines.append("Suites: {0}".format(" ".join(suites_list))) |
| 348 | + if components_list: |
| 349 | + lines.append("Components: {0}".format(" ".join(components_list))) |
| 350 | + if architectures_list: |
| 351 | + lines.append("Architectures: {0}".format(" ".join(architectures_list))) |
| 352 | + if signed_by: |
| 353 | + lines.append("Signed-By: {0}".format(signed_by)) |
| 354 | + content = "\n".join(lines) + "\n" |
| 355 | + |
| 356 | + yield from files.put._inner( |
| 357 | + src=io.StringIO(content), |
| 358 | + dest=dest, |
| 359 | + mode="0644", |
| 360 | + ) |
| 361 | + |
| 362 | + |
246 | 363 | @operation(is_idempotent=False) |
247 | 364 | def ppa(src: str, present=True): |
248 | 365 | """ |
|
0 commit comments