mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 15:54:32 +00:00
chore: refactor dependency resolution and auto-install logic
- Replaced static dependency configuration with dynamic resolution using `get_required_dependencies` and `get_required_dependency_ids`. - Streamlined runtime checks, fallback behaviors, and handling of missing/optional dependencies. - Hardened GKE context acquisition and validation for Kubernetes clusters. - Enhanced test coverage for dependency resolution under various deployment modes (`dev`, `prod`, `gke`).
This commit is contained in:
parent
cedcaae2c5
commit
10bfc42bad
@ -1301,6 +1301,49 @@ def get_platform_dependencies() -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _deployment_mode_hint(inputs: dict | None = None) -> str:
|
||||||
|
from knoe.core.env import _normalize_cluster_env
|
||||||
|
|
||||||
|
data = inputs or {}
|
||||||
|
cluster_env_raw = str(data.get("init_cluster.cluster_env", "")).strip()
|
||||||
|
normalized_env = (_normalize_cluster_env(cluster_env_raw) or "").strip().lower()
|
||||||
|
|
||||||
|
if normalized_env in {"prod", "production", "k8s"}:
|
||||||
|
return "gke"
|
||||||
|
if normalized_env in {"dev", "k3d", "k3s"}:
|
||||||
|
return normalized_env
|
||||||
|
|
||||||
|
deployment_mode = str(
|
||||||
|
data.get("build.deploy_env")
|
||||||
|
or data.get("deployment_mode")
|
||||||
|
or data.get("DEPLOYMENT_MODE")
|
||||||
|
or ""
|
||||||
|
).strip().lower()
|
||||||
|
if deployment_mode in {"prod", "production", "k8s", "gke"}:
|
||||||
|
return "gke"
|
||||||
|
if deployment_mode in {"dev", "k3d", "k3s"}:
|
||||||
|
return deployment_mode
|
||||||
|
|
||||||
|
return "generic"
|
||||||
|
|
||||||
|
|
||||||
|
def get_required_dependency_ids(inputs: dict | None = None) -> set[str]:
|
||||||
|
mode = _deployment_mode_hint(inputs)
|
||||||
|
|
||||||
|
base_required = {"python", "ansible", "kubectl", "kubectx", "docker"}
|
||||||
|
if mode == "gke":
|
||||||
|
return base_required | {"gcloud"}
|
||||||
|
if mode in {"dev", "k3d", "k3s"}:
|
||||||
|
return base_required | {"k3d"}
|
||||||
|
return base_required
|
||||||
|
|
||||||
|
|
||||||
|
def get_required_dependencies(inputs: dict | None = None) -> list[dict]:
|
||||||
|
dependencies = get_platform_dependencies()
|
||||||
|
required_ids = get_required_dependency_ids(inputs)
|
||||||
|
return [dep for dep in dependencies if str(dep.get("id")) in required_ids]
|
||||||
|
|
||||||
|
|
||||||
def _validate_dependency_backend(dependencies: list[dict] | None = None) -> tuple[bool, str]:
|
def _validate_dependency_backend(dependencies: list[dict] | None = None) -> tuple[bool, str]:
|
||||||
info = detect_dependency_platform()
|
info = detect_dependency_platform()
|
||||||
if not info.get("supported"):
|
if not info.get("supported"):
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@ -50,6 +51,7 @@ class DependenciesMilestone(Milestone):
|
|||||||
self, state: InstallerState, progress: ProgressCallback | None = None
|
self, state: InstallerState, progress: ProgressCallback | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
platform_info = inst_config.detect_dependency_platform()
|
platform_info = inst_config.detect_dependency_platform()
|
||||||
|
verify_all = self._parse_bool(state.inputs.get("dependencies.verify_all", "False"))
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Dependency platform detected: %s",
|
"Dependency platform detected: %s",
|
||||||
inst_config.get_dependency_platform_summary(),
|
inst_config.get_dependency_platform_summary(),
|
||||||
@ -58,26 +60,28 @@ class DependenciesMilestone(Milestone):
|
|||||||
if progress:
|
if progress:
|
||||||
progress("Checking dependencies...", 0.1)
|
progress("Checking dependencies...", 0.1)
|
||||||
|
|
||||||
dependencies = inst_config.DEPENDENCIES
|
if verify_all:
|
||||||
if not dependencies:
|
|
||||||
dependencies = inst_config.get_platform_dependencies()
|
dependencies = inst_config.get_platform_dependencies()
|
||||||
|
else:
|
||||||
|
dependencies = inst_config.get_required_dependencies(state.inputs)
|
||||||
|
|
||||||
backend_ok, backend_reason = inst_config._validate_dependency_backend()
|
required_ids = inst_config.get_required_dependency_ids(state.inputs)
|
||||||
|
|
||||||
|
backend_ok, backend_reason = inst_config._validate_dependency_backend(dependencies)
|
||||||
if not backend_ok:
|
if not backend_ok:
|
||||||
self.logger.error("Dependency backend unsupported: %s", backend_reason)
|
self._fatal_dependency_failure(
|
||||||
self._set_status(state, "Missing")
|
state,
|
||||||
return
|
f"Dependency backend unsupported: {backend_reason}",
|
||||||
|
)
|
||||||
|
|
||||||
if not dependencies:
|
if not dependencies:
|
||||||
reason = platform_info.get("reason") or "No dependencies configured for detected platform"
|
reason = platform_info.get("reason") or "No dependencies configured for detected platform"
|
||||||
self.logger.error("Dependency resolution failed: %s", reason)
|
self._fatal_dependency_failure(state, f"Dependency resolution failed: {reason}")
|
||||||
self._set_status(state, "Missing")
|
|
||||||
return
|
|
||||||
|
|
||||||
missing = []
|
missing: list[dict] = []
|
||||||
|
|
||||||
for i, dep in enumerate(dependencies):
|
for i, dep in enumerate(dependencies):
|
||||||
ok, location, version = inst_config.get_dep_info(dep)
|
ok, _, version = inst_config.get_dep_info(dep)
|
||||||
if ok:
|
if ok:
|
||||||
self.logger.info(f"[OK] {dep['name']} {version or ''}".strip())
|
self.logger.info(f"[OK] {dep['name']} {version or ''}".strip())
|
||||||
else:
|
else:
|
||||||
@ -90,12 +94,7 @@ class DependenciesMilestone(Milestone):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not missing:
|
if not missing:
|
||||||
if not self._ensure_gcloud_auth_for_k8s(state):
|
self._finalize_dependency_prerequisites(state, progress)
|
||||||
self._set_status(state, "Missing")
|
|
||||||
return
|
|
||||||
self._set_status(state, "All installed")
|
|
||||||
if progress:
|
|
||||||
progress("All dependencies installed", 1.0)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if auto-install is enabled
|
# Check if auto-install is enabled
|
||||||
@ -103,9 +102,12 @@ class DependenciesMilestone(Milestone):
|
|||||||
state.inputs.get("dependencies.auto_install_missing", "True")
|
state.inputs.get("dependencies.auto_install_missing", "True")
|
||||||
)
|
)
|
||||||
if not auto_install:
|
if not auto_install:
|
||||||
self.logger.error("Dependencies missing and auto-install disabled.")
|
self._fatal_missing_dependencies(
|
||||||
self._set_status(state, "Missing")
|
state,
|
||||||
return
|
missing,
|
||||||
|
required_ids,
|
||||||
|
"Dependencies missing and auto-install disabled",
|
||||||
|
)
|
||||||
|
|
||||||
for i, dep in enumerate(missing):
|
for i, dep in enumerate(missing):
|
||||||
install_cmd = dep.get("install_cmd")
|
install_cmd = dep.get("install_cmd")
|
||||||
@ -126,29 +128,238 @@ class DependenciesMilestone(Milestone):
|
|||||||
|
|
||||||
rc = self._run_cmd(install_cmd)
|
rc = self._run_cmd(install_cmd)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
self.logger.error(f"Install failed for {dep['name']} (code {rc})")
|
self.logger.error(
|
||||||
|
"Install failed for %s (code %s) [backend=%s, cmd=%s]",
|
||||||
|
dep["name"],
|
||||||
|
rc,
|
||||||
|
platform_info.get("package_manager") or "unknown",
|
||||||
|
install_cmd,
|
||||||
|
)
|
||||||
|
|
||||||
# Re-verify
|
# Re-verify
|
||||||
still_missing = []
|
still_missing: list[dict] = []
|
||||||
for dep in missing:
|
for dep in missing:
|
||||||
ok, _, _ = inst_config.get_dep_info(dep)
|
ok, _, _ = inst_config.get_dep_info(dep)
|
||||||
if not ok:
|
if not ok:
|
||||||
still_missing.append(dep["name"])
|
still_missing.append(dep)
|
||||||
|
|
||||||
if still_missing:
|
if still_missing:
|
||||||
self.logger.error(f"Still missing: {', '.join(still_missing)}")
|
self._fatal_missing_dependencies(
|
||||||
self._set_status(state, "Missing")
|
state,
|
||||||
|
still_missing,
|
||||||
|
required_ids,
|
||||||
|
"Dependencies unresolved after install attempts",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
|
self._finalize_dependency_prerequisites(state, progress)
|
||||||
|
|
||||||
|
def _finalize_dependency_prerequisites(
|
||||||
|
self, state: InstallerState, progress: ProgressCallback | None = None
|
||||||
|
) -> None:
|
||||||
if not self._ensure_gcloud_auth_for_k8s(state):
|
if not self._ensure_gcloud_auth_for_k8s(state):
|
||||||
self._set_status(state, "Missing")
|
self._fatal_dependency_failure(
|
||||||
return
|
state,
|
||||||
|
"GKE bootstrap failed: gcloud authentication is required before cluster operations.",
|
||||||
|
)
|
||||||
|
if not self._ensure_gke_contexts_for_k8s(state):
|
||||||
|
self._fatal_dependency_failure(
|
||||||
|
state,
|
||||||
|
"GKE bootstrap failed: required kube contexts are missing or could not be acquired.",
|
||||||
|
)
|
||||||
self._set_status(state, "All installed")
|
self._set_status(state, "All installed")
|
||||||
if progress:
|
if progress:
|
||||||
progress("All dependencies installed", 1.0)
|
progress("All dependencies installed", 1.0)
|
||||||
|
|
||||||
|
def _fatal_dependency_failure(self, state: InstallerState, message: str) -> None:
|
||||||
|
self.logger.error(message)
|
||||||
|
self._set_status(state, "Missing")
|
||||||
|
raise RuntimeError(message)
|
||||||
|
|
||||||
|
def _fatal_missing_dependencies(
|
||||||
|
self,
|
||||||
|
state: InstallerState,
|
||||||
|
missing: list[dict],
|
||||||
|
required_ids: set[str],
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
required_missing = [
|
||||||
|
str(dep.get("name") or dep.get("id"))
|
||||||
|
for dep in missing
|
||||||
|
if str(dep.get("id")) in required_ids
|
||||||
|
]
|
||||||
|
optional_missing = [
|
||||||
|
str(dep.get("name") or dep.get("id"))
|
||||||
|
for dep in missing
|
||||||
|
if str(dep.get("id")) not in required_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
if optional_missing:
|
||||||
|
self.logger.warning("Optional dependencies still missing: %s", ", ".join(optional_missing))
|
||||||
|
|
||||||
|
if required_missing:
|
||||||
|
self._fatal_dependency_failure(
|
||||||
|
state,
|
||||||
|
f"{reason}. Required dependencies still missing: {', '.join(required_missing)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_gke_mode(self, state: InstallerState) -> bool:
|
||||||
|
return "gcloud" in inst_config.get_required_dependency_ids(state.inputs)
|
||||||
|
|
||||||
|
def _gke_context_targets(self, state: InstallerState) -> list[dict]:
|
||||||
|
def _first_non_empty(*keys: str) -> str:
|
||||||
|
for key in keys:
|
||||||
|
value = str(state.inputs.get(key, "")).strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
project = _first_non_empty("init_cluster.project_id")
|
||||||
|
targets: list[dict] = []
|
||||||
|
|
||||||
|
for kind in ("app", "db"):
|
||||||
|
context = _first_non_empty(
|
||||||
|
f"init_cluster.{kind}_cluster_kubecontext",
|
||||||
|
f"env_setup.{kind.upper()}_CLUSTER_KUBECONTEXT",
|
||||||
|
)
|
||||||
|
cluster = _first_non_empty(
|
||||||
|
f"init_cluster.{kind}_cluster_name",
|
||||||
|
f"env_setup.{kind.upper()}_CLUSTER_NAME",
|
||||||
|
)
|
||||||
|
region = _first_non_empty(f"init_cluster.{kind}_cluster_region")
|
||||||
|
zone = _first_non_empty(f"init_cluster.{kind}_cluster_zone")
|
||||||
|
if not context:
|
||||||
|
continue
|
||||||
|
targets.append(
|
||||||
|
{
|
||||||
|
"kind": kind,
|
||||||
|
"context": context,
|
||||||
|
"cluster": cluster,
|
||||||
|
"region": region,
|
||||||
|
"zone": zone,
|
||||||
|
"project": project,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
deduped: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for target in targets:
|
||||||
|
context = str(target.get("context") or "")
|
||||||
|
if context and context not in seen:
|
||||||
|
deduped.append(target)
|
||||||
|
seen.add(context)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
def _get_kube_contexts(self) -> set[str] | None:
|
||||||
|
kube_env = inst_config._augment_env_for_dependency_backend(os.environ.copy())
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["kubectl", "config", "get-contexts", "-o", "name"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=kube_env,
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.error("Failed to query kube contexts: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if res.returncode != 0:
|
||||||
|
self.logger.error(
|
||||||
|
"Failed to list kube contexts (code %s): %s",
|
||||||
|
res.returncode,
|
||||||
|
(res.stderr or "").strip(),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
line.strip()
|
||||||
|
for line in (res.stdout or "").splitlines()
|
||||||
|
if line.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
def _ensure_gke_contexts_for_k8s(self, state: InstallerState) -> bool:
|
||||||
|
if not self._is_gke_mode(state):
|
||||||
|
return True
|
||||||
|
|
||||||
|
targets = self._gke_context_targets(state)
|
||||||
|
if not targets:
|
||||||
|
return True
|
||||||
|
|
||||||
|
kube_contexts = self._get_kube_contexts()
|
||||||
|
if kube_contexts is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
missing_targets = [
|
||||||
|
target for target in targets if str(target.get("context")) not in kube_contexts
|
||||||
|
]
|
||||||
|
if not missing_targets:
|
||||||
|
return True
|
||||||
|
|
||||||
|
gcloud_bin = shutil.which("gcloud")
|
||||||
|
if not gcloud_bin:
|
||||||
|
self.logger.error(
|
||||||
|
"Missing gcloud; cannot acquire required GKE contexts: %s",
|
||||||
|
", ".join(str(t.get("context") or "") for t in missing_targets),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
for target in missing_targets:
|
||||||
|
context = str(target.get("context") or "")
|
||||||
|
cluster = str(target.get("cluster") or "")
|
||||||
|
if not cluster:
|
||||||
|
self.logger.error(
|
||||||
|
"Cannot acquire missing context '%s': cluster name is not configured.",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
cmd = [gcloud_bin, "container", "clusters", "get-credentials", cluster]
|
||||||
|
region = str(target.get("region") or "")
|
||||||
|
zone = str(target.get("zone") or "")
|
||||||
|
project = str(target.get("project") or "")
|
||||||
|
|
||||||
|
if region:
|
||||||
|
cmd += ["--region", region]
|
||||||
|
elif zone:
|
||||||
|
cmd += ["--zone", zone]
|
||||||
|
if project:
|
||||||
|
cmd += ["--project", project]
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"[ACTION] Acquiring GKE context '%s' via: %s",
|
||||||
|
context,
|
||||||
|
" ".join(shlex.quote(part) for part in cmd),
|
||||||
|
)
|
||||||
|
rc = self._run_cmd(cmd)
|
||||||
|
if rc != 0:
|
||||||
|
self.logger.error(
|
||||||
|
"Failed to acquire GKE context '%s' for cluster '%s' (code %s)",
|
||||||
|
context,
|
||||||
|
cluster,
|
||||||
|
rc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
refreshed_contexts = self._get_kube_contexts()
|
||||||
|
if refreshed_contexts is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
still_missing = [
|
||||||
|
str(target.get("context") or "")
|
||||||
|
for target in targets
|
||||||
|
if str(target.get("context") or "") not in refreshed_contexts
|
||||||
|
]
|
||||||
|
if still_missing:
|
||||||
|
self.logger.error(
|
||||||
|
"GKE contexts still missing after credential acquisition: %s",
|
||||||
|
", ".join(still_missing),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _ensure_gcloud_auth_for_k8s(self, state: InstallerState) -> bool:
|
def _ensure_gcloud_auth_for_k8s(self, state: InstallerState) -> bool:
|
||||||
cluster_env = str(state.inputs.get("init_cluster.cluster_env", "")).strip().lower()
|
if not self._is_gke_mode(state):
|
||||||
if cluster_env not in {"prod", "production", "k8s"}:
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
gcloud_env = inst_config._augment_env_for_dependency_backend(os.environ.copy())
|
gcloud_env = inst_config._augment_env_for_dependency_backend(os.environ.copy())
|
||||||
|
|||||||
@ -24,6 +24,8 @@ from knoe.config import (
|
|||||||
detect_dependency_platform,
|
detect_dependency_platform,
|
||||||
get_dependency_milestone_title,
|
get_dependency_milestone_title,
|
||||||
get_platform_dependencies,
|
get_platform_dependencies,
|
||||||
|
get_required_dependencies,
|
||||||
|
get_required_dependency_ids,
|
||||||
_validate_dependency_backend,
|
_validate_dependency_backend,
|
||||||
load_dependencies,
|
load_dependencies,
|
||||||
get_ui_icon_image_path,
|
get_ui_icon_image_path,
|
||||||
@ -385,6 +387,34 @@ def test_get_platform_dependencies_linux_apt_includes_opentofu_without_brew_depe
|
|||||||
assert "apt-get install -y opentofu" in str(opentofu.get("install_cmd") or "")
|
assert "apt-get install -y opentofu" in str(opentofu.get("install_cmd") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_required_dependency_ids_gke_excludes_k3d_and_opentofu():
|
||||||
|
required = get_required_dependency_ids(
|
||||||
|
{
|
||||||
|
"init_cluster.cluster_env": "prod",
|
||||||
|
"build.deploy_env": "Prod",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "gcloud" in required
|
||||||
|
assert "k3d" not in required
|
||||||
|
assert "opentofu" not in required
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_required_dependencies_filters_platform_dependencies_for_gke():
|
||||||
|
deps = [
|
||||||
|
{"id": "python", "name": "python"},
|
||||||
|
{"id": "gcloud", "name": "gcloud"},
|
||||||
|
{"id": "k3d", "name": "k3d"},
|
||||||
|
{"id": "opentofu", "name": "OpenTofu"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("knoe.config.get_platform_dependencies", return_value=deps):
|
||||||
|
required = get_required_dependencies({"init_cluster.cluster_env": "prod"})
|
||||||
|
|
||||||
|
required_ids = [dep["id"] for dep in required]
|
||||||
|
assert required_ids == ["python", "gcloud"]
|
||||||
|
|
||||||
|
|
||||||
def test_validate_dependency_backend_rejects_linux_brew_commands():
|
def test_validate_dependency_backend_rejects_linux_brew_commands():
|
||||||
with patch(
|
with patch(
|
||||||
"knoe.config.detect_dependency_platform",
|
"knoe.config.detect_dependency_platform",
|
||||||
|
|||||||
@ -43,11 +43,13 @@ class TestMilestones(unittest.TestCase):
|
|||||||
def test_dependencies_milestone_install_missing(
|
def test_dependencies_milestone_install_missing(
|
||||||
self, mock_popen, mock_get_dep_info
|
self, mock_popen, mock_get_dep_info
|
||||||
):
|
):
|
||||||
# Mock one missing dependency
|
# Mock one missing required dependency
|
||||||
|
dep = {"id": "python", "name": "python", "install_cmd": "apt install python3"}
|
||||||
|
|
||||||
def side_effect(dep):
|
def side_effect(dep):
|
||||||
if dep["id"] == "brew":
|
if dep["id"] == "python":
|
||||||
if mock_popen.called:
|
if mock_popen.called:
|
||||||
return (True, "/usr/local/bin/brew", "4.0.0")
|
return (True, "/usr/bin/python3", "3.10.0")
|
||||||
return (False, None, None)
|
return (False, None, None)
|
||||||
return (True, "/usr/bin/python", "3.10.0")
|
return (True, "/usr/bin/python", "3.10.0")
|
||||||
|
|
||||||
@ -62,17 +64,18 @@ class TestMilestones(unittest.TestCase):
|
|||||||
mock_process.wait.return_value = 0
|
mock_process.wait.return_value = 0
|
||||||
mock_popen.return_value = mock_process
|
mock_popen.return_value = mock_process
|
||||||
|
|
||||||
# Ensure brew install is enabled in state
|
self.state.inputs["dependencies.python.install"] = "True"
|
||||||
self.state.inputs["dependencies.brew.install"] = "True"
|
|
||||||
|
|
||||||
milestone = DependenciesMilestone()
|
milestone = DependenciesMilestone()
|
||||||
|
with patch("knoe.config.get_required_dependencies", return_value=[dep]), \
|
||||||
|
patch("knoe.config.get_required_dependency_ids", return_value={"python"}):
|
||||||
milestone.execute(self.state)
|
milestone.execute(self.state)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.state.config_data.get("Dependencies", {}).get("STATUS"),
|
self.state.config_data.get("Dependencies", {}).get("STATUS"),
|
||||||
"All installed",
|
"All installed",
|
||||||
)
|
)
|
||||||
# Check that it tried to install brew
|
# Check that it tried to install python
|
||||||
mock_popen.assert_called()
|
mock_popen.assert_called()
|
||||||
|
|
||||||
@patch("knoe.config.get_resource_path")
|
@patch("knoe.config.get_resource_path")
|
||||||
|
|||||||
@ -583,8 +583,11 @@ def test_dependencies_auto_install_disabled():
|
|||||||
"""When auto_install=False, set status Missing and return without installing."""
|
"""When auto_install=False, set status Missing and return without installing."""
|
||||||
state = _make_state(**{"dependencies.auto_install_missing": "False"})
|
state = _make_state(**{"dependencies.auto_install_missing": "False"})
|
||||||
milestone = DependenciesMilestone()
|
milestone = DependenciesMilestone()
|
||||||
missing_dep = {"id": "brew", "name": "Homebrew", "install_cmd": "brew-install"}
|
dep = {"id": "python", "name": "python", "install_cmd": "apt install python3"}
|
||||||
with patch("knoe.config.get_dep_info", return_value=(False, None, None)):
|
with patch("knoe.config.get_dep_info", return_value=(False, None, None)), \
|
||||||
|
patch("knoe.config.get_required_dependencies", return_value=[dep]), \
|
||||||
|
patch("knoe.config.get_required_dependency_ids", return_value={"python"}), \
|
||||||
|
pytest.raises(RuntimeError, match="auto-install disabled"):
|
||||||
milestone.execute(state)
|
milestone.execute(state)
|
||||||
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
||||||
|
|
||||||
@ -595,8 +598,9 @@ def test_dependencies_no_install_cmd():
|
|||||||
milestone = DependenciesMilestone()
|
milestone = DependenciesMilestone()
|
||||||
# First call: dep missing; second call (re-verify): still missing
|
# First call: dep missing; second call (re-verify): still missing
|
||||||
with patch("knoe.config.get_dep_info", return_value=(False, None, None)), \
|
with patch("knoe.config.get_dep_info", return_value=(False, None, None)), \
|
||||||
patch("knoe.config.DEPENDENCIES",
|
patch("knoe.config.get_required_dependencies", return_value=[{"id": "mytool", "name": "MyTool"}]), \
|
||||||
[{"id": "mytool", "name": "MyTool"}]):
|
patch("knoe.config.get_required_dependency_ids", return_value={"mytool"}), \
|
||||||
|
pytest.raises(RuntimeError, match="Required dependencies still missing"):
|
||||||
milestone.execute(state)
|
milestone.execute(state)
|
||||||
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
||||||
|
|
||||||
@ -605,12 +609,14 @@ def test_dependencies_install_skipped_by_config():
|
|||||||
"""Dep with install disabled in state.inputs should be skipped."""
|
"""Dep with install disabled in state.inputs should be skipped."""
|
||||||
state = _make_state(**{
|
state = _make_state(**{
|
||||||
"dependencies.auto_install_missing": "True",
|
"dependencies.auto_install_missing": "True",
|
||||||
"dependencies.brew.install": "False",
|
"dependencies.python.install": "False",
|
||||||
})
|
})
|
||||||
milestone = DependenciesMilestone()
|
milestone = DependenciesMilestone()
|
||||||
dep = {"id": "brew", "name": "Homebrew", "install_cmd": "brew-install"}
|
dep = {"id": "python", "name": "python", "install_cmd": "apt install python3"}
|
||||||
with patch("knoe.config.get_dep_info", return_value=(False, None, None)), \
|
with patch("knoe.config.get_dep_info", return_value=(False, None, None)), \
|
||||||
patch("knoe.config.DEPENDENCIES", [dep]):
|
patch("knoe.config.get_required_dependencies", return_value=[dep]), \
|
||||||
|
patch("knoe.config.get_required_dependency_ids", return_value={"python"}), \
|
||||||
|
pytest.raises(RuntimeError, match="Required dependencies still missing"):
|
||||||
milestone.execute(state)
|
milestone.execute(state)
|
||||||
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
||||||
|
|
||||||
@ -619,10 +625,12 @@ def test_dependencies_install_fails():
|
|||||||
"""When the install command returns non-zero, still mark Missing."""
|
"""When the install command returns non-zero, still mark Missing."""
|
||||||
state = _make_state(**{"dependencies.auto_install_missing": "True"})
|
state = _make_state(**{"dependencies.auto_install_missing": "True"})
|
||||||
milestone = DependenciesMilestone()
|
milestone = DependenciesMilestone()
|
||||||
dep = {"id": "brew", "name": "Homebrew", "install_cmd": "brew-install"}
|
dep = {"id": "python", "name": "python", "install_cmd": "apt install python3"}
|
||||||
with patch("knoe.config.get_dep_info", return_value=(False, None, None)), \
|
with patch("knoe.config.get_dep_info", return_value=(False, None, None)), \
|
||||||
patch("knoe.config.DEPENDENCIES", [dep]), \
|
patch("knoe.config.get_required_dependencies", return_value=[dep]), \
|
||||||
patch.object(milestone, "_run_cmd", return_value=1):
|
patch("knoe.config.get_required_dependency_ids", return_value={"python"}), \
|
||||||
|
patch.object(milestone, "_run_cmd", return_value=1), \
|
||||||
|
pytest.raises(RuntimeError, match="Required dependencies still missing"):
|
||||||
milestone.execute(state)
|
milestone.execute(state)
|
||||||
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
||||||
|
|
||||||
@ -631,7 +639,7 @@ def test_dependencies_install_succeeds_all():
|
|||||||
"""After install, re-verify passes → status 'All installed'."""
|
"""After install, re-verify passes → status 'All installed'."""
|
||||||
state = _make_state(**{"dependencies.auto_install_missing": "True"})
|
state = _make_state(**{"dependencies.auto_install_missing": "True"})
|
||||||
milestone = DependenciesMilestone()
|
milestone = DependenciesMilestone()
|
||||||
dep = {"id": "brew", "name": "Homebrew", "install_cmd": "brew-install"}
|
dep = {"id": "python", "name": "python", "install_cmd": "apt install python3"}
|
||||||
call_count = {"n": 0}
|
call_count = {"n": 0}
|
||||||
|
|
||||||
def _dep_info(d):
|
def _dep_info(d):
|
||||||
@ -639,10 +647,11 @@ def test_dependencies_install_succeeds_all():
|
|||||||
# First call (initial check): missing. Re-verify calls: installed.
|
# First call (initial check): missing. Re-verify calls: installed.
|
||||||
if call_count["n"] <= len([dep]):
|
if call_count["n"] <= len([dep]):
|
||||||
return (False, None, None)
|
return (False, None, None)
|
||||||
return (True, "/usr/bin/brew", "4.0")
|
return (True, "/usr/bin/python3", "3.10")
|
||||||
|
|
||||||
with patch("knoe.config.get_dep_info", side_effect=_dep_info), \
|
with patch("knoe.config.get_dep_info", side_effect=_dep_info), \
|
||||||
patch("knoe.config.DEPENDENCIES", [dep]), \
|
patch("knoe.config.get_required_dependencies", return_value=[dep]), \
|
||||||
|
patch("knoe.config.get_required_dependency_ids", return_value={"python"}), \
|
||||||
patch.object(milestone, "_run_cmd", return_value=0):
|
patch.object(milestone, "_run_cmd", return_value=0):
|
||||||
milestone.execute(state)
|
milestone.execute(state)
|
||||||
assert state.config_data.get("Dependencies", {}).get("STATUS") == "All installed"
|
assert state.config_data.get("Dependencies", {}).get("STATUS") == "All installed"
|
||||||
@ -656,7 +665,7 @@ def test_dependencies_k8s_fails_without_gcloud_auth_session():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
milestone = DependenciesMilestone()
|
milestone = DependenciesMilestone()
|
||||||
dep = {"id": "gcloud", "name": "gcloud", "install_cmd": "brew install gcloud"}
|
dep = {"id": "gcloud", "name": "gcloud", "install_cmd": "apt install google-cloud-cli"}
|
||||||
|
|
||||||
no_token = MagicMock(returncode=1, stdout="", stderr="not logged in")
|
no_token = MagicMock(returncode=1, stdout="", stderr="not logged in")
|
||||||
|
|
||||||
@ -664,11 +673,13 @@ def test_dependencies_k8s_fails_without_gcloud_auth_session():
|
|||||||
raise AssertionError("_gcloud_plain should not be called for auth preflight")
|
raise AssertionError("_gcloud_plain should not be called for auth preflight")
|
||||||
|
|
||||||
with patch("knoe.config.get_dep_info", return_value=(True, "/usr/bin/gcloud", "1.0")), \
|
with patch("knoe.config.get_dep_info", return_value=(True, "/usr/bin/gcloud", "1.0")), \
|
||||||
patch("knoe.config.DEPENDENCIES", [dep]), \
|
patch("knoe.config.get_required_dependencies", return_value=[dep]), \
|
||||||
|
patch("knoe.config.get_required_dependency_ids", return_value={"gcloud"}), \
|
||||||
patch("knoe.config._augment_env_for_dependency_backend", return_value={"PATH": "x"}), \
|
patch("knoe.config._augment_env_for_dependency_backend", return_value={"PATH": "x"}), \
|
||||||
patch("knoe.config._gcloud_plain", side_effect=_unexpected_gcloud_plain), \
|
patch("knoe.config._gcloud_plain", side_effect=_unexpected_gcloud_plain), \
|
||||||
patch("knoe.core.milestones.subprocess.run", return_value=no_token), \
|
patch("knoe.core.milestones.subprocess.run", return_value=no_token), \
|
||||||
patch("knoe.core.milestones.sys.stdin.isatty", return_value=False):
|
patch("knoe.core.milestones.sys.stdin.isatty", return_value=False), \
|
||||||
|
pytest.raises(RuntimeError, match="gcloud authentication is required"):
|
||||||
milestone.execute(state)
|
milestone.execute(state)
|
||||||
|
|
||||||
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
||||||
@ -682,7 +693,7 @@ def test_dependencies_k8s_interactive_login_recovers_auth_session():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
milestone = DependenciesMilestone()
|
milestone = DependenciesMilestone()
|
||||||
dep = {"id": "gcloud", "name": "gcloud", "install_cmd": "brew install gcloud"}
|
dep = {"id": "gcloud", "name": "gcloud", "install_cmd": "apt install google-cloud-cli"}
|
||||||
|
|
||||||
no_token = MagicMock(returncode=1, stdout="", stderr="not logged in")
|
no_token = MagicMock(returncode=1, stdout="", stderr="not logged in")
|
||||||
with_token = MagicMock(returncode=0, stdout="tok123\n", stderr="")
|
with_token = MagicMock(returncode=0, stdout="tok123\n", stderr="")
|
||||||
@ -691,11 +702,13 @@ def test_dependencies_k8s_interactive_login_recovers_auth_session():
|
|||||||
raise AssertionError("_gcloud_plain should not be called for auth preflight")
|
raise AssertionError("_gcloud_plain should not be called for auth preflight")
|
||||||
|
|
||||||
with patch("knoe.config.get_dep_info", return_value=(True, "/usr/bin/gcloud", "1.0")), \
|
with patch("knoe.config.get_dep_info", return_value=(True, "/usr/bin/gcloud", "1.0")), \
|
||||||
patch("knoe.config.DEPENDENCIES", [dep]), \
|
patch("knoe.config.get_required_dependencies", return_value=[dep]), \
|
||||||
|
patch("knoe.config.get_required_dependency_ids", return_value={"gcloud"}), \
|
||||||
patch("knoe.config._augment_env_for_dependency_backend", return_value={"PATH": "x"}), \
|
patch("knoe.config._augment_env_for_dependency_backend", return_value={"PATH": "x"}), \
|
||||||
patch("knoe.config._gcloud_plain", side_effect=_unexpected_gcloud_plain), \
|
patch("knoe.config._gcloud_plain", side_effect=_unexpected_gcloud_plain), \
|
||||||
patch("knoe.core.milestones.subprocess.run", side_effect=[no_token, with_token]), \
|
patch("knoe.core.milestones.subprocess.run", side_effect=[no_token, with_token]), \
|
||||||
patch("knoe.core.milestones.sys.stdin.isatty", return_value=True), \
|
patch("knoe.core.milestones.sys.stdin.isatty", return_value=True), \
|
||||||
|
patch.object(milestone, "_ensure_gke_contexts_for_k8s", return_value=True), \
|
||||||
patch.object(milestone, "_run_cmd", return_value=0) as run_cmd_mock:
|
patch.object(milestone, "_run_cmd", return_value=0) as run_cmd_mock:
|
||||||
milestone.execute(state)
|
milestone.execute(state)
|
||||||
|
|
||||||
@ -703,6 +716,74 @@ def test_dependencies_k8s_interactive_login_recovers_auth_session():
|
|||||||
run_cmd_mock.assert_called_once()
|
run_cmd_mock.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependencies_k8s_acquires_missing_contexts_via_gcloud():
|
||||||
|
state = _make_state(
|
||||||
|
**{
|
||||||
|
"dependencies.auto_install_missing": "True",
|
||||||
|
"init_cluster.cluster_env": "prod",
|
||||||
|
"init_cluster.project_id": "proj-1",
|
||||||
|
"init_cluster.app_cluster_name": "app-cluster",
|
||||||
|
"init_cluster.app_cluster_region": "us-west3",
|
||||||
|
"init_cluster.app_cluster_kubecontext": "ctx-app",
|
||||||
|
"init_cluster.db_cluster_name": "db-cluster",
|
||||||
|
"init_cluster.db_cluster_region": "us-west3",
|
||||||
|
"init_cluster.db_cluster_kubecontext": "ctx-db",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
milestone = DependenciesMilestone()
|
||||||
|
dep = {"id": "gcloud", "name": "gcloud", "install_cmd": "apt install google-cloud-cli"}
|
||||||
|
|
||||||
|
token_ok = MagicMock(returncode=0, stdout="tok123\n", stderr="")
|
||||||
|
contexts_before = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
contexts_after = MagicMock(returncode=0, stdout="ctx-app\nctx-db\n", stderr="")
|
||||||
|
|
||||||
|
with patch("knoe.config.get_dep_info", return_value=(True, "/usr/bin/gcloud", "1.0")), \
|
||||||
|
patch("knoe.config.get_required_dependencies", return_value=[dep]), \
|
||||||
|
patch("knoe.config.get_required_dependency_ids", return_value={"gcloud"}), \
|
||||||
|
patch("knoe.config._augment_env_for_dependency_backend", return_value={"PATH": "x"}), \
|
||||||
|
patch("knoe.core.milestones.shutil.which", return_value="/usr/bin/gcloud"), \
|
||||||
|
patch(
|
||||||
|
"knoe.core.milestones.subprocess.run",
|
||||||
|
side_effect=[token_ok, contexts_before, contexts_after],
|
||||||
|
), \
|
||||||
|
patch.object(milestone, "_run_cmd", return_value=0) as run_cmd_mock:
|
||||||
|
milestone.execute(state)
|
||||||
|
|
||||||
|
assert state.config_data.get("Dependencies", {}).get("STATUS") == "All installed"
|
||||||
|
assert run_cmd_mock.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependencies_k8s_fails_when_context_missing_and_gcloud_unavailable():
|
||||||
|
state = _make_state(
|
||||||
|
**{
|
||||||
|
"dependencies.auto_install_missing": "True",
|
||||||
|
"init_cluster.cluster_env": "prod",
|
||||||
|
"init_cluster.app_cluster_name": "app-cluster",
|
||||||
|
"init_cluster.app_cluster_region": "us-west3",
|
||||||
|
"init_cluster.app_cluster_kubecontext": "ctx-app",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
milestone = DependenciesMilestone()
|
||||||
|
dep = {"id": "gcloud", "name": "gcloud", "install_cmd": "apt install google-cloud-cli"}
|
||||||
|
|
||||||
|
token_ok = MagicMock(returncode=0, stdout="tok123\n", stderr="")
|
||||||
|
contexts_before = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with patch("knoe.config.get_dep_info", return_value=(True, "/usr/bin/gcloud", "1.0")), \
|
||||||
|
patch("knoe.config.get_required_dependencies", return_value=[dep]), \
|
||||||
|
patch("knoe.config.get_required_dependency_ids", return_value={"gcloud"}), \
|
||||||
|
patch("knoe.config._augment_env_for_dependency_backend", return_value={"PATH": "x"}), \
|
||||||
|
patch("knoe.core.milestones.shutil.which", return_value=None), \
|
||||||
|
patch(
|
||||||
|
"knoe.core.milestones.subprocess.run",
|
||||||
|
side_effect=[token_ok, contexts_before],
|
||||||
|
), \
|
||||||
|
pytest.raises(RuntimeError, match="required kube contexts are missing"):
|
||||||
|
milestone.execute(state)
|
||||||
|
|
||||||
|
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# InitializationScriptsMilestone._regenerate_port_mapping_cfg
|
# InitializationScriptsMilestone._regenerate_port_mapping_cfg
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user