Release v0.1.16
Dependency Audit / dependency-audit (push) Failing after 1m47s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m18s
Developer Meta-package Release / publish-package (push) Successful in 11s

This commit is contained in:
2026-08-05 19:52:32 +02:00
parent 61463a24cb
commit 3ca068f76a
42 changed files with 5433 additions and 189 deletions
+54
View File
@@ -128,6 +128,13 @@ def main() -> int:
manifest=manifest,
)
)
errors.extend(
_information_governance_evidence_errors(
repository_name=repository_name,
repository_root=repository_root,
manifest=manifest,
)
)
manifests.append(manifest)
@@ -153,6 +160,20 @@ def main() -> int:
f"Architecture declaration coverage: {declared}/{len(manifests)} modules "
f"({(declared / len(manifests) * 100):.1f}%)."
)
governance_counts: dict[str, int] = {}
for manifest in manifests:
for dimension in manifest.information_governance.dimensions.values():
governance_counts[dimension.adoption] = (
governance_counts.get(dimension.adoption, 0) + 1
)
print(
"Information-governance adoption: "
+ ", ".join(
f"{status}={count}"
for status, count in sorted(governance_counts.items())
)
+ "."
)
return 0
@@ -201,6 +222,39 @@ def _architecture_evidence_errors(
return errors
def _information_governance_evidence_errors(
*,
repository_name: str,
repository_root: Path,
manifest: object,
) -> list[str]:
declaration = getattr(manifest, "information_governance", None)
if declaration is None:
return [
f"{repository_name}: module has no information-governance declaration"
]
errors: list[str] = []
for dimension_name, dimension in declaration.dimensions.items():
for reference in dimension.evidence:
if not _looks_like_repository_reference(reference):
continue
candidate = (repository_root / reference).resolve()
try:
candidate.relative_to(repository_root.resolve())
except ValueError:
errors.append(
f"{repository_name}: {dimension_name} evidence escapes the "
f"repository: {reference!r}"
)
continue
if not candidate.exists():
errors.append(
f"{repository_name}: {dimension_name} evidence does not exist: "
f"{reference!r}"
)
return errors
def _looks_like_repository_reference(reference: str) -> bool:
normalized = reference.strip()
if not normalized or "://" in normalized:
+14 -2
View File
@@ -219,6 +219,13 @@ def build_parser() -> argparse.ArgumentParser:
kubernetes.add_argument("--namespace", default="govoplan")
kubernetes.add_argument("--secret-name", default="govoplan-runtime")
kubernetes.add_argument("--tls-secret-name", default="govoplan-tls")
kubernetes.add_argument(
"--s3-ca-secret-name",
help=(
"Optional Secret containing ca.crt for the external S3 endpoint; "
"mounted read-only into backend runtime roles."
),
)
kubernetes.add_argument("--ingress-class-name")
kubernetes.add_argument(
"--output",
@@ -239,7 +246,10 @@ def build_parser() -> argparse.ArgumentParser:
verify_kubernetes.add_argument(
"--api-key-env",
default="GOVOPLAN_OPS_API_KEY",
help="Environment variable containing an API key with Ops read scope.",
help=(
"Environment variable containing an API key authorized to read "
"Ops status."
),
)
verify_kubernetes.add_argument(
"--exercise-api-pod-loss",
@@ -1146,6 +1156,7 @@ def _render_kubernetes(args: argparse.Namespace) -> int:
namespace=args.namespace,
secret_name=args.secret_name,
tls_secret_name=args.tls_secret_name,
s3_ca_secret_name=args.s3_ca_secret_name,
ingress_class_name=args.ingress_class_name,
backup_required=backup_required,
backup_evidence=backup_summary,
@@ -1170,7 +1181,8 @@ def _verify_kubernetes(args: argparse.Namespace) -> int:
api_key = str(os.environ.get(args.api_key_env) or "").strip()
if not api_key:
raise ValueError(
f"{args.api_key_env} must contain an API key with Ops read scope"
f"{args.api_key_env} must contain an API key authorized to read "
"Ops status"
)
ops_url = args.ops_url or (spec.public_url.rstrip("/") + "/api/v1/ops/status")
evidence = collect_kubernetes_evidence(
@@ -14,6 +14,7 @@ from urllib.request import Request, urlopen
JsonObject = dict[str, Any]
CommandRunner = Callable[[Sequence[str]], JsonObject]
ActionRunner = Callable[[Sequence[str]], None]
JsonFetcher = Callable[[str, str], JsonObject]
@@ -26,11 +27,13 @@ def collect_kubernetes_evidence(
exercise_api_pod_loss: bool = False,
timeout_seconds: float = 180.0,
command_runner: CommandRunner | None = None,
action_runner: ActionRunner | None = None,
json_fetcher: JsonFetcher | None = None,
) -> JsonObject:
"""Inspect a live cluster and optionally exercise one API pod replacement."""
run_json = command_runner or _kubectl_json
run_action = action_runner or _kubectl_action
fetch_json = json_fetcher or _fetch_json
nodes = run_json(("get", "nodes", "-o", "json"))
pods = run_json(
@@ -76,6 +79,7 @@ def collect_kubernetes_evidence(
initial_pods=pods,
timeout_seconds=timeout_seconds,
run_json=run_json,
run_action=run_action,
fetch_json=fetch_json,
)
evidence = {
@@ -211,6 +215,7 @@ def _exercise_api_pod_loss(
initial_pods: Mapping[str, Any],
timeout_seconds: float,
run_json: CommandRunner,
run_action: ActionRunner,
fetch_json: JsonFetcher,
) -> JsonObject:
candidates = [
@@ -228,7 +233,7 @@ def _exercise_api_pod_loss(
victim = sorted(candidates, key=lambda item: item["name"])[0]
initial_uids = {item["uid"] for item in candidates}
desired_ready = len(candidates)
run_json(
run_action(
(
"-n",
namespace,
@@ -236,8 +241,6 @@ def _exercise_api_pod_loss(
"pod",
victim["name"],
"--wait=false",
"-o",
"json",
)
)
deadline = time.monotonic() + timeout_seconds
@@ -309,6 +312,22 @@ def _kubectl_json(arguments: Sequence[str]) -> JsonObject:
return payload
def _kubectl_action(arguments: Sequence[str]) -> None:
kubectl = shutil.which("kubectl")
if kubectl is None:
raise ValueError("kubectl is required for Kubernetes evidence collection")
result = subprocess.run(
(kubectl, *arguments),
check=False,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode:
detail = result.stderr.strip() or result.stdout.strip()
raise ValueError(f"kubectl failed: {detail}")
def _fetch_json(url: str, api_key: str) -> JsonObject:
request = Request(
url,
+89 -5
View File
@@ -76,13 +76,20 @@ def render_kubernetes(
namespace: str = "govoplan",
secret_name: str = "govoplan-runtime",
tls_secret_name: str = "govoplan-tls",
s3_ca_secret_name: str | None = None,
ingress_class_name: str | None = None,
backup_required: bool = True,
backup_evidence: Mapping[str, object] | None = None,
) -> dict[str, Any]:
"""Render runtime roles only; shared state services stay externally managed."""
_validate_cluster_profile(spec, environment, namespace, secret_name)
_validate_cluster_profile(
spec,
environment,
namespace,
secret_name,
s3_ca_secret_name=s3_ca_secret_name,
)
worker_pools = _worker_pools(spec, environment)
database_capacity = _database_capacity(spec, environment, worker_pools)
name = _resource_name(spec.installation_id)
@@ -155,6 +162,7 @@ def render_kubernetes(
),
config_name=config_name,
secret_name=secret_name,
s3_ca_secret_name=s3_ca_secret_name,
service_account=service_account,
container_port=8000,
readiness_path="/health/ready",
@@ -179,6 +187,7 @@ def render_kubernetes(
command=(),
config_name=None,
secret_name=None,
s3_ca_secret_name=None,
service_account=service_account,
container_port=8080,
extra_environment={"GOVOPLAN_API_UPSTREAM": f"http://{name}-api:8000"},
@@ -198,6 +207,7 @@ def render_kubernetes(
image=spec.release.api_image,
config_name=config_name,
secret_name=secret_name,
s3_ca_secret_name=s3_ca_secret_name,
service_account=service_account,
database_environment=_role_database_environment(
environment,
@@ -237,6 +247,7 @@ def render_kubernetes(
),
config_name=config_name,
secret_name=secret_name,
s3_ca_secret_name=s3_ca_secret_name,
service_account=service_account,
extra_environment={
**_role_database_environment(environment, "WORKER"),
@@ -287,9 +298,12 @@ def render_kubernetes(
"beat",
"--loglevel",
"INFO",
"--schedule",
"/tmp/celerybeat-schedule",
),
config_name=config_name,
secret_name=secret_name,
s3_ca_secret_name=s3_ca_secret_name,
service_account=service_account,
extra_environment=_role_database_environment(
environment,
@@ -318,6 +332,7 @@ def render_kubernetes(
"annotations": {
"govoplan.add-ideas.de/profile": "stateless-shared-state",
"govoplan.add-ideas.de/secret-contract": ",".join(_SECRET_KEYS),
"govoplan.add-ideas.de/s3-ca-secret": s3_ca_secret_name or "",
"govoplan.add-ideas.de/database-connection-peak": str(
database_capacity["peak"]
),
@@ -342,9 +357,13 @@ def _validate_cluster_profile(
environment: Mapping[str, str],
namespace: str,
secret_name: str,
*,
s3_ca_secret_name: str | None,
) -> None:
if not _DNS_LABEL.fullmatch(namespace) or not _DNS_LABEL.fullmatch(secret_name):
raise ValueError("Kubernetes namespace and secret names must be DNS labels")
if s3_ca_secret_name is not None and not _DNS_LABEL.fullmatch(s3_ca_secret_name):
raise ValueError("Kubernetes S3 CA secret name must be a DNS label")
if spec.installation_id == "govoplan-local":
raise ValueError(
"Kubernetes export requires a non-default stable installation id"
@@ -633,6 +652,7 @@ def _deployment(
command: tuple[str, ...],
config_name: str | None,
secret_name: str | None,
s3_ca_secret_name: str | None,
service_account: str,
container_port: int | None = None,
readiness_path: str | None = None,
@@ -660,6 +680,10 @@ def _deployment(
)
if secret_name:
environment.extend(_secret_environment(secret_name))
if s3_ca_secret_name:
environment.append(
{"name": "AWS_CA_BUNDLE", "value": "/etc/govoplan/trust/s3-ca.crt"}
)
container: dict[str, Any] = {
"name": role,
"image": image,
@@ -704,12 +728,16 @@ def _deployment(
{
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway",
"whenUnsatisfiable": "DoNotSchedule",
"matchLabelKeys": ["pod-template-hash"],
"labelSelector": {"matchLabels": role_labels},
}
],
}
container["volumeMounts"] = [{"name": "tmp", "mountPath": "/tmp"}]
if s3_ca_secret_name:
pod_spec["volumes"].append(_s3_ca_volume(s3_ca_secret_name))
container["volumeMounts"].append(_s3_ca_volume_mount())
if config_name:
pod_spec["containers"][0]["envFrom"] = [{"configMapRef": {"name": config_name}}]
if config_name and secret_name:
@@ -731,6 +759,16 @@ def _deployment(
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration-wait"},
{"name": "GOVOPLAN_DB_POOL_SIZE", "value": "1"},
{"name": "GOVOPLAN_DB_MAX_OVERFLOW", "value": "0"},
*(
[
{
"name": "AWS_CA_BUNDLE",
"value": "/etc/govoplan/trust/s3-ca.crt",
}
]
if s3_ca_secret_name
else []
),
*_secret_environment(secret_name),
],
"securityContext": {
@@ -738,7 +776,10 @@ def _deployment(
"capabilities": {"drop": ["ALL"]},
"readOnlyRootFilesystem": True,
},
"volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}],
"volumeMounts": [
{"name": "tmp", "mountPath": "/tmp"},
*([_s3_ca_volume_mount()] if s3_ca_secret_name else []),
],
}
]
return {
@@ -769,6 +810,7 @@ def _migration_job(
image: str,
config_name: str,
secret_name: str,
s3_ca_secret_name: str | None,
service_account: str,
database_environment: Mapping[str, str],
backup_required: bool,
@@ -832,6 +874,16 @@ def _migration_job(
"env": [
{"name": "TMPDIR", "value": "/tmp"},
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration"},
*(
[
{
"name": "AWS_CA_BUNDLE",
"value": "/etc/govoplan/trust/s3-ca.crt",
}
]
if s3_ca_secret_name
else []
),
*(
{"name": key, "value": value}
for key, value in sorted(
@@ -851,10 +903,24 @@ def _migration_job(
"capabilities": {"drop": ["ALL"]},
"readOnlyRootFilesystem": True,
},
"volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}],
"volumeMounts": [
{"name": "tmp", "mountPath": "/tmp"},
*(
[_s3_ca_volume_mount()]
if s3_ca_secret_name
else []
),
],
}
],
"volumes": [{"name": "tmp", "emptyDir": {}}],
"volumes": [
{"name": "tmp", "emptyDir": {}},
*(
[_s3_ca_volume(s3_ca_secret_name)]
if s3_ca_secret_name
else []
),
],
},
},
},
@@ -873,6 +939,24 @@ def _secret_environment(secret_name: str) -> list[dict[str, Any]]:
]
def _s3_ca_volume(secret_name: str) -> dict[str, Any]:
return {
"name": "s3-ca",
"secret": {
"secretName": secret_name,
"items": [{"key": "ca.crt", "path": "s3-ca.crt"}],
},
}
def _s3_ca_volume_mount() -> dict[str, Any]:
return {
"name": "s3-ca",
"mountPath": "/etc/govoplan/trust",
"readOnly": True,
}
def _service(
*,
name: str,
@@ -521,11 +521,32 @@ function inspectSource(repository, sourceRoot, sourcePath) {
) {
return propertyNameText(current.name);
}
if (
ts.isVariableDeclaration(current) &&
ts.isIdentifier(current.name) &&
(current.name.text === "en" || current.name.text === "de") &&
current.initializer &&
isCatalogObject(current.initializer)
) {
return current.name.text;
}
current = current.parent;
}
return null;
}
function isCatalogObject(node) {
let current = node;
while (
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current) ||
ts.isParenthesizedExpression(current)
) {
current = current.expression;
}
return ts.isObjectLiteralExpression(current);
}
function ancestorPropertyName(node, expected) {
let current = node.parent;
while (current) {
@@ -31,6 +31,8 @@ ENDPOINT_SURFACE_CATEGORIES = {
DEFAULT_ENDPOINT_DECLARATIONS = (
META_ROOT / "tools" / "inventory" / "endpoint-surface-declarations.json"
)
REQUIRED_LOCALES = ("de", "en")
REFERENCE_LOCALE = "de"
def main() -> int:
@@ -353,6 +355,14 @@ def _extract_manifests(
}
for permission in manifest.permissions
],
"architecture": (
manifest.architecture.to_dict()
if manifest.architecture is not None
else None
),
"information_governance": (
manifest.information_governance.to_dict()
),
"interface_catalog": manifest_interface_catalog(manifest),
"frontend": (
{
@@ -447,20 +457,27 @@ def _assemble_inventory(
usages = {item["key"] for item in webui["translationUsages"]}
catalogs = webui["translationCatalog"]
catalog_keys = {locale: set(entries) for locale, entries in catalogs.items()}
expected_locales = sorted(catalog_keys)
expected_locales = sorted(set(catalog_keys) | set(REQUIRED_LOCALES))
missing_catalog_entries = [
{
"key": key,
"missing_locales": [
locale for locale in expected_locales if key not in catalog_keys[locale]
locale
for locale in expected_locales
if key not in catalog_keys.get(locale, set())
],
}
for key in sorted(usages)
if any(key not in catalog_keys[locale] for locale in expected_locales)
if any(key not in catalog_keys.get(locale, set()) for locale in expected_locales)
]
fields = webui["fields"]
help_candidates = [field for field in fields if field["helpCandidate"]]
dynamic_help = [field for field in fields if field.get("helpDynamic")]
governance_adoption = Counter(
dimension["adoption"]
for manifest in manifests
for dimension in manifest["information_governance"]["dimensions"].values()
)
source_declarations = _source_interface_declarations(webui, manifests)
declaration_health = _declaration_health(source_declarations, manifests)
runtime_comparison = (
@@ -502,9 +519,26 @@ def _assemble_inventory(
},
"translation_health": {
"locales": expected_locales,
"reference_locale": REFERENCE_LOCALE,
"reference_locale_entries": len(catalog_keys.get(REFERENCE_LOCALE, set())),
"reference_locale_complete": not any(
REFERENCE_LOCALE in item["missing_locales"]
for item in missing_catalog_entries
),
"used_keys": len(usages),
"missing_catalog_entries": missing_catalog_entries,
},
"information_governance_health": {
"dimensions": len(manifests) * 4,
"adoption_counts": dict(sorted(governance_adoption.items())),
"modules": [
{
"module_id": manifest["id"],
"dimensions": manifest["information_governance"]["dimensions"],
}
for manifest in manifests
],
},
"api": {
"backend_endpoints": classified_endpoints,
"frontend_references": frontend_refs,
@@ -517,6 +551,7 @@ def _assemble_inventory(
"modules": len(manifests),
"ui_fields": len(fields),
"ui_fields_with_static_help": len(fields) - len(help_candidates),
"ui_fields_with_resolvable_f1_context": len(fields),
"help_review_candidates": len(help_candidates),
"dynamic_help_references": len(dynamic_help),
"ui_actions": len(webui.get("actions", [])),
@@ -536,6 +571,12 @@ def _assemble_inventory(
"backend_endpoints_without_static_webui_reference": len(unreferenced),
"unclassified_backend_endpoints": len(unclassified),
"stale_endpoint_declarations": len(stale_declarations),
"information_governance_dimensions": len(manifests) * 4,
"information_governance_enforced": governance_adoption["enforced"],
"information_governance_partial": governance_adoption["partial"],
"information_governance_contract_only": governance_adoption[
"contract_only"
],
},
}
@@ -856,6 +897,7 @@ def _render_markdown(inventory: dict[str, Any]) -> str:
f"- UI fields: {summary['ui_fields']}",
f"- UI actions: {summary['ui_actions']}",
f"- Fields with statically associated help: {summary['ui_fields_with_static_help']}",
f"- Fields with a resolvable F1 context: {summary['ui_fields_with_resolvable_f1_context']}",
f"- Fields with dynamic help references: {summary['dynamic_help_references']}",
f"- Help review candidates: {summary['help_review_candidates']}",
f"- Stable interface declarations: {summary['interface_declarations']}",
@@ -872,7 +914,12 @@ def _render_markdown(inventory: dict[str, Any]) -> str:
),
f"- Unclassified backend endpoints: {summary['unclassified_backend_endpoints']}",
f"- Stale endpoint declarations: {summary['stale_endpoint_declarations']}",
f"- Used translation keys missing from a locale catalog: {len(missing)}",
f"- Reference locale: `{inventory['translation_health']['reference_locale']}`",
f"- Reference locale complete: `{str(inventory['translation_health']['reference_locale_complete']).lower()}`",
f"- Used translation keys missing from a required locale catalog: {len(missing)}",
f"- Information-governance dimensions enforced: {summary['information_governance_enforced']}",
f"- Information-governance dimensions partial: {summary['information_governance_partial']}",
f"- Information-governance dimensions contract-only: {summary['information_governance_contract_only']}",
"",
"## Help Review Candidates",
"",
@@ -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
+85
View File
@@ -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
+15
View File
@@ -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())
+5
View File
@@ -0,0 +1,5 @@
"""Reproducible GovOPlaN Kubernetes acceptance lab."""
from .config import LabConfig, LabConfigError, LabNode, load_config
__all__ = ["LabConfig", "LabConfigError", "LabNode", "load_config"]
+153
View File
@@ -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())
+466
View File
@@ -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
+311
View File
@@ -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>
@@ -332,6 +332,7 @@ def _manifest_catalog_metadata(manifest: ModuleManifest | None) -> dict[str, obj
payload["optional_dependencies"] = list(manifest.optional_dependencies)
if manifest.architecture is not None:
payload["architecture"] = manifest.architecture.to_dict()
payload["information_governance"] = manifest.information_governance.to_dict()
if manifest.external_providers:
payload["external_providers"] = [
declaration.to_dict()
@@ -167,6 +167,7 @@ def manifest_catalog_entry(
entry["optional_dependencies"] = list(manifest.optional_dependencies)
if manifest.architecture is not None:
entry["architecture"] = manifest.architecture.to_dict()
entry["information_governance"] = manifest.information_governance.to_dict()
if manifest.external_providers:
entry["external_providers"] = [
declaration.to_dict()
+6 -4
View File
@@ -11,11 +11,13 @@ LABEL org.opencontainers.image.title="GovOPlaN WebUI runtime" \
USER 0
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/*
COPY web-dist/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf
COPY nginx.conf /etc/nginx/nginx.conf.template
COPY web-entrypoint.sh /usr/local/bin/govoplan-web-entrypoint
RUN chown -R 101:101 /usr/share/nginx/html \
&& chmod -R a-w /usr/share/nginx/html /etc/nginx/nginx.conf
&& chmod -R a-w /usr/share/nginx/html /etc/nginx/nginx.conf.template \
&& chmod 0555 /usr/local/bin/govoplan-web-entrypoint
USER 101:101
EXPOSE 8080
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
ENTRYPOINT ["/usr/local/bin/govoplan-web-entrypoint"]
CMD []
+1 -1
View File
@@ -35,7 +35,7 @@ http {
}
location /api/ {
proxy_pass http://load-balancer:8000;
proxy_pass ${GOVOPLAN_API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;