mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +00:00
chore: add GKE auth plugin and kubeconfig handling for improved GKE bootstrap
- Added `gke-gcloud-auth-plugin` as a required dependency for `linux/apt` environments. - Enhanced GKE context acquisition with writable kubeconfig target resolution. - Improved failure reporting with detailed messages for GKE dependency and context issues. - Expanded test coverage for scenarios involving missing auth plugins, kubeconfig errors, and GKE configurations.
This commit is contained in:
parent
797b566663
commit
6741a684dd
@ -1223,7 +1223,7 @@ def _linux_dependencies(manager: str) -> list[dict]:
|
||||
if manager == "apt"
|
||||
else lambda pkg: _rpm_install(manager, pkg)
|
||||
)
|
||||
return [
|
||||
deps = [
|
||||
{
|
||||
"id": "python",
|
||||
"name": "python",
|
||||
@ -1298,6 +1298,21 @@ def _linux_dependencies(manager: str) -> list[dict]:
|
||||
},
|
||||
]
|
||||
|
||||
if manager == "apt":
|
||||
deps.append(
|
||||
{
|
||||
"id": "gke-gcloud-auth-plugin",
|
||||
"name": "gke-gcloud-auth-plugin",
|
||||
"description": "GKE auth plugin required by kubectl for gcloud-based authentication",
|
||||
"url": "https://cloud.google.com/kubernetes-engine/docs/how-to/cluster-access-for-kubectl",
|
||||
"install_cmd": install("google-cloud-cli-gke-gcloud-auth-plugin"),
|
||||
"check_cmd": "gke-gcloud-auth-plugin --version",
|
||||
"bin": "gke-gcloud-auth-plugin",
|
||||
}
|
||||
)
|
||||
|
||||
return deps
|
||||
|
||||
|
||||
_DEP_PLATFORM_CACHE: Optional[dict] = None
|
||||
|
||||
@ -1440,7 +1455,14 @@ def get_required_dependency_ids(inputs: dict | None = None) -> set[str]:
|
||||
|
||||
base_required = {"python", "ansible", "kubectl", "kubectx", "docker"}
|
||||
if mode == "gke":
|
||||
return base_required | {"gcloud"}
|
||||
required = base_required | {"gcloud"}
|
||||
platform_info = detect_dependency_platform()
|
||||
if (
|
||||
platform_info.get("os") == "linux"
|
||||
and platform_info.get("package_manager") == "apt"
|
||||
):
|
||||
required.add("gke-gcloud-auth-plugin")
|
||||
return required
|
||||
if mode in {"dev", "k3d", "k3s"}:
|
||||
return base_required | {"k3d"}
|
||||
return base_required
|
||||
|
||||
@ -46,6 +46,7 @@ class DependenciesMilestone(Milestone):
|
||||
def __init__(self):
|
||||
super().__init__("dependencies", inst_config.get_dependency_milestone_title())
|
||||
self.logger = logging.getLogger("DependenciesMilestone")
|
||||
self._last_gke_context_failure_reason = ""
|
||||
|
||||
def execute(
|
||||
self, state: InstallerState, progress: ProgressCallback | None = None
|
||||
@ -161,10 +162,19 @@ class DependenciesMilestone(Milestone):
|
||||
state,
|
||||
"GKE bootstrap failed: gcloud authentication is required before cluster operations.",
|
||||
)
|
||||
if not self._ensure_gke_contexts_for_k8s(state):
|
||||
if not self._ensure_gke_auth_plugin_for_k8s(state):
|
||||
self._fatal_dependency_failure(
|
||||
state,
|
||||
"GKE bootstrap failed: required kube contexts are missing or could not be acquired.",
|
||||
"GKE bootstrap failed: gke-gcloud-auth-plugin is required before acquiring cluster contexts.",
|
||||
)
|
||||
if not self._ensure_gke_contexts_for_k8s(state):
|
||||
context_reason = str(getattr(self, "_last_gke_context_failure_reason", "") or "").strip()
|
||||
message = "GKE bootstrap failed: required kube contexts are missing or could not be acquired."
|
||||
if context_reason:
|
||||
message = f"GKE bootstrap failed: {context_reason}"
|
||||
self._fatal_dependency_failure(
|
||||
state,
|
||||
message,
|
||||
)
|
||||
self._set_status(state, "All installed")
|
||||
if progress:
|
||||
@ -205,6 +215,11 @@ class DependenciesMilestone(Milestone):
|
||||
def _is_gke_mode(self, state: InstallerState) -> bool:
|
||||
return "gcloud" in inst_config.get_required_dependency_ids(state.inputs)
|
||||
|
||||
def _ensure_gke_auth_plugin_for_k8s(self, state: InstallerState) -> bool:
|
||||
if not self._is_gke_mode(state):
|
||||
return True
|
||||
return bool(shutil.which("gke-gcloud-auth-plugin"))
|
||||
|
||||
def _gke_context_targets(self, state: InstallerState) -> list[dict]:
|
||||
def _first_non_empty(*keys: str) -> str:
|
||||
for key in keys:
|
||||
@ -249,8 +264,61 @@ class DependenciesMilestone(Milestone):
|
||||
seen.add(context)
|
||||
return deduped
|
||||
|
||||
def _get_kube_contexts(self) -> set[str] | None:
|
||||
def _resolve_gke_kubeconfig_target(self) -> tuple[str | None, str | None]:
|
||||
preferred = (Path.home() / ".kube" / "config").expanduser()
|
||||
raw_kubeconfig = str(os.environ.get("KUBECONFIG", "")).strip()
|
||||
|
||||
candidate_raw = ""
|
||||
if raw_kubeconfig:
|
||||
first_entry = raw_kubeconfig.split(os.pathsep)[0].strip()
|
||||
if first_entry:
|
||||
candidate_raw = first_entry
|
||||
|
||||
if candidate_raw:
|
||||
candidate = Path(candidate_raw).expanduser()
|
||||
if str(candidate) == "/etc/rancher/k3s/k3s.yaml":
|
||||
self.logger.warning(
|
||||
"Ignoring KUBECONFIG='%s' for GKE bootstrap (local k3s config); using '%s' instead.",
|
||||
candidate,
|
||||
preferred,
|
||||
)
|
||||
elif self._is_kubeconfig_target_writable(candidate):
|
||||
return str(candidate), None
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Ignoring non-writable KUBECONFIG='%s' for GKE bootstrap; using '%s' instead.",
|
||||
candidate,
|
||||
preferred,
|
||||
)
|
||||
|
||||
try:
|
||||
preferred.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc:
|
||||
return None, (
|
||||
f"kubeconfig target '{preferred}' is not writable "
|
||||
f"(failed to create parent directory: {exc})"
|
||||
)
|
||||
|
||||
if not self._is_kubeconfig_target_writable(preferred):
|
||||
return None, (
|
||||
f"kubeconfig target '{preferred}' is not writable. "
|
||||
"Set KUBECONFIG to a user-writable file and retry."
|
||||
)
|
||||
|
||||
return str(preferred), None
|
||||
|
||||
def _is_kubeconfig_target_writable(self, target: Path) -> bool:
|
||||
try:
|
||||
if target.exists():
|
||||
return target.is_file() and os.access(target, os.W_OK)
|
||||
return target.parent.exists() and target.parent.is_dir() and os.access(target.parent, os.W_OK)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _get_kube_contexts(self, kubeconfig_target: str | None = None) -> set[str] | None:
|
||||
kube_env = inst_config._augment_env_for_dependency_backend(os.environ.copy())
|
||||
if kubeconfig_target:
|
||||
kube_env["KUBECONFIG"] = kubeconfig_target
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["kubectl", "config", "get-contexts", "-o", "name"],
|
||||
@ -278,6 +346,7 @@ class DependenciesMilestone(Milestone):
|
||||
}
|
||||
|
||||
def _ensure_gke_contexts_for_k8s(self, state: InstallerState) -> bool:
|
||||
self._last_gke_context_failure_reason = ""
|
||||
if not self._is_gke_mode(state):
|
||||
return True
|
||||
|
||||
@ -285,8 +354,19 @@ class DependenciesMilestone(Milestone):
|
||||
if not targets:
|
||||
return True
|
||||
|
||||
kube_contexts = self._get_kube_contexts()
|
||||
kubeconfig_target, kubeconfig_error = self._resolve_gke_kubeconfig_target()
|
||||
if kubeconfig_error:
|
||||
self._last_gke_context_failure_reason = kubeconfig_error
|
||||
self.logger.error(
|
||||
"Unable to select writable kubeconfig target for GKE bootstrap: %s",
|
||||
kubeconfig_error,
|
||||
)
|
||||
return False
|
||||
self.logger.info("Using kubeconfig target for GKE bootstrap: %s", kubeconfig_target)
|
||||
|
||||
kube_contexts = self._get_kube_contexts(kubeconfig_target)
|
||||
if kube_contexts is None:
|
||||
self._last_gke_context_failure_reason = "failed to list kube contexts for the selected kubeconfig target"
|
||||
return False
|
||||
|
||||
missing_targets = [
|
||||
@ -297,6 +377,7 @@ class DependenciesMilestone(Milestone):
|
||||
|
||||
gcloud_bin = shutil.which("gcloud")
|
||||
if not gcloud_bin:
|
||||
self._last_gke_context_failure_reason = "gcloud is missing while required GKE contexts are absent"
|
||||
self.logger.error(
|
||||
"Missing gcloud; cannot acquire required GKE contexts: %s",
|
||||
", ".join(str(t.get("context") or "") for t in missing_targets),
|
||||
@ -307,6 +388,9 @@ class DependenciesMilestone(Milestone):
|
||||
context = str(target.get("context") or "")
|
||||
cluster = str(target.get("cluster") or "")
|
||||
if not cluster:
|
||||
self._last_gke_context_failure_reason = (
|
||||
f"cannot acquire missing context '{context}': cluster name is not configured"
|
||||
)
|
||||
self.logger.error(
|
||||
"Cannot acquire missing context '%s': cluster name is not configured.",
|
||||
context,
|
||||
@ -326,12 +410,16 @@ class DependenciesMilestone(Milestone):
|
||||
cmd += ["--project", project]
|
||||
|
||||
self.logger.info(
|
||||
"[ACTION] Acquiring GKE context '%s' via: %s",
|
||||
"[ACTION] Acquiring GKE context '%s' via: %s [kubeconfig=%s]",
|
||||
context,
|
||||
" ".join(shlex.quote(part) for part in cmd),
|
||||
kubeconfig_target,
|
||||
)
|
||||
rc = self._run_cmd(cmd)
|
||||
rc = self._run_cmd(cmd, env={"KUBECONFIG": str(kubeconfig_target)})
|
||||
if rc != 0:
|
||||
self._last_gke_context_failure_reason = (
|
||||
f"failed to acquire GKE context '{context}' for cluster '{cluster}'"
|
||||
)
|
||||
self.logger.error(
|
||||
"Failed to acquire GKE context '%s' for cluster '%s' (code %s)",
|
||||
context,
|
||||
@ -340,8 +428,9 @@ class DependenciesMilestone(Milestone):
|
||||
)
|
||||
return False
|
||||
|
||||
refreshed_contexts = self._get_kube_contexts()
|
||||
refreshed_contexts = self._get_kube_contexts(kubeconfig_target)
|
||||
if refreshed_contexts is None:
|
||||
self._last_gke_context_failure_reason = "failed to refresh kube contexts after GKE credential acquisition"
|
||||
return False
|
||||
|
||||
still_missing = [
|
||||
@ -350,6 +439,10 @@ class DependenciesMilestone(Milestone):
|
||||
if str(target.get("context") or "") not in refreshed_contexts
|
||||
]
|
||||
if still_missing:
|
||||
self._last_gke_context_failure_reason = (
|
||||
"required kube contexts are still missing after credential acquisition: "
|
||||
+ ", ".join(still_missing)
|
||||
)
|
||||
self.logger.error(
|
||||
"GKE contexts still missing after credential acquisition: %s",
|
||||
", ".join(still_missing),
|
||||
|
||||
@ -414,6 +414,26 @@ def test_get_platform_dependencies_linux_apt_gcloud_bootstraps_vendor_repo_when_
|
||||
assert "[ERROR] Failed to install apt package google-cloud-cli after repository bootstrap." in cmd
|
||||
|
||||
|
||||
def test_get_platform_dependencies_linux_apt_includes_gke_auth_plugin_package():
|
||||
with patch(
|
||||
"knoe.config.detect_dependency_platform",
|
||||
return_value={
|
||||
"os": "linux",
|
||||
"arch": "aarch64",
|
||||
"package_manager": "apt",
|
||||
"package_family": "apt/deb",
|
||||
"supported": True,
|
||||
"reason": None,
|
||||
},
|
||||
):
|
||||
deps = get_platform_dependencies()
|
||||
|
||||
plugin_dep = next(d for d in deps if d["id"] == "gke-gcloud-auth-plugin")
|
||||
cmd = str(plugin_dep.get("install_cmd") or "")
|
||||
assert "google-cloud-cli-gke-gcloud-auth-plugin" in cmd
|
||||
assert "apt-get install -y google-cloud-cli-gke-gcloud-auth-plugin" in cmd
|
||||
|
||||
|
||||
def test_get_platform_dependencies_linux_apt_opentofu_bootstraps_vendor_repo_when_missing():
|
||||
with patch(
|
||||
"knoe.config.detect_dependency_platform",
|
||||
@ -454,6 +474,24 @@ def test_get_required_dependency_ids_gke_excludes_k3d_and_opentofu():
|
||||
assert "opentofu" not in required
|
||||
|
||||
|
||||
def test_get_required_dependency_ids_gke_linux_apt_includes_gke_auth_plugin():
|
||||
with patch(
|
||||
"knoe.config.detect_dependency_platform",
|
||||
return_value={
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"package_manager": "apt",
|
||||
"package_family": "apt/deb",
|
||||
"supported": True,
|
||||
"reason": None,
|
||||
},
|
||||
):
|
||||
required = get_required_dependency_ids({"init_cluster.cluster_env": "prod"})
|
||||
|
||||
assert "gcloud" in required
|
||||
assert "gke-gcloud-auth-plugin" in required
|
||||
|
||||
|
||||
def test_get_required_dependencies_filters_platform_dependencies_for_gke():
|
||||
deps = [
|
||||
{"id": "python", "name": "python"},
|
||||
|
||||
@ -708,6 +708,7 @@ def test_dependencies_k8s_interactive_login_recovers_auth_session():
|
||||
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_auth_plugin_for_k8s", 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)
|
||||
@ -753,6 +754,30 @@ def test_dependencies_k8s_acquires_missing_contexts_via_gcloud():
|
||||
assert run_cmd_mock.call_count == 2
|
||||
|
||||
|
||||
def test_dependencies_k8s_fails_when_auth_plugin_missing():
|
||||
state = _make_state(
|
||||
**{
|
||||
"dependencies.auto_install_missing": "True",
|
||||
"init_cluster.cluster_env": "prod",
|
||||
}
|
||||
)
|
||||
milestone = DependenciesMilestone()
|
||||
dep = {"id": "gcloud", "name": "gcloud", "install_cmd": "apt install google-cloud-cli"}
|
||||
|
||||
token_ok = MagicMock(returncode=0, stdout="tok123\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.subprocess.run", return_value=token_ok), \
|
||||
patch("knoe.core.milestones.shutil.which", return_value=None), \
|
||||
pytest.raises(RuntimeError, match="gke-gcloud-auth-plugin is required"):
|
||||
milestone.execute(state)
|
||||
|
||||
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
||||
|
||||
|
||||
def test_dependencies_k8s_fails_when_context_missing_and_gcloud_unavailable():
|
||||
state = _make_state(
|
||||
**{
|
||||
@ -773,12 +798,77 @@ def test_dependencies_k8s_fails_when_context_missing_and_gcloud_unavailable():
|
||||
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.shutil.which",
|
||||
side_effect=lambda binary: "/usr/bin/gke-gcloud-auth-plugin"
|
||||
if binary == "gke-gcloud-auth-plugin"
|
||||
else None,
|
||||
), \
|
||||
patch(
|
||||
"knoe.core.milestones.subprocess.run",
|
||||
side_effect=[token_ok, contexts_before],
|
||||
), \
|
||||
pytest.raises(RuntimeError, match="required kube contexts are missing"):
|
||||
pytest.raises(RuntimeError, match="gcloud is missing while required GKE contexts are absent"):
|
||||
milestone.execute(state)
|
||||
|
||||
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
||||
|
||||
|
||||
def test_dependencies_k8s_uses_user_kubeconfig_when_k3s_kubeconfig_is_set(tmp_path):
|
||||
state = _make_state(
|
||||
**{
|
||||
"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()
|
||||
|
||||
contexts_before = MagicMock(returncode=0, stdout="", stderr="")
|
||||
contexts_after = MagicMock(returncode=0, stdout="ctx-app\n", stderr="")
|
||||
|
||||
with 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.Path.home", return_value=tmp_path), \
|
||||
patch.dict("knoe.core.milestones.os.environ", {"KUBECONFIG": "/etc/rancher/k3s/k3s.yaml"}, clear=False), \
|
||||
patch(
|
||||
"knoe.core.milestones.subprocess.run",
|
||||
side_effect=[contexts_before, contexts_after],
|
||||
), \
|
||||
patch.object(milestone, "_run_cmd", return_value=0) as run_cmd_mock:
|
||||
assert milestone._ensure_gke_contexts_for_k8s(state) is True
|
||||
|
||||
expected_target = str(tmp_path / ".kube" / "config")
|
||||
assert run_cmd_mock.call_count == 1
|
||||
assert run_cmd_mock.call_args.kwargs["env"]["KUBECONFIG"] == expected_target
|
||||
|
||||
|
||||
def test_dependencies_k8s_fails_with_clear_message_when_kubeconfig_target_not_writable(tmp_path):
|
||||
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="")
|
||||
|
||||
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.subprocess.run", return_value=token_ok), \
|
||||
patch("knoe.core.milestones.shutil.which", return_value="/usr/bin/gke-gcloud-auth-plugin"), \
|
||||
patch("knoe.core.milestones.Path.home", return_value=tmp_path), \
|
||||
patch.object(milestone, "_is_kubeconfig_target_writable", return_value=False), \
|
||||
pytest.raises(RuntimeError, match="kubeconfig target '.+/.kube/config' is not writable"):
|
||||
milestone.execute(state)
|
||||
|
||||
assert state.config_data.get("Dependencies", {}).get("STATUS") == "Missing"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user