feat: add Proxmox provisioning service with CI and deployment tooling
CI / javascript-check (push) Successful in 51s
CI / container-policy (push) Successful in 2s
CI / container-verify (push) Canceled after 0s
CI / container-publish (push) Canceled after 0s
CI / python-tests (push) Canceled after 6m59s

This commit is contained in:
BartelLuis
2026-09-14 20:09:12 +02:00
commit 06c3474636
52 changed files with 9779 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Mitgelieferte Modulentwürfe
Alle acht Module werden als **Entwurf ohne Hardware-Testnachweis und ohne freigegebene Zielbuilds** angeboten. Vor Veröffentlichung müssen Quelltext, konkrete Parameter und Verhalten auf einem passenden Testhost geprüft werden. Die Syntaxprüfung ersetzt diesen Nachweis nicht.
| Modul | Parameter und Umfang |
| --- | --- |
| Voraussetzungen | `allowed_versions`: exakte Versionsstrings aus `pveversion`; `dns_names`: aufzulösende Namen; `minimum_free_mb`: freier Platz auf `/`. Eine leere Versionsliste lässt die zusätzliche Versionsprüfung aus; die Buildfreigabe im Dienst bleibt erforderlich. |
| Paketquellen | `url`, `suite`, `components`, `keyring` sind zwingend. Verwaltet genau `/etc/apt/sources.list.d/pve-provisioner.sources` mit HTTPS und vorhandenem APT-Schlüsselbund. Andere Quellen, insbesondere Subscription-Konfigurationen, werden nicht automatisch entfernt. |
| Basispakete | `packages`: Debian-Paketnamen ohne Shell-Ausdrücke oder APT-Optionen. Installiert fehlende Pakete, wartet auf die Paketmanagersperre und prüft anschließend den Installationsstatus. Führt kein allgemeines Systemupgrade aus. |
| SSH-Zugang | `users`: Liste aus `name` und `authorized_keys`. Benutzer müssen bereits existieren und eine Login-Shell sowie ein sicher berechtigtes Home-Verzeichnis haben. OpenSSH validiert vollständige Schlüssel vor jeder Änderung. Schlüssel werden ergänzt und Dateirechte, effektive lokale SSH-Schlüsselrichtlinie, `sshd -t` sowie der aktive Dienst geprüft. Verbindung und gegebenenfalls abweichende `Match`-Regeln aus dem realen Managementnetz sind separat zu testen. |
| Zeitsynchronisation | `servers`: explizite NTP-Hostnamen oder IP-Adressen. `chrony` muss zuvor installiert sein und `sourcedir /etc/chrony/sources.d` verwenden. Verwaltet eine eigene Quelldatei und wartet begrenzt auf Synchronisation. |
| Monitoring | `enabled`: standardmäßig `false`. Bei Aktivierung muss `prometheus-node-exporter` bereits installiert sein. Aktiviert den Dienst und prüft dessen lokalen Metrics-Endpunkt. Netzwerkzugriff auf den Exporter muss im Standortnetz passend geregelt sein. |
| Zusätzlicher Storage | `id`, `path`, `content`: registriert ein bereits existierendes Verzeichnis unter `/mnt/` oder `/srv/` als PVE-Verzeichnisstorage. Kein Formatieren, kein Mounten, keine Änderung widersprüchlicher vorhandener Storage-Konfiguration. |
| Abschlussprüfung | `allowed_versions`, `dns_names`, `storage_ids`, `require_time_sync`: prüft PVE-Dienste, Version, DNS, Zeit und angegebenen aktiven Storage. |
Modulabhängigkeiten beziehen sich im Verwaltungsmodell auf **Modulnamen**. Beim Freigeben werden sie auf die konkreten Schritt-IDs des unveränderlichen Laufmanifests aufgelöst. Profilparameter werden gegen das jeweilige JSON-Schema geprüft. Beispielsweise muss das Paketprofil `chrony` enthalten, wenn der Zeitschritt auf einem Host ohne Chrony eingeplant wird.
## Modulvertrag
Der Runner startet `bash modul.sh check|apply|verify parameter.json`. `check` liefert `0`, wenn der Sollzustand erreicht ist, `1`, wenn eine Änderung nötig ist, und einen anderen Rückgabecode für einen Prüffehler. Ein Schritt gilt erst nach erfolgreichem `verify` als erfolgreich. `apply` darf mit `194` einen geplanten Neustart anfordern; der Runner schreibt zuerst seinen Checkpoint und kontrolliert das Neustartbudget. Ein Modul darf den Neustart nicht selbst auslösen.
Die Parameterdatei enthält die freigegebenen Parameter sowie ein Objekt `secrets` mit ausschließlich den Geheimnissen des aktuellen Schritts. Sie wird mit Modus `0600` angelegt und nach dem Schritt entfernt. Module sollen keine Geheimnisse ausgeben; zusätzlich redigiert der Runner bekannte Geheimniswerte vor der dauerhaften Logablage. Logausgabe ist pro Phase und in der lokalen Warteschlange begrenzt.
Das Schritt-Timeout gilt gemeinsam für `check`, `apply` und `verify`. Nach Unterbrechungen werden `check` und `verify` erneut ausgeführt; ein nicht bestätigter Zustand darf nur bei `retry_safe=true` erneut angewendet werden. Ein permanenter Fehler wartet auf eine explizite Wiederaufnahme im Webtool. Die standardmäßige Wartefrist beträgt 24 Stunden, die maximale automatische Wiederherstellung bei Netzausfall 30 Minuten.
Chrony-Kommandos orientieren sich an der offiziellen Dokumentation zu [chronyc](https://chrony-project.org/doc/4.7/chronyc.html) und [sourcedir](https://chrony-project.org/doc/4.7/chrony.conf.html). Die tatsächliche Paketversion und Distribution bleiben Bestandteil des Zielhost-Tests.
+53
View File
@@ -0,0 +1,53 @@
"""Conservative module drafts. Publication always requires target-host evidence."""
from pathlib import Path
def schema(properties, required=()):
return {"type": "object", "additionalProperties": False, "properties": properties, "required": list(required)}
STRING_LIST = {"type": "array", "items": {"type": "string"}, "maxItems": 100}
DEFINITIONS = [
("prerequisites", "Voraussetzungen", "PVE-Version, DNS, Uhrzeit und freien Speicher prüfen.",
schema({"allowed_versions": STRING_LIST, "dns_names": STRING_LIST,
"minimum_free_mb": {"type": "integer", "minimum": 512, "maximum": 1048576}}),
{"allowed_versions": [], "dns_names": [], "minimum_free_mb": 2048}, [], 120, True),
("repositories", "Paketquellen", "Eine signierte, explizit freigegebene HTTPS-Paketquelle verwalten.",
schema({"url": {"type": "string"}, "suite": {"type": "string"}, "components": STRING_LIST,
"keyring": {"type": "string"}}, ("url", "suite", "components", "keyring")),
{}, ["prerequisites"], 600, True),
("packages", "Basispakete", "Explizit genannte Pakete installieren; keine globale Aktualisierung.",
schema({"packages": STRING_LIST}), {"packages": []}, ["prerequisites"], 1800, True),
("ssh", "SSH-Zugang", "Freigegebene Schlüssel vorhandenen Benutzern hinzufügen; sshd validieren.",
schema({"users": {"type": "array", "maxItems": 50, "items": schema({
"name": {"type": "string", "pattern": "^[a-z_][a-z0-9_-]{0,31}$"},
"authorized_keys": STRING_LIST}, ("name", "authorized_keys"))}}),
{"users": []}, ["prerequisites"], 120, True),
("time", "Zeitsynchronisation", "Chrony mit expliziten Zeitservern konfigurieren und Synchronisation prüfen.",
schema({"servers": STRING_LIST}, ("servers",)), {"servers": []}, ["packages"], 600, True),
("monitoring", "Monitoring", "Optional den Debian prometheus-node-exporter aktivieren.",
schema({"enabled": {"type": "boolean"}}), {"enabled": False}, ["packages"], 600, False),
("storage", "Zusätzlicher Storage", "Vorhandenes Verzeichnis ohne Formatierung als PVE-Storage anbinden.",
schema({"id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,31}$"},
"path": {"type": "string"}, "content": STRING_LIST}, ("id", "path", "content")),
{}, ["prerequisites"], 120, True),
("final-verification", "Abschlussprüfung", "PVE-Dienste, Versionsstand, DNS, Zeit und aktiven Storage prüfen.",
schema({"allowed_versions": STRING_LIST, "dns_names": STRING_LIST,
"storage_ids": STRING_LIST, "require_time_sync": {"type": "boolean"}}),
{"allowed_versions": [], "dns_names": [], "storage_ids": [], "require_time_sync": True},
["prerequisites"], 180, True),
]
def catalog():
result = []
root = Path(__file__).parent
names = {definition[0]: definition[1] for definition in DEFINITIONS}
for module_id, name, description, parameters_schema, defaults, dependencies, timeout, required in DEFINITIONS:
result.append({"id": module_id, "name": name, "description": description, "version": "1.0.0",
"source": (root / f"{module_id}.sh").read_text(encoding="utf-8"),
"parameters_schema": parameters_schema, "default_parameters": defaults,
"dependencies": [names[dependency] for dependency in dependencies], "timeout_seconds": timeout,
"retry_safe": True, "required": required, "status": "draft",
"test_evidence": "", "target_builds": []})
return result
@@ -0,0 +1,31 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, socket, subprocess, sys
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
try:
version = subprocess.run(['pveversion'], check=True, capture_output=True, text=True, timeout=30).stdout
match = re.search(r'pve-manager/([^/\s]+)', version)
if not match or (p.get('allowed_versions') and match[1] not in p['allowed_versions']):
raise ValueError('PVE version verification failed')
for service in ('pve-cluster.service', 'pvedaemon.service', 'pveproxy.service', 'pvestatd.service'):
subprocess.run(['systemctl', 'is-active', '--quiet', service], check=True, timeout=30)
for name in p.get('dns_names', []):
socket.getaddrinfo(name, 443)
if p.get('require_time_sync', True):
sync = subprocess.run(['timedatectl', 'show', '-p', 'NTPSynchronized', '--value'], check=True, capture_output=True, text=True, timeout=30)
if sync.stdout.strip() != 'yes':
raise ValueError('System clock is not synchronized')
for storage_id in p.get('storage_ids', []):
if not isinstance(storage_id, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,31}', storage_id):
raise ValueError('Invalid storage identifier')
result = subprocess.run(['pvesm', 'status', '--storage', storage_id], check=True, capture_output=True, text=True, timeout=30)
if not any(re.match(r'^' + re.escape(storage_id) + r'\s+\S+\s+active\s', line) for line in result.stdout.splitlines()):
raise ValueError('Required storage is not active')
print(json.dumps({'passed': True, 'pve_version': match[1], 'services': 'active', 'clock': 'checked', 'storage': 'checked'}))
except (ValueError, OSError, subprocess.SubprocessError) as exc:
print(str(exc), file=sys.stderr)
sys.exit(2)
PY
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, subprocess, sys, urllib.request
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
if not isinstance(p.get('enabled', False), bool):
raise SystemExit('enabled must be a boolean')
if not p.get('enabled', False):
print('{"passed":true,"enabled":false}')
raise SystemExit(0)
service = 'prometheus-node-exporter.service'
active = subprocess.run(['systemctl', 'is-active', '--quiet', service], timeout=30).returncode == 0
if mode == 'check':
sys.exit(0 if active else 1)
if mode == 'apply':
package = subprocess.run(['dpkg-query', '-W', '-f=${Status}', 'prometheus-node-exporter'], capture_output=True, text=True, timeout=30)
if package.returncode != 0 or package.stdout != 'install ok installed':
raise SystemExit('Install prometheus-node-exporter with the package module first')
subprocess.run(['systemctl', 'enable', '--now', service], check=True, timeout=60)
subprocess.run(['systemctl', 'is-active', '--quiet', service], check=True, timeout=30)
with urllib.request.urlopen('http://127.0.0.1:9100/metrics', timeout=10) as response:
body = response.read(2 * 1024 * 1024)
if b'node_exporter_build_info' not in body:
raise SystemExit('Node exporter metrics validation failed')
print('{"passed":true,"metrics":"available"}')
PY
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, subprocess, sys
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
packages = p.get('packages', [])
if not isinstance(packages, list) or len(packages) > 100 or any(not isinstance(x, str) or not re.fullmatch(r'[a-z0-9][a-z0-9+.-]{0,100}', x) for x in packages):
raise SystemExit('Invalid package list; names only, no options or shell expressions')
def installed(name):
result = subprocess.run(['dpkg-query', '-W', '-f=${Status}', name], capture_output=True, text=True, timeout=30)
return result.returncode == 0 and result.stdout == 'install ok installed'
missing = [name for name in packages if not installed(name)]
if mode == 'apply' and missing:
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'update'], check=True, timeout=600)
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', '--no-install-recommends', '--', *missing], check=True, timeout=1200)
missing = [name for name in packages if not installed(name)]
print(json.dumps({'passed': not missing, 'missing': missing}))
sys.exit(1 if missing else 0)
PY
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, shutil, socket, subprocess, sys, time
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
try:
result = subprocess.run(['pveversion'], check=True, capture_output=True, text=True, timeout=20)
match = re.search(r'pve-manager/([^/\s]+)', result.stdout)
if not match:
raise ValueError('Target does not report a Proxmox VE manager version')
if p.get('allowed_versions') and match[1] not in p['allowed_versions']:
raise ValueError('PVE version is outside the approved module target versions')
minimum = p.get('minimum_free_mb', 2048)
if not isinstance(minimum, int) or not 512 <= minimum <= 1048576:
raise ValueError('minimum_free_mb is invalid')
if shutil.disk_usage('/').free < minimum * 1024 * 1024:
raise ValueError('Insufficient free root filesystem space')
if time.time() < 1704067200:
raise ValueError('System clock is not plausible')
for name in p.get('dns_names', []):
if not isinstance(name, str) or not name or len(name) > 253:
raise ValueError('Invalid DNS target')
socket.getaddrinfo(name, 443)
print(json.dumps({'passed': True, 'pve_version': match[1], 'dns': 'resolved', 'free_space': 'sufficient'}))
except (ValueError, OSError, subprocess.SubprocessError) as exc:
print(str(exc), file=sys.stderr)
sys.exit(2)
PY
@@ -0,0 +1,48 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, os, pathlib, re, subprocess, sys, tempfile, urllib.parse
def has_repository_indexes(policy, url, suite, components):
indexes = [line.split() for line in policy.splitlines()]
return all(any(any(field.rstrip('/') == url.rstrip('/') for field in fields) and f'{suite}/{component}' in fields for fields in indexes) for component in components)
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
url, suite, components, keyring = (p.get(k) for k in ('url', 'suite', 'components', 'keyring'))
if not isinstance(url, str) or any(c.isspace() for c in url):
raise SystemExit('An explicit HTTPS repository URL is required')
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise SystemExit('Repository URL must use HTTPS without credentials or query parameters')
if not isinstance(suite, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,40}', suite):
raise SystemExit('Invalid repository suite')
if not isinstance(components, list) or not components or any(not isinstance(c, str) or not re.fullmatch(r'[a-z][a-z0-9/-]{0,50}', c) for c in components):
raise SystemExit('Invalid repository components')
if not isinstance(keyring, str) or not re.fullmatch(r'/(usr/share|etc/apt)/keyrings/[A-Za-z0-9_.-]+\.(gpg|asc)', keyring) or not pathlib.Path(keyring).is_file():
raise SystemExit('An existing administrator-provisioned APT keyring is required')
expected = f'Types: deb\nURIs: {url}\nSuites: {suite}\nComponents: {" ".join(components)}\nSigned-By: {keyring}\n'
target = pathlib.Path('/etc/apt/sources.list.d/pve-provisioner.sources')
matches = target.is_file() and target.read_text() == expected
if mode == 'check':
sys.exit(0 if matches else 1)
if mode == 'apply' and not matches:
fd, name = tempfile.mkstemp(prefix='.pve-provisioner-', dir=target.parent)
try:
os.fchmod(fd, 0o644)
with os.fdopen(fd, 'w') as stream:
stream.write(expected)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, target)
finally:
if os.path.exists(name):
os.unlink(name)
if mode == 'apply':
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'update'], check=True, timeout=500)
policy = subprocess.run(['apt-cache', 'policy'], check=True, capture_output=True, text=True, timeout=30).stdout
indexed = has_repository_indexes(policy, url, suite, components)
passed = target.is_file() and target.read_text() == expected and indexed
print(json.dumps({'passed': passed, 'repository': url, 'suite': suite}))
sys.exit(0 if passed else 1)
PY
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import base64, json, os, pathlib, pwd, re, subprocess, sys, tempfile
def validate_public_key(key):
if not isinstance(key, str) or '\n' in key or '\r' in key or len(key) > 16384:
raise SystemExit('Invalid SSH public key')
parts = key.split()
if len(parts) < 2 or parts[0] not in ('ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521'):
raise SystemExit('Unsupported SSH public key format')
try:
blob = base64.b64decode(parts[1], validate=True)
size = int.from_bytes(blob[:4], 'big')
if blob[4:4 + size].decode() != parts[0] or len(blob) <= 4 + size:
raise ValueError()
except (ValueError, UnicodeError):
raise SystemExit('Invalid SSH public key encoding')
# sshd -t does not parse authorized_keys. Validate the complete public key.
fd, key_file = tempfile.mkstemp(prefix='pve-public-key-')
try:
with os.fdopen(fd, 'w') as stream:
stream.write(key + '\n')
parsed = subprocess.run(['ssh-keygen', '-l', '-f', key_file], capture_output=True, text=True, timeout=15)
if parsed.returncode != 0:
raise SystemExit('OpenSSH rejected the configured public key')
finally:
os.unlink(key_file)
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
users = p.get('users', [])
if not isinstance(users, list) or len(users) > 50:
raise SystemExit('Invalid SSH users')
changes = []
for entry in users:
name = entry.get('name', '')
if not isinstance(name, str) or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', name):
raise SystemExit('Invalid SSH account name')
try:
user = pwd.getpwnam(name)
except KeyError:
raise SystemExit('SSH module requires an existing user account')
keys = entry.get('authorized_keys', [])
if not isinstance(keys, list) or not keys or len(keys) > 100:
raise SystemExit('At least one authorized key per configured user is required')
for key in keys:
validate_public_key(key)
home = pathlib.Path(user.pw_dir)
if not home.is_dir() or home.stat().st_uid not in (0, user.pw_uid) or home.stat().st_mode & 0o022:
raise SystemExit('Account home must have safe ownership and must not be writable by group or others')
if user.pw_shell in ('/usr/sbin/nologin', '/sbin/nologin', '/bin/false'):
raise SystemExit('SSH account requires an interactive login shell')
effective = subprocess.run(['/usr/sbin/sshd', '-T', '-C', f'user={name},host=localhost,addr=127.0.0.1'], check=True, capture_output=True, text=True, timeout=30)
settings = dict(line.split(None, 1) for line in effective.stdout.splitlines() if ' ' in line)
if settings.get('pubkeyauthentication') != 'yes' or (name == 'root' and settings.get('permitrootlogin') not in ('yes', 'prohibit-password', 'without-password')):
raise SystemExit('Effective SSH policy does not permit public-key login for this account')
key_paths = settings.get('authorizedkeysfile', '').split()
accepted = {'.ssh/authorized_keys', '%h/.ssh/authorized_keys', str(home / '.ssh/authorized_keys')}
if not accepted.intersection(key_paths):
raise SystemExit('Effective SSH policy does not use the managed authorized_keys file')
directory = pathlib.Path(user.pw_dir) / '.ssh'
target = directory / 'authorized_keys'
if directory.is_symlink() or target.is_symlink():
raise SystemExit('Refusing symlinked SSH paths')
existing = target.read_text() if target.exists() else ''
missing = [key for key in keys if key not in existing.splitlines()]
correct_permissions = directory.exists() and target.exists() and directory.stat().st_mode & 0o777 == 0o700 and target.stat().st_mode & 0o777 == 0o600 and target.stat().st_uid == user.pw_uid and directory.stat().st_uid == user.pw_uid
if missing or not correct_permissions:
changes.append((user, directory, target, existing, missing))
if mode == 'check':
sys.exit(1 if changes else 0)
if mode == 'apply':
for user, directory, target, existing, missing in changes:
directory.mkdir(mode=0o700, exist_ok=True)
os.chmod(directory, 0o700)
os.chown(directory, user.pw_uid, user.pw_gid)
content = existing.rstrip('\n') + ('\n' if existing else '') + '\n'.join(missing) + ('\n' if missing else '')
fd, name = tempfile.mkstemp(prefix='.authorized-', dir=directory)
try:
os.fchmod(fd, 0o600)
os.fchown(fd, user.pw_uid, user.pw_gid)
with os.fdopen(fd, 'w') as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, target)
finally:
if os.path.exists(name):
os.unlink(name)
subprocess.run(['/usr/sbin/sshd', '-t'], check=True, timeout=30)
subprocess.run(['systemctl', 'is-active', '--quiet', 'ssh.service'], check=True, timeout=30)
if mode == 'verify' and changes:
raise SystemExit(1)
print(json.dumps({'passed': True, 'accounts': len(users), 'sshd': 'validated'}))
PY
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, subprocess, sys
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
storage_id, location, content = (p.get(k) for k in ('id', 'path', 'content'))
if not isinstance(storage_id, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,31}', storage_id):
raise SystemExit('Invalid storage identifier')
if not isinstance(location, str) or any(c.isspace() for c in location) or not location.startswith(('/mnt/', '/srv/')):
raise SystemExit('Storage must be an existing absolute directory under /mnt or /srv')
directory = pathlib.Path(location)
if not directory.is_dir() or directory.is_symlink() or str(directory.resolve()) != location.rstrip('/'):
raise SystemExit('Storage directory must already exist without symbolic links or traversal')
if not isinstance(content, list) or not content or not set(content).issubset({'images', 'rootdir', 'vztmpl', 'iso', 'backup', 'snippets'}):
raise SystemExit('Invalid storage content types')
def configuration():
result = subprocess.run(['pvesh', 'get', '/storage', '--output-format', 'json'], check=True, capture_output=True, text=True, timeout=30)
if not any(entry.get('storage') == storage_id for entry in json.loads(result.stdout)):
return None
detail = subprocess.run(['pvesh', 'get', '/storage/' + storage_id, '--output-format', 'json'], check=True, capture_output=True, text=True, timeout=30)
return json.loads(detail.stdout)
existing = configuration()
if existing and (existing.get('type') != 'dir' or existing.get('path', '').rstrip('/') != location.rstrip('/') or set(existing.get('content', '').split(',')) != set(content)):
raise SystemExit('Existing storage has conflicting settings; automatic changes are refused')
if mode == 'check':
sys.exit(0 if existing else 1)
if mode == 'apply' and not existing:
subprocess.run(['pvesm', 'add', 'dir', storage_id, '--path', location, '--content', ','.join(content)], check=True, timeout=60)
result = subprocess.run(['pvesm', 'status', '--storage', storage_id], check=True, capture_output=True, text=True, timeout=30)
if not any(re.match(r'^' + re.escape(storage_id) + r'\s+dir\s+active\s', line) for line in result.stdout.splitlines()):
raise SystemExit('Storage is not active')
print(json.dumps({'passed': True, 'storage': storage_id, 'destructive_operations': False}))
PY
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import ipaddress, json, os, pathlib, re, subprocess, sys, tempfile
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
servers = p.get('servers')
if not isinstance(servers, list) or not 1 <= len(servers) <= 16:
raise SystemExit('Between one and sixteen explicit NTP servers are required')
for server in servers:
if not isinstance(server, str) or len(server) > 253:
raise SystemExit('Invalid NTP server')
try:
ipaddress.ip_address(server)
except ValueError:
if not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?', server):
raise SystemExit('NTP server must be a hostname or IP address')
target = pathlib.Path('/etc/chrony/sources.d/pve-provisioner.sources')
expected = ''.join(f'server {server} iburst\n' for server in servers)
active = subprocess.run(['systemctl', 'is-active', '--quiet', 'chrony.service'], timeout=30).returncode == 0
matches = target.is_file() and target.read_text() == expected
if mode == 'check':
sys.exit(0 if matches and active else 1)
if mode == 'apply':
# The package module owns installation; this module never swaps NTP daemons.
config = pathlib.Path('/etc/chrony/chrony.conf')
if not config.is_file() or not any(line.strip() == 'sourcedir /etc/chrony/sources.d' for line in config.read_text().splitlines()):
raise SystemExit('Install chrony first and enable its standard sources.d directory')
target.parent.mkdir(mode=0o755, exist_ok=True)
fd, name = tempfile.mkstemp(prefix='.pve-provisioner-', dir=target.parent)
try:
os.fchmod(fd, 0o644)
with os.fdopen(fd, 'w') as stream:
stream.write(expected)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, target)
finally:
if os.path.exists(name):
os.unlink(name)
subprocess.run(['systemctl', 'enable', '--now', 'chrony.service'], check=True, timeout=60)
subprocess.run(['chronyc', 'reload', 'sources'], check=True, timeout=30)
subprocess.run(['chronyc', 'waitsync', '30', '0.5', '0', '2'], check=True, timeout=90)
subprocess.run(['systemctl', 'is-active', '--quiet', 'chrony.service'], check=True, timeout=30)
if not target.is_file() or target.read_text() != expected:
raise SystemExit(1)
print(json.dumps({'passed': True, 'clock': 'synchronized'}))
PY