Skip to content

Commit 88b17c3

Browse files
committed
feat: add apt.sources_file() operation for deb822 .sources files
Creates /etc/apt/sources.list.d/<filename>.sources in modern deb822 format. Supports all standard fields (Types, URIs, Suites, Components, Architectures, Signed-By) and is idempotent via AptSources fact check. Removal (present=False) deletes the file; removal on absent file is a noop. Tests: create, create_idempotent, remove, remove_missing
1 parent 97a6ebc commit 88b17c3

5 files changed

Lines changed: 197 additions & 1 deletion

File tree

src/pyinfra/operations/apt.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44

55
from __future__ import annotations
66

7+
import io
78
import re
89
from datetime import datetime, timedelta, timezone
910
from urllib.parse import urlparse
1011

1112
from pyinfra import host
12-
from pyinfra.api import operation
13+
from pyinfra.api import OperationError, operation
1314
from pyinfra.facts.apt import (
15+
AptSourcesFile,
1416
AptSources,
1517
SimulateOperationWillChange,
1618
noninteractive_apt,
@@ -243,6 +245,121 @@ def repo(src: str, present=True, filename: str | None = None):
243245
)
244246

245247

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+
246363
@operation(is_idempotent=False)
247364
def ppa(src: str, present=True):
248365
"""
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"kwargs": {
3+
"filename": "docker",
4+
"types": ["deb"],
5+
"uris": ["https://download.docker.com/linux/debian"],
6+
"suites": ["bookworm"],
7+
"components": ["stable"],
8+
"architectures": ["amd64"],
9+
"signed_by": "/etc/apt/keyrings/docker.gpg"
10+
},
11+
"facts": {
12+
"apt.AptSources": [],
13+
"files.File": {
14+
"path=/etc/apt/sources.list.d/docker.sources": null
15+
},
16+
"files.Directory": {
17+
"path=/etc/apt/sources.list.d": {"mode": "755"},
18+
"path=/etc/apt/sources.list.d/docker.sources": null
19+
},
20+
"files.Sha1File": {
21+
"path=/etc/apt/sources.list.d/docker.sources": null
22+
}
23+
},
24+
"commands": [
25+
["upload", "Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: bookworm\nComponents: stable\nArchitectures: amd64\nSigned-By: /etc/apt/keyrings/docker.gpg\n", "/etc/apt/sources.list.d/docker.sources"],
26+
"chmod 644 /etc/apt/sources.list.d/docker.sources"
27+
]
28+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"kwargs": {
3+
"filename": "docker",
4+
"types": ["deb"],
5+
"uris": ["https://download.docker.com/linux/debian"],
6+
"suites": ["bookworm"],
7+
"components": ["stable"],
8+
"architectures": ["amd64"],
9+
"signed_by": "/etc/apt/keyrings/docker.gpg"
10+
},
11+
"facts": {
12+
"apt.AptSources": [
13+
{
14+
"type": "deb",
15+
"url": "https://download.docker.com/linux/debian",
16+
"distribution": "bookworm",
17+
"components": ["stable"],
18+
"options": {"arch": "amd64", "signed-by": "/etc/apt/keyrings/docker.gpg"}
19+
}
20+
]
21+
},
22+
"commands": [],
23+
"noop_description": "apt sources file \"/etc/apt/sources.list.d/docker.sources\" is already configured"
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"kwargs": {
3+
"filename": "docker",
4+
"present": false
5+
},
6+
"facts": {
7+
"files.File": {
8+
"path=/etc/apt/sources.list.d/docker.sources": {"mode": 644}
9+
}
10+
},
11+
"commands": [
12+
"rm -f /etc/apt/sources.list.d/docker.sources"
13+
]
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"kwargs": {
3+
"filename": "docker",
4+
"present": false
5+
},
6+
"facts": {
7+
"files.File": {
8+
"path=/etc/apt/sources.list.d/docker.sources": null
9+
}
10+
},
11+
"commands": [],
12+
"noop_description": "apt sources file \"/etc/apt/sources.list.d/docker.sources\" does not exist"
13+
}

0 commit comments

Comments
 (0)