mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +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 []
|
||||
|
||||
|
||||
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]:
|
||||
info = detect_dependency_platform()
|
||||
if not info.get("supported"):
|
||||
|
||||
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@ -50,6 +51,7 @@ class DependenciesMilestone(Milestone):
|
||||
self, state: InstallerState, progress: ProgressCallback | None = None
|
||||
) -> None:
|
||||
platform_info = inst_config.detect_dependency_platform()
|
||||
verify_all = self._parse_bool(state.inputs.get("dependencies.verify_all", "False"))
|
||||
self.logger.info(
|
||||
"Dependency platform detected: %s",
|
||||
inst_config.get_dependency_platform_summary(),
|
||||
@ -58,26 +60,28 @@ class DependenciesMilestone(Milestone):
|
||||
if progress:
|
||||
progress("Checking dependencies...", 0.1)
|
||||
|
||||
dependencies = inst_config.DEPENDENCIES
|
||||
if not dependencies:
|
||||
if verify_all:
|
||||
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:
|
||||
self.logger.error("Dependency backend unsupported: %s", backend_reason)
|
||||
self._set_status(state, "Missing")
|
||||
return
|
||||
self._fatal_dependency_failure(
|
||||
state,
|
||||
f"Dependency backend unsupported: {backend_reason}",
|
||||
)
|
||||
|
||||
if not dependencies:
|
||||
reason = platform_info.get("reason") or "No dependencies configured for detected platform"
|
||||
self.logger.error("Dependency resolution failed: %s", reason)
|
||||
self._set_status(state, "Missing")
|
||||
return
|
||||
self._fatal_dependency_failure(state, f"Dependency resolution failed: {reason}")
|
||||
|
||||
missing = []
|
||||
missing: list[dict] = []
|
||||
|
||||
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:
|
||||
self.logger.info(f"[OK] {dep['name']} {version or ''}".strip())
|
||||
else:
|
||||
@ -90,12 +94,7 @@ class DependenciesMilestone(Milestone):
|
||||
)
|
||||
|
||||
if not missing:
|
||||
if not self._ensure_gcloud_auth_for_k8s(state):
|
||||
self._set_status(state, "Missing")
|
||||
return
|
||||
self._set_status(state, "All installed")
|
||||
if progress:
|
||||
progress("All dependencies installed", 1.0)
|
||||
self._finalize_dependency_prerequisites(state, progress)
|
||||
return
|
||||
|
||||
# Check if auto-install is enabled
|
||||
@ -103,9 +102,12 @@ class DependenciesMilestone(Milestone):
|
||||
state.inputs.get("dependencies.auto_install_missing", "True")
|
||||
)
|
||||
if not auto_install:
|
||||
self.logger.error("Dependencies missing and auto-install disabled.")
|
||||
self._set_status(state, "Missing")
|
||||
return
|
||||
self._fatal_missing_dependencies(
|
||||
state,
|
||||
missing,
|
||||
required_ids,
|
||||
"Dependencies missing and auto-install disabled",
|
||||
)
|
||||
|
||||
for i, dep in enumerate(missing):
|
||||
install_cmd = dep.get("install_cmd")
|
||||
@ -126,29 +128,238 @@ class DependenciesMilestone(Milestone):
|
||||
|
||||
rc = self._run_cmd(install_cmd)
|
||||
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
|
||||
still_missing = []
|
||||
still_missing: list[dict] = []
|
||||
for dep in missing:
|
||||
ok, _, _ = inst_config.get_dep_info(dep)
|
||||
if not ok:
|
||||
still_missing.append(dep["name"])
|
||||
still_missing.append(dep)
|
||||
|
||||
if still_missing:
|
||||
self.logger.error(f"Still missing: {', '.join(still_missing)}")
|
||||
self._set_status(state, "Missing")
|
||||
self._fatal_missing_dependencies(
|
||||
state,
|
||||
still_missing,
|
||||
required_ids,
|
||||
"Dependencies unresolved after install attempts",
|
||||
)
|
||||
else:
|
||||
if not self._ensure_gcloud_auth_for_k8s(state):
|
||||
self._set_status(state, "Missing")
|
||||
return
|
||||
self._set_status(state, "All installed")
|
||||
if progress:
|
||||
progress("All dependencies installed", 1.0)
|
||||
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):
|
||||
self._fatal_dependency_failure(
|
||||
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")
|
||||
if progress:
|
||||
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:
|
||||
cluster_env = str(state.inputs.get("init_cluster.cluster_env", "")).strip().lower()
|
||||
if cluster_env not in {"prod", "production", "k8s"}:
|
||||
if not self._is_gke_mode(state):
|
||||
return True
|
||||
|
||||
gcloud_env = inst_config._augment_env_for_dependency_backend(os.environ.copy())
|
||||
|
||||
@ -24,6 +24,8 @@ from knoe.config import (
|
||||
detect_dependency_platform,
|
||||
get_dependency_milestone_title,
|
||||
get_platform_dependencies,
|
||||
get_required_dependencies,
|
||||
get_required_dependency_ids,
|
||||
_validate_dependency_backend,
|
||||
load_dependencies,
|
||||
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 "")
|
||||
|
||||
|
||||
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():
|
||||
with patch(
|
||||
"knoe.config.detect_dependency_platform",
|
||||
|
||||
@ -43,11 +43,13 @@ class TestMilestones(unittest.TestCase):
|
||||
def test_dependencies_milestone_install_missing(
|
||||
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):
|
||||
if dep["id"] == "brew":
|
||||
if dep["id"] == "python":
|
||||
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 (True, "/usr/bin/python", "3.10.0")
|
||||
|
||||
@ -62,17 +64,18 @@ class TestMilestones(unittest.TestCase):
|
||||
mock_process.wait.return_value = 0
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
# Ensure brew install is enabled in state
|
||||
self.state.inputs["dependencies.brew.install"] = "True"
|
||||
self.state.inputs["dependencies.python.install"] = "True"
|
||||
|
||||
milestone = DependenciesMilestone()
|
||||
milestone.execute(self.state)
|
||||
with patch("knoe.config.get_required_dependencies", return_value=[dep]), \
|
||||
patch("knoe.config.get_required_dependency_ids", return_value={"python"}):
|
||||
milestone.execute(self.state)
|
||||
|
||||
self.assertEqual(
|
||||
self.state.config_data.get("Dependencies", {}).get("STATUS"),
|
||||
"All installed",
|
||||
)
|
||||
# Check that it tried to install brew
|
||||
# Check that it tried to install python
|
||||
mock_popen.assert_called()
|
||||
|
||||
@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."""
|
||||
state = _make_state(**{"dependencies.auto_install_missing": "False"})
|
||||
milestone = DependenciesMilestone()
|
||||
missing_dep = {"id": "brew", "name": "Homebrew", "install_cmd": "brew-install"}
|
||||
with patch("knoe.config.get_dep_info", return_value=(False, None, None)):
|
||||
dep = {"id": "python", "name": "python", "install_cmd": "apt install python3"}
|
||||
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)
|
||||
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
||||
|
||||
@ -595,8 +598,9 @@ def test_dependencies_no_install_cmd():
|
||||
milestone = DependenciesMilestone()
|
||||
# First call: dep missing; second call (re-verify): still missing
|
||||
with patch("knoe.config.get_dep_info", return_value=(False, None, None)), \
|
||||
patch("knoe.config.DEPENDENCIES",
|
||||
[{"id": "mytool", "name": "MyTool"}]):
|
||||
patch("knoe.config.get_required_dependencies", return_value=[{"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)
|
||||
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."""
|
||||
state = _make_state(**{
|
||||
"dependencies.auto_install_missing": "True",
|
||||
"dependencies.brew.install": "False",
|
||||
"dependencies.python.install": "False",
|
||||
})
|
||||
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)), \
|
||||
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)
|
||||
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."""
|
||||
state = _make_state(**{"dependencies.auto_install_missing": "True"})
|
||||
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)), \
|
||||
patch("knoe.config.DEPENDENCIES", [dep]), \
|
||||
patch.object(milestone, "_run_cmd", return_value=1):
|
||||
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=1), \
|
||||
pytest.raises(RuntimeError, match="Required dependencies still missing"):
|
||||
milestone.execute(state)
|
||||
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'."""
|
||||
state = _make_state(**{"dependencies.auto_install_missing": "True"})
|
||||
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}
|
||||
|
||||
def _dep_info(d):
|
||||
@ -639,10 +647,11 @@ def test_dependencies_install_succeeds_all():
|
||||
# First call (initial check): missing. Re-verify calls: installed.
|
||||
if call_count["n"] <= len([dep]):
|
||||
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), \
|
||||
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):
|
||||
milestone.execute(state)
|
||||
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()
|
||||
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")
|
||||
|
||||
@ -664,11 +673,13 @@ def test_dependencies_k8s_fails_without_gcloud_auth_session():
|
||||
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")), \
|
||||
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._gcloud_plain", side_effect=_unexpected_gcloud_plain), \
|
||||
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)
|
||||
|
||||
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
||||
@ -682,7 +693,7 @@ def test_dependencies_k8s_interactive_login_recovers_auth_session():
|
||||
}
|
||||
)
|
||||
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")
|
||||
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")
|
||||
|
||||
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._gcloud_plain", side_effect=_unexpected_gcloud_plain), \
|
||||
patch("knoe.core.milestones.subprocess.run", side_effect=[no_token, with_token]), \
|
||||
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:
|
||||
milestone.execute(state)
|
||||
|
||||
@ -703,6 +716,74 @@ def test_dependencies_k8s_interactive_login_recovers_auth_session():
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user