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:
chrisfu 2026-04-12 18:51:35 -07:00
parent 797b566663
commit 6741a684dd
4 changed files with 254 additions and 11 deletions

View File

@ -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

View File

@ -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),

View File

@ -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"},

View File

@ -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"