Release v0.1.16
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
schema_version = 1
|
||||
name = "govoplan-k8s-acceptance"
|
||||
mode = "acceptance"
|
||||
state_directory = "~/.local/share/govoplan/labs/govoplan-k8s-acceptance"
|
||||
vm_image_directory = "/var/lib/libvirt/images/govoplan-labs"
|
||||
ssh_user = "govoplan"
|
||||
ssh_private_key = "~/.ssh/govoplan-lab"
|
||||
ssh_public_key = "~/.ssh/govoplan-lab.pub"
|
||||
namespace = "govoplan"
|
||||
public_host = "govoplan.acceptance.example.org"
|
||||
s3_host = "s3.govoplan.acceptance.example.org"
|
||||
ingress_class = "traefik"
|
||||
module_set = "base"
|
||||
api_replicas = 2
|
||||
web_replicas = 2
|
||||
worker_replicas = 2
|
||||
db_connection_limit = 100
|
||||
|
||||
[network]
|
||||
prefix_length = 24
|
||||
gateway = "10.77.10.1"
|
||||
dns_servers = ["10.77.10.1", "1.1.1.1"]
|
||||
bridge = "br0"
|
||||
|
||||
[image]
|
||||
url = "https://cloud-images.ubuntu.com/releases/noble/release-20260801/ubuntu-24.04-server-cloudimg-amd64.img"
|
||||
sha256 = "0533b0655c32e68b31d792ecd6ccfca95abdbc536c4446874fe0513bd4140ffe"
|
||||
|
||||
[k3s]
|
||||
version = "v1.36.1+k3s1"
|
||||
binary_url = "https://github.com/k3s-io/k3s/releases/download/v1.36.1%2Bk3s1/k3s"
|
||||
binary_sha256 = "a443db3fe9820cd93617ae67e4386d87c1514c1e96ceb30f4c2791c39065653c"
|
||||
install_script_url = "https://raw.githubusercontent.com/k3s-io/k3s/v1.36.1%2Bk3s1/install.sh"
|
||||
install_script_sha256 = "46177d4c99440b4c0311b67233823a8e8a2fc09693f6c89af1a7161e152fbfad"
|
||||
|
||||
[release]
|
||||
manifest_url = "https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/v0.1.15/distribution-manifest.json"
|
||||
manifest_sha256 = "09ac1ade6ede4958bab0dfb7fd8f99246f4d991846308db1f410b25b46267840"
|
||||
keyring_url = "https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/v0.1.15/distribution-keyring.json"
|
||||
keyring_sha256 = "92a9f8e3bac0ef525ad9a063c76faa36233a9070cd4a5db9ee9b3f92323b602f"
|
||||
channel = "stable"
|
||||
|
||||
[[nodes]]
|
||||
name = "control-1"
|
||||
role = "control"
|
||||
address = "10.77.10.21"
|
||||
hypervisor = "lab-admin@hypervisor-state.example.org"
|
||||
failure_domain = "rack-c"
|
||||
mac_address = "52:54:00:68:01:01"
|
||||
cpus = 2
|
||||
memory_mib = 4096
|
||||
disk_gib = 32
|
||||
|
||||
[[nodes]]
|
||||
name = "worker-1"
|
||||
role = "worker"
|
||||
address = "10.77.10.22"
|
||||
hypervisor = "lab-admin@hypervisor-a.example.org"
|
||||
failure_domain = "rack-a"
|
||||
mac_address = "52:54:00:68:01:02"
|
||||
cpus = 2
|
||||
memory_mib = 4096
|
||||
disk_gib = 40
|
||||
|
||||
[[nodes]]
|
||||
name = "worker-2"
|
||||
role = "worker"
|
||||
address = "10.77.10.23"
|
||||
hypervisor = "lab-admin@hypervisor-b.example.org"
|
||||
failure_domain = "rack-b"
|
||||
mac_address = "52:54:00:68:01:03"
|
||||
cpus = 2
|
||||
memory_mib = 4096
|
||||
disk_gib = 40
|
||||
|
||||
[[nodes]]
|
||||
name = "state-1"
|
||||
role = "state"
|
||||
address = "10.77.10.24"
|
||||
hypervisor = "lab-admin@hypervisor-state.example.org"
|
||||
failure_domain = "rack-c"
|
||||
mac_address = "52:54:00:68:01:04"
|
||||
cpus = 4
|
||||
memory_mib = 8192
|
||||
disk_gib = 120
|
||||
@@ -0,0 +1,85 @@
|
||||
schema_version = 1
|
||||
name = "govoplan-k8s-lab"
|
||||
mode = "rehearsal"
|
||||
state_directory = "~/.local/share/govoplan/labs/govoplan-k8s-lab"
|
||||
vm_image_directory = "/var/lib/libvirt/images/govoplan-labs"
|
||||
ssh_user = "govoplan"
|
||||
ssh_private_key = "~/.ssh/govoplan-lab"
|
||||
ssh_public_key = "~/.ssh/govoplan-lab.pub"
|
||||
namespace = "govoplan"
|
||||
public_host = "govoplan.lab.test"
|
||||
s3_host = "s3.govoplan.lab.test"
|
||||
ingress_class = "traefik"
|
||||
module_set = "base"
|
||||
api_replicas = 2
|
||||
web_replicas = 2
|
||||
worker_replicas = 2
|
||||
db_connection_limit = 100
|
||||
|
||||
[network]
|
||||
prefix_length = 24
|
||||
gateway = "192.168.123.1"
|
||||
dns_servers = ["192.168.123.1", "1.1.1.1"]
|
||||
bridge = "virbr-gplab"
|
||||
|
||||
[image]
|
||||
url = "https://cloud-images.ubuntu.com/releases/noble/release-20260801/ubuntu-24.04-server-cloudimg-amd64.img"
|
||||
sha256 = "0533b0655c32e68b31d792ecd6ccfca95abdbc536c4446874fe0513bd4140ffe"
|
||||
|
||||
[k3s]
|
||||
version = "v1.36.1+k3s1"
|
||||
binary_url = "https://github.com/k3s-io/k3s/releases/download/v1.36.1%2Bk3s1/k3s"
|
||||
binary_sha256 = "a443db3fe9820cd93617ae67e4386d87c1514c1e96ceb30f4c2791c39065653c"
|
||||
install_script_url = "https://raw.githubusercontent.com/k3s-io/k3s/v1.36.1%2Bk3s1/install.sh"
|
||||
install_script_sha256 = "46177d4c99440b4c0311b67233823a8e8a2fc09693f6c89af1a7161e152fbfad"
|
||||
|
||||
[release]
|
||||
manifest_url = "https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/v0.1.15/distribution-manifest.json"
|
||||
manifest_sha256 = "09ac1ade6ede4958bab0dfb7fd8f99246f4d991846308db1f410b25b46267840"
|
||||
keyring_url = "https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/v0.1.15/distribution-keyring.json"
|
||||
keyring_sha256 = "92a9f8e3bac0ef525ad9a063c76faa36233a9070cd4a5db9ee9b3f92323b602f"
|
||||
channel = "stable"
|
||||
|
||||
[[nodes]]
|
||||
name = "control-1"
|
||||
role = "control"
|
||||
address = "192.168.123.201"
|
||||
hypervisor = "local"
|
||||
failure_domain = "local-host"
|
||||
mac_address = "52:54:00:67:01:01"
|
||||
cpus = 2
|
||||
memory_mib = 4096
|
||||
disk_gib = 32
|
||||
|
||||
[[nodes]]
|
||||
name = "worker-1"
|
||||
role = "worker"
|
||||
address = "192.168.123.202"
|
||||
hypervisor = "local"
|
||||
failure_domain = "local-host"
|
||||
mac_address = "52:54:00:67:01:02"
|
||||
cpus = 2
|
||||
memory_mib = 4096
|
||||
disk_gib = 40
|
||||
|
||||
[[nodes]]
|
||||
name = "worker-2"
|
||||
role = "worker"
|
||||
address = "192.168.123.203"
|
||||
hypervisor = "local"
|
||||
failure_domain = "local-host"
|
||||
mac_address = "52:54:00:67:01:03"
|
||||
cpus = 2
|
||||
memory_mib = 4096
|
||||
disk_gib = 40
|
||||
|
||||
[[nodes]]
|
||||
name = "state-1"
|
||||
role = "state"
|
||||
address = "192.168.123.204"
|
||||
hypervisor = "local"
|
||||
failure_domain = "local-host"
|
||||
mac_address = "52:54:00:67:01:04"
|
||||
cpus = 4
|
||||
memory_mib = 4096
|
||||
disk_gib = 80
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Executable entry point for the GovOPlaN Kubernetes VM lab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from govoplan_lab.cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Reproducible GovOPlaN Kubernetes acceptance lab."""
|
||||
|
||||
from .config import LabConfig, LabConfigError, LabNode, load_config
|
||||
|
||||
__all__ = ["LabConfig", "LabConfigError", "LabNode", "load_config"]
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Command-line interface for the GovOPlaN Kubernetes VM lab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from .config import LabConfigError, load_config
|
||||
from .lifecycle import (
|
||||
LabOperationError,
|
||||
create,
|
||||
deploy,
|
||||
destroy,
|
||||
doctor,
|
||||
enroll_admin,
|
||||
pause,
|
||||
resume,
|
||||
status,
|
||||
update,
|
||||
verify,
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="govoplan-lab",
|
||||
description="Create and operate a libvirt-backed GovOPlaN Kubernetes test lab.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=Path,
|
||||
default=Path("govoplan-lab.toml"),
|
||||
help="Strict TOML lab inventory (default: ./govoplan-lab.toml).",
|
||||
)
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
doctor_parser = subparsers.add_parser("doctor", help="Validate inventory and prerequisites.")
|
||||
doctor_parser.add_argument(
|
||||
"--online",
|
||||
action="store_true",
|
||||
help="Also connect to every hypervisor and verify its toolchain.",
|
||||
)
|
||||
|
||||
subparsers.add_parser("status", help="Show VM, Kubernetes node, and pod state.")
|
||||
_mutation_parser(subparsers, "create", "Create or reuse all declared VMs.")
|
||||
_mutation_parser(subparsers, "deploy", "Deploy shared state, K3s, and GovOPlaN.")
|
||||
_mutation_parser(subparsers, "update", "Reconcile pinned K3s and GovOPlaN inputs serially.")
|
||||
_mutation_parser(subparsers, "pause", "Gracefully stop the lab while preserving disks.")
|
||||
_mutation_parser(subparsers, "resume", "Start a paused lab in dependency order.")
|
||||
|
||||
destroy_parser = _mutation_parser(
|
||||
subparsers,
|
||||
"destroy",
|
||||
"Destroy lab-owned VMs and disks with an explicit name confirmation.",
|
||||
)
|
||||
destroy_parser.add_argument("--confirm", default="")
|
||||
destroy_parser.add_argument(
|
||||
"--purge-local-state",
|
||||
action="store_true",
|
||||
help="Also delete local secrets, manifests, and evidence after VM teardown.",
|
||||
)
|
||||
|
||||
verify_parser = subparsers.add_parser(
|
||||
"verify",
|
||||
help=(
|
||||
"Collect sanitized live-cluster evidence using an "
|
||||
"Ops-read-authorized GOVOPLAN_OPS_API_KEY."
|
||||
),
|
||||
)
|
||||
verify_parser.add_argument(
|
||||
"--exercise-api-pod-loss",
|
||||
action="store_true",
|
||||
help="Delete one ready API pod during the bounded availability drill.",
|
||||
)
|
||||
enroll_parser = _mutation_parser(
|
||||
subparsers,
|
||||
"enroll-admin",
|
||||
"Securely consume the first-administrator enrollment artifact.",
|
||||
)
|
||||
enroll_parser.add_argument("--email", required=True)
|
||||
enroll_parser.add_argument("--display-name", default=None)
|
||||
enroll_parser.add_argument("--tenant-slug", default="default")
|
||||
enroll_parser.add_argument("--tenant-name", default="Default Tenant")
|
||||
return parser
|
||||
|
||||
|
||||
def _mutation_parser(
|
||||
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
||||
name: str,
|
||||
help_text: str,
|
||||
) -> argparse.ArgumentParser:
|
||||
parser = subparsers.add_parser(name, help=help_text)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Perform mutations; without this flag the command is a dry run.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
if args.command == "doctor":
|
||||
return doctor(config, online=args.online, verbose=args.verbose)
|
||||
if args.command == "status":
|
||||
return status(config, verbose=args.verbose)
|
||||
if args.command == "create":
|
||||
create(config, apply=args.apply, verbose=args.verbose)
|
||||
elif args.command == "deploy":
|
||||
deploy(config, apply=args.apply, verbose=args.verbose)
|
||||
elif args.command == "update":
|
||||
update(config, apply=args.apply, verbose=args.verbose)
|
||||
elif args.command == "pause":
|
||||
pause(config, apply=args.apply, verbose=args.verbose)
|
||||
elif args.command == "resume":
|
||||
resume(config, apply=args.apply, verbose=args.verbose)
|
||||
elif args.command == "destroy":
|
||||
destroy(
|
||||
config,
|
||||
apply=args.apply,
|
||||
confirmation=args.confirm,
|
||||
purge_local_state=args.purge_local_state,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
elif args.command == "verify":
|
||||
verify(
|
||||
config,
|
||||
exercise_api_pod_loss=args.exercise_api_pod_loss,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
elif args.command == "enroll-admin":
|
||||
enroll_admin(
|
||||
config,
|
||||
email=args.email,
|
||||
display_name=args.display_name,
|
||||
tenant_slug=args.tenant_slug,
|
||||
tenant_name=args.tenant_name,
|
||||
apply=args.apply,
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"unsupported command: {args.command}")
|
||||
return 0
|
||||
except (LabConfigError, LabOperationError, OSError, ValueError) as exc:
|
||||
print(f"error: {exc}", file=__import__("sys").stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,466 @@
|
||||
"""Strict TOML model for the GovOPlaN Kubernetes VM lab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tomllib
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
_NAME = re.compile(r"^[a-z][a-z0-9-]{1,47}$")
|
||||
_HOSTNAME = re.compile(
|
||||
r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
|
||||
r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$"
|
||||
)
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_K3S_VERSION = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+\+k3s[0-9]+$")
|
||||
_SSH_TARGET = re.compile(r"^(?:[A-Za-z0-9_.-]+@)?[A-Za-z0-9_.:-]+$")
|
||||
_BRIDGE = re.compile(r"^[A-Za-z0-9_.:-]{1,32}$")
|
||||
_ROLE = {"control", "worker", "state"}
|
||||
_MODE = {"rehearsal", "acceptance"}
|
||||
|
||||
|
||||
class LabConfigError(ValueError):
|
||||
"""Raised when the lab inventory cannot be used safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LabNode:
|
||||
name: str
|
||||
role: str
|
||||
address: ipaddress.IPv4Address
|
||||
hypervisor: str
|
||||
failure_domain: str
|
||||
mac_address: str
|
||||
cpus: int
|
||||
memory_mib: int
|
||||
disk_gib: int
|
||||
|
||||
@property
|
||||
def is_local(self) -> bool:
|
||||
return self.hypervisor == "local"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NetworkConfig:
|
||||
prefix_length: int
|
||||
gateway: ipaddress.IPv4Address
|
||||
dns_servers: tuple[ipaddress.IPv4Address, ...]
|
||||
bridge: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImageConfig:
|
||||
url: str
|
||||
sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class K3sConfig:
|
||||
version: str
|
||||
binary_url: str
|
||||
binary_sha256: str
|
||||
install_script_url: str
|
||||
install_script_sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReleaseConfig:
|
||||
manifest_url: str
|
||||
manifest_sha256: str
|
||||
keyring_url: str
|
||||
keyring_sha256: str
|
||||
channel: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LabConfig:
|
||||
source: Path
|
||||
schema_version: int
|
||||
name: str
|
||||
mode: str
|
||||
state_directory: Path
|
||||
vm_image_directory: str
|
||||
ssh_user: str
|
||||
ssh_private_key: Path
|
||||
ssh_public_key: Path
|
||||
namespace: str
|
||||
public_host: str
|
||||
s3_host: str
|
||||
ingress_class: str
|
||||
module_set: str
|
||||
api_replicas: int
|
||||
web_replicas: int
|
||||
worker_replicas: int
|
||||
db_connection_limit: int
|
||||
network: NetworkConfig
|
||||
image: ImageConfig
|
||||
k3s: K3sConfig
|
||||
release: ReleaseConfig
|
||||
nodes: tuple[LabNode, ...]
|
||||
|
||||
@property
|
||||
def controls(self) -> tuple[LabNode, ...]:
|
||||
return tuple(node for node in self.nodes if node.role == "control")
|
||||
|
||||
@property
|
||||
def workers(self) -> tuple[LabNode, ...]:
|
||||
return tuple(node for node in self.nodes if node.role == "worker")
|
||||
|
||||
@property
|
||||
def state_node(self) -> LabNode:
|
||||
return next(node for node in self.nodes if node.role == "state")
|
||||
|
||||
@property
|
||||
def primary_control(self) -> LabNode:
|
||||
return self.controls[0]
|
||||
|
||||
@property
|
||||
def public_url(self) -> str:
|
||||
return f"https://{self.public_host}"
|
||||
|
||||
@property
|
||||
def s3_url(self) -> str:
|
||||
return f"https://{self.s3_host}:9443"
|
||||
|
||||
@property
|
||||
def evidence_capable(self) -> bool:
|
||||
worker_domains = {node.failure_domain for node in self.workers}
|
||||
worker_hypervisors = {node.hypervisor for node in self.workers}
|
||||
return (
|
||||
self.mode == "acceptance"
|
||||
and len(worker_domains) == len(self.workers)
|
||||
and len(worker_hypervisors) == len(self.workers)
|
||||
and self.state_node.failure_domain not in worker_domains
|
||||
and self.state_node.hypervisor not in worker_hypervisors
|
||||
)
|
||||
|
||||
|
||||
def load_config(path: Path) -> LabConfig:
|
||||
source = path.expanduser().resolve()
|
||||
try:
|
||||
raw = tomllib.loads(source.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise LabConfigError(f"lab configuration does not exist: {source}") from exc
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
raise LabConfigError(f"lab configuration is not valid TOML: {exc}") from exc
|
||||
root = _mapping(raw, "lab configuration")
|
||||
_only_keys(
|
||||
root,
|
||||
{
|
||||
"schema_version",
|
||||
"name",
|
||||
"mode",
|
||||
"state_directory",
|
||||
"vm_image_directory",
|
||||
"ssh_user",
|
||||
"ssh_private_key",
|
||||
"ssh_public_key",
|
||||
"namespace",
|
||||
"public_host",
|
||||
"s3_host",
|
||||
"ingress_class",
|
||||
"module_set",
|
||||
"api_replicas",
|
||||
"web_replicas",
|
||||
"worker_replicas",
|
||||
"db_connection_limit",
|
||||
"network",
|
||||
"image",
|
||||
"k3s",
|
||||
"release",
|
||||
"nodes",
|
||||
},
|
||||
"lab configuration",
|
||||
)
|
||||
schema_version = _integer(root, "schema_version")
|
||||
if schema_version != SCHEMA_VERSION:
|
||||
raise LabConfigError(
|
||||
f"schema_version must be {SCHEMA_VERSION}; found {schema_version}"
|
||||
)
|
||||
name = _pattern(root, "name", _NAME)
|
||||
mode = _choice(root, "mode", _MODE)
|
||||
base = source.parent
|
||||
state_directory = _path(root, "state_directory", base)
|
||||
vm_image_directory = _absolute_posix_path(root, "vm_image_directory")
|
||||
ssh_user = _pattern(root, "ssh_user", re.compile(r"^[a-z_][a-z0-9_-]{0,31}$"))
|
||||
ssh_private_key = _path(root, "ssh_private_key", base)
|
||||
ssh_public_key = _path(root, "ssh_public_key", base)
|
||||
namespace = _pattern(root, "namespace", _NAME)
|
||||
public_host = _pattern(root, "public_host", _HOSTNAME)
|
||||
s3_host = _pattern(root, "s3_host", _HOSTNAME)
|
||||
if public_host == s3_host:
|
||||
raise LabConfigError("public_host and s3_host must be different")
|
||||
ingress_class = _pattern(root, "ingress_class", _NAME)
|
||||
module_set = _choice(root, "module_set", {"core", "base", "full"})
|
||||
api_replicas = _bounded_integer(root, "api_replicas", 2, 32)
|
||||
web_replicas = _bounded_integer(root, "web_replicas", 2, 32)
|
||||
worker_replicas = _bounded_integer(root, "worker_replicas", 2, 64)
|
||||
db_connection_limit = _bounded_integer(root, "db_connection_limit", 50, 10000)
|
||||
network = _parse_network(_mapping(root.get("network"), "network"))
|
||||
image = _parse_image(_mapping(root.get("image"), "image"))
|
||||
k3s = _parse_k3s(_mapping(root.get("k3s"), "k3s"))
|
||||
release = _parse_release(_mapping(root.get("release"), "release"))
|
||||
raw_nodes = root.get("nodes")
|
||||
if not isinstance(raw_nodes, list) or not raw_nodes:
|
||||
raise LabConfigError("nodes must be a non-empty array of tables")
|
||||
nodes = tuple(_parse_node(item, index=index) for index, item in enumerate(raw_nodes))
|
||||
config = LabConfig(
|
||||
source=source,
|
||||
schema_version=schema_version,
|
||||
name=name,
|
||||
mode=mode,
|
||||
state_directory=state_directory,
|
||||
vm_image_directory=vm_image_directory.rstrip("/"),
|
||||
ssh_user=ssh_user,
|
||||
ssh_private_key=ssh_private_key,
|
||||
ssh_public_key=ssh_public_key,
|
||||
namespace=namespace,
|
||||
public_host=public_host,
|
||||
s3_host=s3_host,
|
||||
ingress_class=ingress_class,
|
||||
module_set=module_set,
|
||||
api_replicas=api_replicas,
|
||||
web_replicas=web_replicas,
|
||||
worker_replicas=worker_replicas,
|
||||
db_connection_limit=db_connection_limit,
|
||||
network=network,
|
||||
image=image,
|
||||
k3s=k3s,
|
||||
release=release,
|
||||
nodes=nodes,
|
||||
)
|
||||
_validate_topology(config)
|
||||
return config
|
||||
|
||||
|
||||
def _parse_network(raw: Mapping[str, Any]) -> NetworkConfig:
|
||||
_only_keys(raw, {"prefix_length", "gateway", "dns_servers", "bridge"}, "network")
|
||||
prefix_length = _bounded_integer(raw, "prefix_length", 8, 30)
|
||||
gateway = _ipv4(raw, "gateway")
|
||||
dns_raw = raw.get("dns_servers")
|
||||
if not isinstance(dns_raw, list) or not dns_raw or len(dns_raw) > 4:
|
||||
raise LabConfigError("network.dns_servers must contain 1-4 IPv4 addresses")
|
||||
dns_servers = tuple(_ipv4_value(value, "network.dns_servers") for value in dns_raw)
|
||||
bridge = _pattern(raw, "bridge", _BRIDGE)
|
||||
return NetworkConfig(prefix_length, gateway, dns_servers, bridge)
|
||||
|
||||
|
||||
def _parse_image(raw: Mapping[str, Any]) -> ImageConfig:
|
||||
_only_keys(raw, {"url", "sha256"}, "image")
|
||||
return ImageConfig(
|
||||
url=_https_url(raw, "url"),
|
||||
sha256=_pattern(raw, "sha256", _SHA256),
|
||||
)
|
||||
|
||||
|
||||
def _parse_k3s(raw: Mapping[str, Any]) -> K3sConfig:
|
||||
_only_keys(
|
||||
raw,
|
||||
{
|
||||
"version",
|
||||
"binary_url",
|
||||
"binary_sha256",
|
||||
"install_script_url",
|
||||
"install_script_sha256",
|
||||
},
|
||||
"k3s",
|
||||
)
|
||||
return K3sConfig(
|
||||
version=_pattern(raw, "version", _K3S_VERSION),
|
||||
binary_url=_https_url(raw, "binary_url"),
|
||||
binary_sha256=_pattern(raw, "binary_sha256", _SHA256),
|
||||
install_script_url=_https_url(raw, "install_script_url"),
|
||||
install_script_sha256=_pattern(raw, "install_script_sha256", _SHA256),
|
||||
)
|
||||
|
||||
|
||||
def _parse_release(raw: Mapping[str, Any]) -> ReleaseConfig:
|
||||
_only_keys(
|
||||
raw,
|
||||
{"manifest_url", "manifest_sha256", "keyring_url", "keyring_sha256", "channel"},
|
||||
"release",
|
||||
)
|
||||
return ReleaseConfig(
|
||||
manifest_url=_https_url(raw, "manifest_url"),
|
||||
manifest_sha256=_pattern(raw, "manifest_sha256", _SHA256),
|
||||
keyring_url=_https_url(raw, "keyring_url"),
|
||||
keyring_sha256=_pattern(raw, "keyring_sha256", _SHA256),
|
||||
channel=_pattern(raw, "channel", _NAME),
|
||||
)
|
||||
|
||||
|
||||
def _parse_node(value: object, *, index: int) -> LabNode:
|
||||
raw = _mapping(value, f"nodes[{index}]")
|
||||
_only_keys(
|
||||
raw,
|
||||
{
|
||||
"name",
|
||||
"role",
|
||||
"address",
|
||||
"hypervisor",
|
||||
"failure_domain",
|
||||
"mac_address",
|
||||
"cpus",
|
||||
"memory_mib",
|
||||
"disk_gib",
|
||||
},
|
||||
f"nodes[{index}]",
|
||||
)
|
||||
hypervisor = _string(raw, "hypervisor")
|
||||
if hypervisor != "local" and not _SSH_TARGET.fullmatch(hypervisor):
|
||||
raise LabConfigError(
|
||||
f"nodes[{index}].hypervisor must be 'local' or a simple SSH target"
|
||||
)
|
||||
mac_address = _string(raw, "mac_address").lower()
|
||||
try:
|
||||
octets = mac_address.split(":")
|
||||
valid_mac = len(octets) == 6 and all(
|
||||
len(octet) == 2 and 0 <= int(octet, 16) <= 255 for octet in octets
|
||||
)
|
||||
except ValueError:
|
||||
valid_mac = False
|
||||
if not valid_mac:
|
||||
raise LabConfigError(f"nodes[{index}].mac_address is not a canonical MAC address")
|
||||
return LabNode(
|
||||
name=_pattern(raw, "name", _NAME),
|
||||
role=_choice(raw, "role", _ROLE),
|
||||
address=_ipv4(raw, "address"),
|
||||
hypervisor=hypervisor,
|
||||
failure_domain=_pattern(raw, "failure_domain", _NAME),
|
||||
mac_address=mac_address,
|
||||
cpus=_bounded_integer(raw, "cpus", 1, 64),
|
||||
memory_mib=_bounded_integer(raw, "memory_mib", 2048, 262144),
|
||||
disk_gib=_bounded_integer(raw, "disk_gib", 16, 4096),
|
||||
)
|
||||
|
||||
|
||||
def _validate_topology(config: LabConfig) -> None:
|
||||
names = [node.name for node in config.nodes]
|
||||
addresses = [node.address for node in config.nodes]
|
||||
mac_addresses = [node.mac_address for node in config.nodes]
|
||||
for label, values in (
|
||||
("node names", names),
|
||||
("node addresses", addresses),
|
||||
("node MAC addresses", mac_addresses),
|
||||
):
|
||||
if len(values) != len(set(values)):
|
||||
raise LabConfigError(f"{label} must be unique")
|
||||
too_long = [
|
||||
node.name
|
||||
for node in config.nodes
|
||||
if len(f"{config.name}-{node.name}") > 63
|
||||
]
|
||||
if too_long:
|
||||
raise LabConfigError(
|
||||
"lab name plus node name must fit the 63-character libvirt domain limit: "
|
||||
+ ", ".join(too_long)
|
||||
)
|
||||
if len(config.controls) not in {1, 3}:
|
||||
raise LabConfigError("the lab requires exactly one or three control-plane nodes")
|
||||
if len(config.workers) < 2:
|
||||
raise LabConfigError("the lab requires at least two worker nodes")
|
||||
if sum(node.role == "state" for node in config.nodes) != 1:
|
||||
raise LabConfigError("the lab requires exactly one external shared-state node")
|
||||
network = ipaddress.ip_network(
|
||||
f"{config.network.gateway}/{config.network.prefix_length}", strict=False
|
||||
)
|
||||
if any(node.address not in network for node in config.nodes):
|
||||
raise LabConfigError("every node address must be in the configured IPv4 network")
|
||||
if config.network.gateway in addresses:
|
||||
raise LabConfigError("the network gateway cannot also be a node address")
|
||||
if config.mode == "acceptance" and not config.evidence_capable:
|
||||
raise LabConfigError(
|
||||
"acceptance mode requires each worker and the shared-state node to use "
|
||||
"distinct hypervisors and failure_domain values"
|
||||
)
|
||||
|
||||
|
||||
def _mapping(value: object, label: str) -> Mapping[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise LabConfigError(f"{label} must be a table")
|
||||
return value
|
||||
|
||||
|
||||
def _only_keys(raw: Mapping[str, Any], allowed: set[str], label: str) -> None:
|
||||
unexpected = sorted(set(raw) - allowed)
|
||||
if unexpected:
|
||||
raise LabConfigError(f"{label} contains unsupported keys: {', '.join(unexpected)}")
|
||||
|
||||
|
||||
def _string(raw: Mapping[str, Any], key: str) -> str:
|
||||
value = raw.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise LabConfigError(f"{key} must be a non-empty string")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _integer(raw: Mapping[str, Any], key: str) -> int:
|
||||
value = raw.get(key)
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise LabConfigError(f"{key} must be an integer")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_integer(raw: Mapping[str, Any], key: str, minimum: int, maximum: int) -> int:
|
||||
value = _integer(raw, key)
|
||||
if not minimum <= value <= maximum:
|
||||
raise LabConfigError(f"{key} must be between {minimum} and {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def _pattern(raw: Mapping[str, Any], key: str, pattern: re.Pattern[str]) -> str:
|
||||
value = _string(raw, key)
|
||||
if pattern.fullmatch(value) is None:
|
||||
raise LabConfigError(f"{key} has an unsupported format")
|
||||
return value
|
||||
|
||||
|
||||
def _choice(raw: Mapping[str, Any], key: str, choices: set[str]) -> str:
|
||||
value = _string(raw, key)
|
||||
if value not in choices:
|
||||
raise LabConfigError(f"{key} must be one of: {', '.join(sorted(choices))}")
|
||||
return value
|
||||
|
||||
|
||||
def _ipv4(raw: Mapping[str, Any], key: str) -> ipaddress.IPv4Address:
|
||||
return _ipv4_value(_string(raw, key), key)
|
||||
|
||||
|
||||
def _ipv4_value(value: object, label: str) -> ipaddress.IPv4Address:
|
||||
if not isinstance(value, str):
|
||||
raise LabConfigError(f"{label} must contain strings")
|
||||
try:
|
||||
parsed = ipaddress.ip_address(value)
|
||||
except ValueError as exc:
|
||||
raise LabConfigError(f"{label} contains an invalid IP address") from exc
|
||||
if not isinstance(parsed, ipaddress.IPv4Address):
|
||||
raise LabConfigError(f"{label} supports IPv4 only in schema version 1")
|
||||
return parsed
|
||||
|
||||
|
||||
def _path(raw: Mapping[str, Any], key: str, base: Path) -> Path:
|
||||
value = Path(_string(raw, key)).expanduser()
|
||||
return (value if value.is_absolute() else base / value).resolve()
|
||||
|
||||
|
||||
def _absolute_posix_path(raw: Mapping[str, Any], key: str) -> str:
|
||||
value = _string(raw, key)
|
||||
if not value.startswith("/") or ".." in Path(value).parts:
|
||||
raise LabConfigError(f"{key} must be an absolute path without '..'")
|
||||
return value
|
||||
|
||||
|
||||
def _https_url(raw: Mapping[str, Any], key: str) -> str:
|
||||
value = _string(raw, key)
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
|
||||
raise LabConfigError(f"{key} must be a credential-free HTTPS URL")
|
||||
if parsed.fragment:
|
||||
raise LabConfigError(f"{key} must not contain a fragment")
|
||||
return value
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,311 @@
|
||||
"""Deterministic configuration rendering for the Kubernetes VM lab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from .config import LabConfig, LabNode
|
||||
|
||||
|
||||
def render_cloud_init(config: LabConfig, node: LabNode, public_key: str) -> str:
|
||||
packages = ["ca-certificates", "curl", "qemu-guest-agent"]
|
||||
if node.role == "state":
|
||||
packages.extend(["docker.io", "docker-compose-v2", "openssl"])
|
||||
package_lines = "\n".join(f" - {item}" for item in packages)
|
||||
return f"""#cloud-config
|
||||
hostname: {node.name}
|
||||
manage_etc_hosts: true
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
packages:
|
||||
{package_lines}
|
||||
users:
|
||||
- default
|
||||
- name: {config.ssh_user}
|
||||
groups: [adm, sudo]
|
||||
shell: /bin/bash
|
||||
sudo: ALL=(ALL) NOPASSWD:ALL
|
||||
lock_passwd: true
|
||||
ssh_authorized_keys:
|
||||
- {json.dumps(public_key.strip())}
|
||||
ssh_pwauth: false
|
||||
disable_root: true
|
||||
runcmd:
|
||||
- [systemctl, enable, --now, qemu-guest-agent]
|
||||
- [sh, -c, "test ! -e /usr/bin/docker || systemctl enable --now docker"]
|
||||
final_message: "GovOPlaN lab node is ready"
|
||||
"""
|
||||
|
||||
|
||||
def render_network_config(config: LabConfig, node: LabNode) -> str:
|
||||
dns = ", ".join(str(item) for item in config.network.dns_servers)
|
||||
return f"""version: 2
|
||||
ethernets:
|
||||
primary:
|
||||
match:
|
||||
macaddress: {node.mac_address}
|
||||
set-name: eth0
|
||||
addresses:
|
||||
- {node.address}/{config.network.prefix_length}
|
||||
routes:
|
||||
- to: default
|
||||
via: {config.network.gateway}
|
||||
nameservers:
|
||||
addresses: [{dns}]
|
||||
"""
|
||||
|
||||
|
||||
def render_meta_data(config: LabConfig, node: LabNode) -> str:
|
||||
return f"instance-id: {config.name}-{node.name}\nlocal-hostname: {node.name}\n"
|
||||
|
||||
|
||||
def render_k3s_config(
|
||||
config: LabConfig,
|
||||
node: LabNode,
|
||||
*,
|
||||
cluster_token: str,
|
||||
) -> str:
|
||||
lines = [
|
||||
f'node-name: "{node.name}"',
|
||||
f'node-ip: "{node.address}"',
|
||||
f'token: "{cluster_token}"',
|
||||
]
|
||||
if node.role == "control":
|
||||
if node == config.primary_control:
|
||||
lines.append("cluster-init: true")
|
||||
else:
|
||||
lines.append(f'server: "https://{config.primary_control.address}:6443"')
|
||||
lines.extend(
|
||||
[
|
||||
'write-kubeconfig-mode: "0600"',
|
||||
"secrets-encryption: true",
|
||||
"tls-san:",
|
||||
f' - "{config.primary_control.address}"',
|
||||
"node-taint:",
|
||||
' - "node-role.kubernetes.io/control-plane=true:NoSchedule"',
|
||||
]
|
||||
)
|
||||
elif node.role == "worker":
|
||||
lines.extend(
|
||||
[
|
||||
f'server: "https://{config.primary_control.address}:6443"',
|
||||
"node-label:",
|
||||
' - "govoplan.add-ideas.de/runtime=true"',
|
||||
f' - "topology.govoplan.add-ideas.de/failure-domain={node.failure_domain}"',
|
||||
]
|
||||
)
|
||||
else:
|
||||
raise ValueError("state nodes do not receive K3s configuration")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def render_registry_config(username: str, password: str) -> str:
|
||||
if not username and not password:
|
||||
return ""
|
||||
if not username or not password:
|
||||
raise ValueError("registry username and password must be supplied together")
|
||||
return (
|
||||
'mirrors:\n "git.add-ideas.de":\n'
|
||||
' endpoint:\n - "https://git.add-ideas.de"\n'
|
||||
'configs:\n "git.add-ideas.de":\n auth:\n'
|
||||
f" username: {json.dumps(username)}\n"
|
||||
f" password: {json.dumps(password)}\n"
|
||||
)
|
||||
|
||||
|
||||
def render_garage_config() -> str:
|
||||
return """metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
db_engine = "sqlite"
|
||||
|
||||
replication_factor = 1
|
||||
|
||||
rpc_bind_addr = "[::]:3901"
|
||||
rpc_public_addr = "127.0.0.1:3901"
|
||||
|
||||
[s3_api]
|
||||
s3_region = "garage"
|
||||
api_bind_addr = "[::]:3900"
|
||||
root_domain = ".s3.garage.localhost"
|
||||
|
||||
[admin]
|
||||
api_bind_addr = "[::]:3903"
|
||||
"""
|
||||
|
||||
|
||||
def render_caddyfile() -> str:
|
||||
return """:9443 {
|
||||
tls /etc/caddy/tls/server.crt /etc/caddy/tls/server.key
|
||||
reverse_proxy garage:3900
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def render_state_compose(images: Mapping[str, str]) -> str:
|
||||
required = {"postgres", "redis", "garage", "managed_ingress", "test_mail"}
|
||||
missing = sorted(required - set(images))
|
||||
if missing:
|
||||
raise ValueError("release manifest is missing state images: " + ", ".join(missing))
|
||||
value = {
|
||||
"name": "govoplan-lab-state",
|
||||
"services": {
|
||||
"postgres": {
|
||||
"image": images["postgres"],
|
||||
"restart": "unless-stopped",
|
||||
"environment": {
|
||||
"POSTGRES_DB": "${POSTGRES_DB}",
|
||||
"POSTGRES_USER": "${POSTGRES_USER}",
|
||||
"POSTGRES_PASSWORD": "${POSTGRES_PASSWORD}",
|
||||
},
|
||||
"ports": ["5432:5432"],
|
||||
"healthcheck": {
|
||||
"test": [
|
||||
"CMD-SHELL",
|
||||
'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"',
|
||||
],
|
||||
"interval": "5s",
|
||||
"timeout": "3s",
|
||||
"retries": 30,
|
||||
},
|
||||
"volumes": ["postgres-data:/var/lib/postgresql/data"],
|
||||
"networks": ["internal"],
|
||||
},
|
||||
"redis": {
|
||||
"image": images["redis"],
|
||||
"restart": "unless-stopped",
|
||||
"command": [
|
||||
"sh",
|
||||
"-ec",
|
||||
'exec redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"',
|
||||
],
|
||||
"environment": {"REDIS_PASSWORD": "${REDIS_PASSWORD}"},
|
||||
"ports": ["6379:6379"],
|
||||
"healthcheck": {
|
||||
"test": [
|
||||
"CMD-SHELL",
|
||||
'redis-cli -a "$${REDIS_PASSWORD}" --no-auth-warning ping',
|
||||
],
|
||||
"interval": "5s",
|
||||
"timeout": "3s",
|
||||
"retries": 30,
|
||||
},
|
||||
"volumes": ["redis-data:/data"],
|
||||
"networks": ["internal"],
|
||||
},
|
||||
"garage": {
|
||||
"image": images["garage"],
|
||||
"restart": "unless-stopped",
|
||||
"command": ["/garage", "server", "--single-node", "--default-bucket"],
|
||||
"environment": {
|
||||
"GARAGE_DEFAULT_ACCESS_KEY": "${GARAGE_DEFAULT_ACCESS_KEY}",
|
||||
"GARAGE_DEFAULT_SECRET_KEY": "${GARAGE_DEFAULT_SECRET_KEY}",
|
||||
"GARAGE_DEFAULT_BUCKET": "${GARAGE_DEFAULT_BUCKET}",
|
||||
"GARAGE_RPC_SECRET": "${GARAGE_RPC_SECRET}",
|
||||
"GARAGE_ADMIN_TOKEN": "${GARAGE_ADMIN_TOKEN}",
|
||||
"GARAGE_METRICS_TOKEN": "${GARAGE_METRICS_TOKEN}",
|
||||
},
|
||||
"healthcheck": {
|
||||
"test": ["CMD", "/garage", "status"],
|
||||
"interval": "10s",
|
||||
"timeout": "5s",
|
||||
"retries": 30,
|
||||
"start_period": "15s",
|
||||
},
|
||||
"security_opt": ["no-new-privileges:true"],
|
||||
"volumes": [
|
||||
"./garage.toml:/etc/garage.toml:ro",
|
||||
"garage-meta:/var/lib/garage/meta",
|
||||
"garage-data:/var/lib/garage/data",
|
||||
],
|
||||
"networks": ["internal"],
|
||||
},
|
||||
"s3-tls": {
|
||||
"image": images["managed_ingress"],
|
||||
"restart": "unless-stopped",
|
||||
"depends_on": {"garage": {"condition": "service_healthy"}},
|
||||
"ports": ["9443:9443"],
|
||||
"volumes": [
|
||||
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
||||
"./tls:/etc/caddy/tls:ro",
|
||||
],
|
||||
"security_opt": ["no-new-privileges:true"],
|
||||
"networks": ["internal"],
|
||||
},
|
||||
"test-mail": {
|
||||
"image": images["test_mail"],
|
||||
"restart": "unless-stopped",
|
||||
"environment": {
|
||||
"GREENMAIL_OPTS": (
|
||||
"-Dgreenmail.setup.test.smtp -Dgreenmail.setup.test.imap "
|
||||
"-Dgreenmail.hostname=0.0.0.0"
|
||||
)
|
||||
},
|
||||
"ports": ["3025:3025", "3143:3143"],
|
||||
"networks": ["internal"],
|
||||
},
|
||||
},
|
||||
"volumes": {
|
||||
"postgres-data": {},
|
||||
"redis-data": {},
|
||||
"garage-meta": {},
|
||||
"garage-data": {},
|
||||
},
|
||||
"networks": {"internal": {"driver": "bridge"}},
|
||||
}
|
||||
return json.dumps(value, indent=2, sort_keys=True) + "\n"
|
||||
|
||||
|
||||
def render_state_environment(values: Mapping[str, str]) -> str:
|
||||
return "".join(f"{key}={_env_quote(value)}\n" for key, value in sorted(values.items()))
|
||||
|
||||
|
||||
def render_secret_manifest(
|
||||
*, namespace: str, name: str, values: Mapping[str, bytes | str]
|
||||
) -> bytes:
|
||||
encoded = {
|
||||
key: base64.b64encode(value.encode() if isinstance(value, str) else value).decode()
|
||||
for key, value in sorted(values.items())
|
||||
}
|
||||
payload = {
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": {"name": name, "namespace": namespace},
|
||||
"type": "Opaque",
|
||||
"data": encoded,
|
||||
}
|
||||
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode()
|
||||
|
||||
|
||||
def render_tls_secret_manifest(
|
||||
*, namespace: str, name: str, certificate: bytes, private_key: bytes
|
||||
) -> bytes:
|
||||
payload = json.loads(
|
||||
render_secret_manifest(
|
||||
namespace=namespace,
|
||||
name=name,
|
||||
values={"tls.crt": certificate, "tls.key": private_key},
|
||||
)
|
||||
)
|
||||
payload["type"] = "kubernetes.io/tls"
|
||||
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode()
|
||||
|
||||
|
||||
def render_hosts(config: LabConfig) -> str:
|
||||
return (
|
||||
f"{config.primary_control.address} {config.public_host}\n"
|
||||
f"{config.state_node.address} {config.s3_host}\n"
|
||||
)
|
||||
|
||||
|
||||
def _env_quote(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=True)
|
||||
|
||||
|
||||
def write_private(path: Path, value: str | bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
data = value.encode() if isinstance(value, str) else value
|
||||
path.write_bytes(data)
|
||||
path.chmod(0o600)
|
||||
@@ -0,0 +1,11 @@
|
||||
<network>
|
||||
<name>govoplan-lab</name>
|
||||
<forward mode="nat"/>
|
||||
<bridge name="virbr-gplab" stp="on" delay="0"/>
|
||||
<domain name="lab.test" localOnly="yes"/>
|
||||
<ip address="192.168.123.1" netmask="255.255.255.0">
|
||||
<dhcp>
|
||||
<range start="192.168.123.2" end="192.168.123.99"/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>
|
||||
Reference in New Issue
Block a user