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.
- qm_template/__init__.py +10 -0
- qm_template/__main__.py +6 -0
- qm_template/checksum.py +65 -0
- qm_template/cli.py +64 -0
- qm_template/commands.py +204 -0
- qm_template/config.default.toml +90 -0
- qm_template/config.py +239 -0
- qm_template/distros/__init__.py +24 -0
- qm_template/distros/alpine.py +31 -0
- qm_template/distros/archlinux.py +28 -0
- qm_template/distros/base.py +65 -0
- qm_template/distros/debian.py +47 -0
- qm_template/distros/opensuse.py +39 -0
- qm_template/distros/redhat.py +132 -0
- qm_template/distros/ubuntu.py +42 -0
- qm_template/download.py +153 -0
- qm_template/errors.py +6 -0
- qm_template/http.py +49 -0
- qm_template/log.py +13 -0
- qm_template/pve.py +209 -0
- qm_template/shell.py +17 -0
- qm_template-0.1.0.dist-info/METADATA +222 -0
- qm_template-0.1.0.dist-info/RECORD +26 -0
- qm_template-0.1.0.dist-info/WHEEL +4 -0
- qm_template-0.1.0.dist-info/entry_points.txt +2 -0
- qm_template-0.1.0.dist-info/licenses/LICENSE +18 -0
qm_template/__init__.py
ADDED
qm_template/__main__.py
ADDED
qm_template/checksum.py
ADDED
|
@@ -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())
|
qm_template/commands.py
ADDED
|
@@ -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)
|