qm-template 0.1.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.
@@ -0,0 +1,10 @@
1
+ """Download cloud images and create Proxmox VE VM templates."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ PROGRAM = "qm-template"
6
+
7
+ try:
8
+ __version__ = version(PROGRAM)
9
+ except PackageNotFoundError:
10
+ __version__ = "0.0.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from qm_template.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,65 @@
1
+ import hashlib
2
+ import re
3
+ from pathlib import Path
4
+
5
+ from qm_template.errors import QmTemplateError
6
+ from qm_template.http import http_get_text
7
+ from qm_template.log import log
8
+
9
+ _CHECKSUM_SUFFIXES = {"sha256": ".sha256", "sha512": ".sha512"}
10
+
11
+ _CHECKSUM_PATTERNS = (
12
+ re.compile(r"^(?P<digest>[0-9a-fA-F]{32,128})[ \t]+\*?(?P<name>\S.*?)\s*$"),
13
+ re.compile(
14
+ r"^(?:SHA\d+|MD5)\s*\((?P<name>.+?)\)\s*=\s*(?P<digest>[0-9a-fA-F]+)\s*$"
15
+ ),
16
+ re.compile(r"^(?P<digest>[0-9a-fA-F]{32,128})$"),
17
+ )
18
+
19
+
20
+ def parse_checksum(text: str, filename: str) -> str:
21
+ entries: list[tuple[str, str]] = []
22
+ for raw_line in text.splitlines():
23
+ line = raw_line.strip()
24
+ if not line or line.startswith(("#", "-", "Hash:")):
25
+ continue
26
+ for pattern in _CHECKSUM_PATTERNS:
27
+ match = pattern.match(line)
28
+ if match:
29
+ groups = match.groupdict()
30
+ entries.append((groups.get("name") or "", groups["digest"].lower()))
31
+ break
32
+ for name, digest in entries:
33
+ if Path(name).name == filename:
34
+ return digest
35
+ if len(entries) == 1:
36
+ return entries[0][1]
37
+ raise QmTemplateError(f"no checksum for {filename!r} in checksum file")
38
+
39
+
40
+ def fetch_checksum(url: str, filename: str) -> str:
41
+ log.debug("Fetching checksum from: %s", url)
42
+ return parse_checksum(http_get_text(url), filename)
43
+
44
+
45
+ def verify_checksum(path: Path, expected: str, algorithm: str) -> bool:
46
+ with path.open("rb") as handle:
47
+ actual = hashlib.file_digest(handle, algorithm).hexdigest()
48
+ if actual != expected.lower():
49
+ log.debug(
50
+ "Checksum mismatch for %s: expected %s, got %s", path, expected, actual
51
+ )
52
+ return False
53
+ return True
54
+
55
+
56
+ def save_checksum(path: Path, digest: str, algorithm: str) -> Path:
57
+ """Write a sha256sum-compatible checksum file next to the image."""
58
+ try:
59
+ suffix = _CHECKSUM_SUFFIXES[algorithm]
60
+ except KeyError:
61
+ raise QmTemplateError(f"unsupported checksum algorithm: {algorithm}") from None
62
+ target = path.with_name(path.name + suffix)
63
+ target.write_text(f"{digest.lower()} {path.name}\n", encoding="ascii")
64
+ log.debug("Wrote checksum file: %s", target)
65
+ return target
qm_template/cli.py ADDED
@@ -0,0 +1,64 @@
1
+ import argparse
2
+ import sys
3
+ from collections.abc import Sequence
4
+
5
+ from qm_template import PROGRAM, __version__
6
+ from qm_template.commands import COMMANDS
7
+ from qm_template.config import load_settings, resolve_config_path, write_default_config
8
+ from qm_template.errors import QmTemplateError, UserCancelled
9
+ from qm_template.log import log, setup_logging
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ common = argparse.ArgumentParser(add_help=False)
14
+ common.add_argument(
15
+ "--config",
16
+ metavar="PATH",
17
+ default=argparse.SUPPRESS,
18
+ help="configuration file (default: /etc/qm-template/config.toml)",
19
+ )
20
+ parser = argparse.ArgumentParser(
21
+ prog=PROGRAM,
22
+ description="Download cloud images and create Proxmox VE VM templates.",
23
+ parents=[common],
24
+ )
25
+ parser.add_argument(
26
+ "--version", action="version", version=f"%(prog)s {__version__}"
27
+ )
28
+ subparsers = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)
29
+ for command in COMMANDS:
30
+ subparser = subparsers.add_parser(
31
+ command.name,
32
+ aliases=list(command.aliases),
33
+ parents=[common],
34
+ help=command.help,
35
+ description=command.description,
36
+ )
37
+ command.add_arguments(subparser)
38
+ subparser.set_defaults(handler=command.run)
39
+ return parser
40
+
41
+
42
+ def main(argv: Sequence[str] | None = None) -> int:
43
+ args = build_parser().parse_args(argv)
44
+ setup_logging()
45
+ try:
46
+ config_path, explicit = resolve_config_path(getattr(args, "config", None))
47
+ if not explicit:
48
+ write_default_config(config_path)
49
+ settings = load_settings(config_path, explicit=explicit)
50
+ args.handler(args, settings)
51
+ except UserCancelled:
52
+ log.info("Operation cancelled by user")
53
+ return 0
54
+ except QmTemplateError as exc:
55
+ log.error("%s", exc)
56
+ return 1
57
+ except KeyboardInterrupt:
58
+ log.warning("Interrupted")
59
+ return 130
60
+ return 0
61
+
62
+
63
+ if __name__ == "__main__":
64
+ sys.exit(main())
@@ -0,0 +1,204 @@
1
+ import argparse
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass, replace
4
+
5
+ from qm_template import PROGRAM
6
+ from qm_template.checksum import fetch_checksum, save_checksum, verify_checksum
7
+ from qm_template.config import Settings
8
+ from qm_template.distros import DISTROS
9
+ from qm_template.download import download_image, part_path, select_downloaders
10
+ from qm_template.errors import QmTemplateError
11
+ from qm_template.log import log
12
+ from qm_template.pve import (
13
+ build_qm_create,
14
+ check_storage,
15
+ choose_image,
16
+ choose_vm_id,
17
+ default_vm_name,
18
+ find_images,
19
+ run_qm,
20
+ sshkeys_file,
21
+ vm_config_path,
22
+ )
23
+ from qm_template.shell import pretty
24
+
25
+
26
+ def add_download_arguments(parser: argparse.ArgumentParser) -> None:
27
+ parser.add_argument(
28
+ "distro",
29
+ nargs="?",
30
+ help="distro name (default from configuration)",
31
+ )
32
+ parser.add_argument("--release", help="distro release or codename")
33
+ parser.add_argument("--variant", help="image variant")
34
+ parser.add_argument("--arch", help="image architecture")
35
+ parser.add_argument("--tag", help="specific image tag/version")
36
+ parser.add_argument(
37
+ "--dry-run",
38
+ action="store_true",
39
+ help="print the download command and exit",
40
+ )
41
+
42
+
43
+ def run_download(args: argparse.Namespace, settings: Settings) -> None:
44
+ name = args.distro or settings.download.default_distro
45
+ distro = DISTROS.get(name)
46
+ if distro is None:
47
+ raise QmTemplateError(
48
+ f"unknown distro {name!r} (run `{PROGRAM} distros` for a list)"
49
+ )
50
+ overrides = {
51
+ "release": args.release,
52
+ "variant": args.variant,
53
+ "arch": args.arch,
54
+ "tag": args.tag,
55
+ }
56
+ if args.tag and not distro.supports_tag:
57
+ log.warning("%s does not support --tag, ignoring it", distro.name)
58
+ overrides["tag"] = None
59
+ params = distro.merge(settings.download.defaults.get(distro.name, {}), overrides)
60
+ image = distro.resolve(params)
61
+ log.info("Image: %s", image.local_path)
62
+ log.debug("URL: %s", image.url)
63
+ log.debug("Checksum: %s (%s)", image.checksum_url, image.algorithm)
64
+ destination = settings.images_dir / image.local_path
65
+ if args.dry_run:
66
+ downloader = select_downloaders(
67
+ settings.download.preferred, settings.download.connections
68
+ )[0]
69
+ print(pretty(downloader.build_command(image.url, part_path(destination))))
70
+ return
71
+ destination.parent.mkdir(parents=True, exist_ok=True)
72
+ expected = fetch_checksum(image.checksum_url, image.filename)
73
+ if destination.is_file():
74
+ if verify_checksum(destination, expected, image.algorithm):
75
+ save_checksum(destination, expected, image.algorithm)
76
+ log.info("Already up to date: %s", destination)
77
+ return
78
+ log.warning("Checksum mismatch for %s, removing it", destination)
79
+ destination.unlink()
80
+ part = download_image(
81
+ image,
82
+ destination,
83
+ select_downloaders(settings.download.preferred, settings.download.connections),
84
+ )
85
+ log.info("Verifying checksum")
86
+ if not verify_checksum(part, expected, image.algorithm):
87
+ part.unlink(missing_ok=True)
88
+ raise QmTemplateError("downloaded image failed checksum verification")
89
+ part.replace(destination)
90
+ save_checksum(destination, expected, image.algorithm)
91
+ log.info("Saved %s", destination)
92
+
93
+
94
+ def add_create_arguments(parser: argparse.ArgumentParser) -> None:
95
+ parser.add_argument("pattern", nargs="?", help="regex to filter local images")
96
+ parser.add_argument("--vm-id", type=int, help="VM ID (prompted when omitted)")
97
+ parser.add_argument(
98
+ "--vm-name", help="VM name (derived from the image when omitted)"
99
+ )
100
+ parser.add_argument("--storage", help="Proxmox storage for VM disks")
101
+ parser.add_argument("--cores", type=int, help="number of CPU cores")
102
+ parser.add_argument("--memory", type=int, help="memory in MiB")
103
+ parser.add_argument("--bridge", help="network bridge")
104
+ parser.add_argument(
105
+ "--dry-run",
106
+ action="store_true",
107
+ help="print the assembled qm command and exit",
108
+ )
109
+
110
+
111
+ def run_create(args: argparse.Namespace, settings: Settings) -> None:
112
+ create = replace(
113
+ settings.create,
114
+ storage=(args.storage if args.storage is not None else settings.create.storage),
115
+ cores=args.cores if args.cores is not None else settings.create.cores,
116
+ memory=args.memory if args.memory is not None else settings.create.memory,
117
+ bridge=args.bridge if args.bridge is not None else settings.create.bridge,
118
+ )
119
+ if create.cores < 1:
120
+ raise QmTemplateError("cores must be a positive integer")
121
+ if create.memory < 1:
122
+ raise QmTemplateError("memory must be a positive integer")
123
+
124
+ images = find_images(settings.images_dir, args.pattern)
125
+ if len(images) == 1:
126
+ image = images[0]
127
+ log.info("Selected image: %s", image.name)
128
+ else:
129
+ image = choose_image(images, settings.images_dir)
130
+
131
+ vm_name = args.vm_name or default_vm_name(image)
132
+ if args.vm_id is not None:
133
+ if args.vm_id <= 0:
134
+ raise QmTemplateError("VM ID must be a positive integer")
135
+ if vm_config_path(args.vm_id).exists():
136
+ raise QmTemplateError(f"VM ID {args.vm_id} is already in use")
137
+ vm_id = args.vm_id
138
+ else:
139
+ vm_id = choose_vm_id()
140
+ log.info("Creating VM %d (%s)", vm_id, vm_name)
141
+ log.debug(
142
+ "storage=%s cores=%d memory=%d bridge=%s",
143
+ create.storage,
144
+ create.cores,
145
+ create.memory,
146
+ create.bridge,
147
+ )
148
+
149
+ check_storage(create.storage)
150
+ with sshkeys_file(create) as sshkeys:
151
+ command = build_qm_create(vm_id, vm_name, image, sshkeys, create)
152
+ if args.dry_run:
153
+ print(pretty(command))
154
+ return
155
+ run_qm(command)
156
+ log.info("Template %s (ID %d) created", vm_name, vm_id)
157
+
158
+
159
+ def add_distros_arguments(parser: argparse.ArgumentParser) -> None:
160
+ pass
161
+
162
+
163
+ def run_distros(args: argparse.Namespace, settings: Settings) -> None:
164
+ for distro in DISTROS.values():
165
+ params = distro.merge(settings.download.defaults.get(distro.name, {}), {})
166
+ rendered = " ".join(f"{key}={value}" for key, value in sorted(params.items()))
167
+ print(f"{distro.name:<12} {distro.description:<26} {rendered}")
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class Command:
172
+ name: str
173
+ help: str
174
+ add_arguments: Callable[[argparse.ArgumentParser], None]
175
+ run: Callable[[argparse.Namespace, Settings], None]
176
+ description: str = ""
177
+ aliases: tuple[str, ...] = ()
178
+
179
+
180
+ COMMANDS: tuple[Command, ...] = (
181
+ Command(
182
+ name="download",
183
+ help="download a cloud image",
184
+ add_arguments=add_download_arguments,
185
+ run=run_download,
186
+ description="Resolve, download and verify a distro cloud image.",
187
+ aliases=("get",),
188
+ ),
189
+ Command(
190
+ name="create",
191
+ help="create a Proxmox VE VM template",
192
+ add_arguments=add_create_arguments,
193
+ run=run_create,
194
+ description="Create a Proxmox VE VM template from a downloaded cloud image.",
195
+ aliases=("template",),
196
+ ),
197
+ Command(
198
+ name="distros",
199
+ help="list supported distros",
200
+ add_arguments=add_distros_arguments,
201
+ run=run_distros,
202
+ description="List supported distros with their configured defaults.",
203
+ ),
204
+ )
@@ -0,0 +1,90 @@
1
+ # qm-template configuration
2
+ #
3
+ # Default location:
4
+ # /etc/qm-template/config.toml
5
+ #
6
+ # Every setting is optional; the values shown here are the built-in defaults.
7
+
8
+ [paths]
9
+ # Directory where downloaded cloud images are stored.
10
+ images_dir = "/var/lib/qm-template"
11
+
12
+ [download]
13
+ # Downloader preference order; the first available command is used.
14
+ preferred = ["axel", "aria2c", "wget", "curl"]
15
+
16
+ # Parallel connections per file for axel and aria2c; ignored by wget and curl.
17
+ connections = 8
18
+
19
+ # Distro used when `qm-template download` is called without a name.
20
+ default_distro = "debian"
21
+
22
+ # Per-distro overrides for `qm-template download`. Any table can be removed to
23
+ # fall back to the built-in defaults shown by `qm-template distros`.
24
+
25
+ [download.debian]
26
+ release = "trixie" # codename, optionally with -backports
27
+ variant = "genericcloud" # genericcloud, generic, nocloud
28
+ arch = "amd64" # amd64, arm64
29
+
30
+ [download.ubuntu]
31
+ release = "resolute" # codename, e.g. noble, jammy
32
+ variant = "server" # server, minimal
33
+ arch = "amd64"
34
+
35
+ [download.rocky]
36
+ release = "10"
37
+ variant = "GenericCloud" # GenericCloud, GenericCloud-LVM
38
+ arch = "x86_64"
39
+
40
+ [download.almalinux]
41
+ release = "10"
42
+ variant = "GenericCloud" # GenericCloud, GenericCloud-ext4
43
+ arch = "x86_64"
44
+
45
+ [download.fedora]
46
+ release = "44"
47
+ variant = "Generic" # Generic, UEFI-UKI
48
+ arch = "x86_64"
49
+
50
+ [download.centos]
51
+ release = "10"
52
+ variant = "GenericCloud"
53
+ arch = "x86_64"
54
+
55
+ [download.alpine]
56
+ release = "3.24"
57
+ variant = "generic" # generic, nocloud
58
+ arch = "x86_64"
59
+
60
+ [download.opensuse]
61
+ release = "tumbleweed" # tumbleweed or a Leap version, e.g. "15.6"
62
+ variant = "Minimal"
63
+ arch = "x86_64"
64
+
65
+ [download.archlinux]
66
+ release = "latest"
67
+ variant = "cloudimg" # cloudimg, basic
68
+ arch = "x86_64"
69
+
70
+ [create]
71
+ # Proxmox storage for the imported disk and the Cloud-Init drive.
72
+ storage = "local-lvm"
73
+ cores = 1
74
+ memory = 1024
75
+ bridge = "vmbr0"
76
+ ciuser = "debian"
77
+ # Kept as a fixed default so the console rescue password stays predictable;
78
+ # change it here to use something else.
79
+ cipassword = "debian"
80
+
81
+ # SSH public keys injected via Cloud-Init. Inline keys in sshkeys and the
82
+ # contents of every sshkeys_files entry are merged and deduplicated by SSH key
83
+ # fingerprint; at least one key is required. Each inline entry must look like
84
+ # an SSH public key. No key file is read unless configured here.
85
+ sshkeys = [
86
+ # "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... user@example.com",
87
+ ]
88
+ sshkeys_files = [
89
+ # "~/.ssh/id_ed25519.pub",
90
+ ]
qm_template/config.py ADDED
@@ -0,0 +1,239 @@
1
+ import base64
2
+ import binascii
3
+ import hashlib
4
+ import os
5
+ import tomllib
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+ from importlib import resources
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from qm_template import PROGRAM
13
+ from qm_template.distros import DISTROS
14
+ from qm_template.errors import QmTemplateError
15
+ from qm_template.log import log
16
+
17
+
18
+ def default_config_path() -> Path:
19
+ return Path("/etc") / PROGRAM / "config.toml"
20
+
21
+
22
+ def default_images_dir() -> Path:
23
+ return Path("/var/lib") / PROGRAM
24
+
25
+
26
+ def resolve_config_path(cli_value: str | None) -> tuple[Path, bool]:
27
+ if cli_value:
28
+ return Path(cli_value).expanduser(), True
29
+ env_value = os.environ.get("QM_TEMPLATE_CONFIG")
30
+ if env_value:
31
+ return Path(env_value).expanduser(), True
32
+ return default_config_path(), False
33
+
34
+
35
+ def _packaged_config_text() -> str:
36
+ config = resources.files("qm_template") / "config.default.toml"
37
+ return config.read_text(encoding="utf-8")
38
+
39
+
40
+ def write_default_config(path: Path) -> bool:
41
+ """Write the packaged default configuration unless a file already exists."""
42
+ if path.exists():
43
+ return False
44
+ try:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ path.write_text(_packaged_config_text(), encoding="utf-8")
47
+ except OSError as exc:
48
+ log.debug("Could not write default configuration to %s: %s", path, exc)
49
+ return False
50
+ log.info("Wrote default configuration to %s", path)
51
+ return True
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class DownloadSettings:
56
+ preferred: tuple[str, ...] = ("axel", "aria2c", "wget", "curl")
57
+ connections: int = 8
58
+ default_distro: str = "debian"
59
+ defaults: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class CreateSettings:
64
+ storage: str = "local-lvm"
65
+ cores: int = 1
66
+ memory: int = 1024
67
+ bridge: str = "vmbr0"
68
+ ciuser: str = "debian"
69
+ cipassword: str = "debian"
70
+ sshkeys: tuple[str, ...] = ()
71
+ sshkeys_files: tuple[str, ...] = ()
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Settings:
76
+ images_dir: Path = field(default_factory=default_images_dir)
77
+ download: DownloadSettings = field(default_factory=DownloadSettings)
78
+ create: CreateSettings = field(default_factory=CreateSettings)
79
+
80
+
81
+ _DOWNLOAD_DEFAULTS = DownloadSettings()
82
+ _CREATE_DEFAULTS = CreateSettings()
83
+ _DOWNLOAD_KEYS = frozenset(DownloadSettings.__dataclass_fields__) - {"defaults"}
84
+ _CREATE_KEYS = frozenset(CreateSettings.__dataclass_fields__)
85
+
86
+
87
+ def _table(data: Mapping[str, Any], key: str, source: Path) -> Mapping[str, Any]:
88
+ value = data.get(key, {})
89
+ if not isinstance(value, dict):
90
+ raise QmTemplateError(f"[{key}] must be a table in {source}")
91
+ return value
92
+
93
+
94
+ def _reject_unknown(
95
+ table: Mapping[str, Any],
96
+ allowed: set[str] | frozenset[str],
97
+ section: str | None,
98
+ source: Path,
99
+ ) -> None:
100
+ unknown = sorted(set(table) - allowed)
101
+ if unknown:
102
+ names = ", ".join(repr(key) for key in unknown)
103
+ where = f"in [{section}]" if section else "at the top level"
104
+ raise QmTemplateError(f"unknown keys {names} {where} of {source}")
105
+
106
+
107
+ def _str(table: Mapping[str, Any], key: str, default: str, source: Path) -> str:
108
+ value = table.get(key, default)
109
+ if not isinstance(value, str):
110
+ raise QmTemplateError(f"{key!r} must be a string in {source}")
111
+ return value
112
+
113
+
114
+ def _int(table: Mapping[str, Any], key: str, default: int, source: Path) -> int:
115
+ value = table.get(key, default)
116
+ if not isinstance(value, int) or isinstance(value, bool):
117
+ raise QmTemplateError(f"{key!r} must be an integer in {source}")
118
+ return value
119
+
120
+
121
+ def _string_list(
122
+ table: Mapping[str, Any], key: str, default: Sequence[str], source: Path
123
+ ) -> tuple[str, ...]:
124
+ value = table.get(key, default)
125
+ if not isinstance(value, (list, tuple)) or not all(
126
+ isinstance(item, str) for item in value
127
+ ):
128
+ raise QmTemplateError(f"{key!r} must be a list of strings in {source}")
129
+ return tuple(value)
130
+
131
+
132
+ SSH_KEY_TYPE_PREFIXES = ("ssh-", "ecdsa-sha2-", "sk-")
133
+
134
+
135
+ def ssh_key_fingerprint(line: str) -> str | None:
136
+ fields = line.split()
137
+ if len(fields) < 2:
138
+ return None
139
+ try:
140
+ blob = base64.b64decode(fields[1], validate=True)
141
+ except binascii.Error:
142
+ return None
143
+ digest = hashlib.sha256(blob).digest()
144
+ return base64.b64encode(digest).rstrip(b"=").decode("ascii")
145
+
146
+
147
+ def _ssh_keys(table: Mapping[str, Any], key: str, source: Path) -> tuple[str, ...]:
148
+ keys: list[str] = []
149
+ for value in _string_list(table, key, _CREATE_DEFAULTS.sshkeys, source):
150
+ stripped = value.strip()
151
+ if not stripped.startswith(SSH_KEY_TYPE_PREFIXES) or not ssh_key_fingerprint(
152
+ stripped
153
+ ):
154
+ raise QmTemplateError(
155
+ f"{key!r} entry {value!r} does not look like an SSH public key "
156
+ f"in {source}"
157
+ )
158
+ keys.append(stripped)
159
+ return tuple(keys)
160
+
161
+
162
+ def _parse_download(table: Mapping[str, Any], source: Path) -> DownloadSettings:
163
+ _reject_unknown(table, _DOWNLOAD_KEYS | set(DISTROS), "download", source)
164
+ defaults: dict[str, dict[str, str]] = {}
165
+ for key, value in table.items():
166
+ if key in _DOWNLOAD_KEYS:
167
+ continue
168
+ if not isinstance(value, dict):
169
+ raise QmTemplateError(f"[download.{key}] must be a table in {source}")
170
+ allowed = set(DISTROS[key].defaults) | {"tag"}
171
+ params: dict[str, str] = {}
172
+ for param, raw in value.items():
173
+ if param not in allowed:
174
+ raise QmTemplateError(
175
+ f"unknown parameter {param!r} in [download.{key}] of {source}"
176
+ )
177
+ if isinstance(raw, bool) or not isinstance(raw, (str, int)):
178
+ raise QmTemplateError(
179
+ f"[download.{key}].{param} must be a string or integer in {source}"
180
+ )
181
+ params[param] = str(raw)
182
+ defaults[key] = params
183
+ connections = _int(table, "connections", _DOWNLOAD_DEFAULTS.connections, source)
184
+ if connections < 1:
185
+ raise QmTemplateError(f"'connections' must be a positive integer in {source}")
186
+ return DownloadSettings(
187
+ preferred=_string_list(
188
+ table, "preferred", _DOWNLOAD_DEFAULTS.preferred, source
189
+ ),
190
+ connections=connections,
191
+ default_distro=_str(
192
+ table, "default_distro", _DOWNLOAD_DEFAULTS.default_distro, source
193
+ ),
194
+ defaults=defaults,
195
+ )
196
+
197
+
198
+ def _parse_create(table: Mapping[str, Any], source: Path) -> CreateSettings:
199
+ _reject_unknown(table, _CREATE_KEYS, "create", source)
200
+ return CreateSettings(
201
+ storage=_str(table, "storage", _CREATE_DEFAULTS.storage, source),
202
+ cores=_int(table, "cores", _CREATE_DEFAULTS.cores, source),
203
+ memory=_int(table, "memory", _CREATE_DEFAULTS.memory, source),
204
+ bridge=_str(table, "bridge", _CREATE_DEFAULTS.bridge, source),
205
+ ciuser=_str(table, "ciuser", _CREATE_DEFAULTS.ciuser, source),
206
+ cipassword=_str(table, "cipassword", _CREATE_DEFAULTS.cipassword, source),
207
+ sshkeys=_ssh_keys(table, "sshkeys", source),
208
+ sshkeys_files=_string_list(
209
+ table, "sshkeys_files", _CREATE_DEFAULTS.sshkeys_files, source
210
+ ),
211
+ )
212
+
213
+
214
+ def parse_settings(data: Mapping[str, Any], source: Path) -> Settings:
215
+ _reject_unknown(data, {"paths", "download", "create"}, None, source)
216
+ paths = _table(data, "paths", source)
217
+ _reject_unknown(paths, {"images_dir"}, "paths", source)
218
+ images_dir_value = _str(paths, "images_dir", str(default_images_dir()), source)
219
+ images_dir = Path(os.path.expandvars(images_dir_value)).expanduser()
220
+ return Settings(
221
+ images_dir=images_dir,
222
+ download=_parse_download(_table(data, "download", source), source),
223
+ create=_parse_create(_table(data, "create", source), source),
224
+ )
225
+
226
+
227
+ def load_settings(path: Path, *, explicit: bool) -> Settings:
228
+ if not path.is_file():
229
+ if explicit:
230
+ raise QmTemplateError(f"configuration file not found: {path}")
231
+ log.debug("No configuration file at %s, using built-in defaults", path)
232
+ return Settings()
233
+ log.debug("Loading configuration from: %s", path)
234
+ try:
235
+ with path.open("rb") as handle:
236
+ data = tomllib.load(handle)
237
+ except tomllib.TOMLDecodeError as exc:
238
+ raise QmTemplateError(f"invalid TOML in {path}: {exc}") from exc
239
+ return parse_settings(data, source=path)