Release v0.1.16
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user