From 32e2e3598d0807f0d9ab34e5cfa41c55eb86218d Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 9 Apr 2026 12:06:07 +0200 Subject: [PATCH 01/54] feat: remove Helm charts and keep only operator-based deployment Remove all Helm-related code, configuration, CI steps, and documentation from the repository. The project has transitioned to OLM operator-based deployment, making Helm artifacts dead code. Changes: - Delete controller/deploy/helm/ directory (43 files) - Delete controller/hack/deploy_with_helm.sh and install_helm.sh - Update controller Makefile to generate CRDs/RBAC directly to operator config paths and remove Helm targets - Update Go test CRD paths to use operator config/crd/bases - Remove jumpstarter_kubernetes install.py, cluster/helm.py and their test files - Remove jumpstarter_cli_admin install.py and its test file - Update all __init__.py exports to remove Helm symbols - Update cluster/operations.py to always use operator install - Update cluster/kubectl.py to use CRD-only detection - Update CLI create.py and get.py to remove Helm options - Remove publish-helm-charts job from build-images.yaml - Remove lint-helm job from lint.yaml - Remove Helm from controller-kind.yaml CI matrix - Update e2e scripts to use operator-only deployment - Update documentation and rule files Closes #445 Generated-By: Forge/20260409_114748_239149_5e586097 Co-Authored-By: Claude Opus 4.6 --- .cursor/rules/working-with-operator.mdc | 1 - .github/workflows/build-images.yaml | 39 - .github/workflows/controller-kind.yaml | 9 +- .github/workflows/lint.yaml | 18 - controller/Makefile | 35 +- .../deploy/helm/jumpstarter/.helmignore | 23 - controller/deploy/helm/jumpstarter/Chart.yaml | 9 - .../charts/jumpstarter-controller/Chart.yaml | 7 - .../charts/jumpstarter-controller/model.py | 385 ----- .../templates/_endpoints.tpl | 2 - .../additional-router-deployment.yaml | 111 -- .../templates/additional-router-ingress.yaml | 47 - .../templates/additional-router-route.yaml | 42 - .../templates/additional-router-service.yaml | 27 - .../templates/cms/controller-cm.yaml | 42 - .../templates/controller-deployment.yaml | 144 -- .../templates/controller-ingress.yaml | 44 - .../templates/controller-route.yaml | 34 - .../templates/controller-service.yaml | 24 - .../crds/jumpstarter.dev_clients.yaml | 69 - ...umpstarter.dev_exporteraccesspolicies.yaml | 166 -- .../crds/jumpstarter.dev_exporters.yaml | 185 --- .../crds/jumpstarter.dev_leases.yaml | 256 --- .../templates/login-ingress.yaml | 36 - .../templates/login-route.yaml | 38 - .../templates/login-service.yaml | 26 - .../templates/metrics/metrics_service.yaml | 16 - .../templates/metrics/monitor.yaml | 20 - .../templates/rbac/client_editor_role.yaml | 26 - .../templates/rbac/client_viewer_role.yaml | 22 - .../templates/rbac/exporter_editor_role.yaml | 26 - .../templates/rbac/exporter_viewer_role.yaml | 22 - .../templates/rbac/leader_election_role.yaml | 40 - .../rbac/leader_election_role_binding.yaml | 15 - .../templates/rbac/role.yaml | 67 - .../templates/rbac/role_binding.yaml | 16 - .../templates/rbac/service_account.yaml | 9 - .../templates/router-deployment.yaml | 109 -- .../templates/router-ingress.yaml | 44 - .../templates/router-route.yaml | 39 - .../templates/router-service.yaml | 24 - .../templates/secrets-job.yaml | 36 - .../jumpstarter-controller/values.schema.json | 1458 ----------------- .../charts/jumpstarter-controller/values.yaml | 76 - controller/deploy/helm/jumpstarter/model.py | 68 - .../deploy/helm/jumpstarter/values.kind.yaml | 10 - .../helm/jumpstarter/values.schema.json | 233 --- .../deploy/helm/jumpstarter/values.yaml | 125 -- controller/deploy/operator/README.md | 18 - .../jumpstarter/jumpstarter_controller.go | 4 +- controller/hack/deploy_with_helm.sh | 74 - controller/hack/install_helm.sh | 17 - controller/hack/utils | 2 +- controller/internal/controller/suite_test.go | 2 +- controller/internal/service/suite_test.go | 2 +- e2e/compat/setup.sh | 41 +- e2e/setup-e2e.sh | 42 +- .../jumpstarter_cli_admin/__init__.py | 5 - .../jumpstarter_cli_admin/create.py | 37 - .../jumpstarter_cli_admin/create_test.py | 35 - .../jumpstarter_cli_admin/get.py | 12 +- .../jumpstarter_cli_admin/install.py | 255 --- .../jumpstarter_cli_admin/install_test.py | 137 -- .../jumpstarter_kubernetes/__init__.py | 8 - .../cluster/__init__.py | 8 - .../jumpstarter_kubernetes/cluster/common.py | 1 - .../jumpstarter_kubernetes/cluster/helm.py | 53 - .../cluster/helm_test.py | 243 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 95 +- .../cluster/kubectl_test.py | 68 +- .../cluster/operations.py | 70 +- .../cluster/operations_test.py | 30 +- .../jumpstarter_kubernetes/exceptions.py | 2 +- .../jumpstarter_kubernetes/install.py | 91 - .../jumpstarter_kubernetes/install_test.py | 258 --- 75 files changed, 85 insertions(+), 5845 deletions(-) delete mode 100644 controller/deploy/helm/jumpstarter/.helmignore delete mode 100644 controller/deploy/helm/jumpstarter/Chart.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/Chart.yaml delete mode 100755 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/model.py delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/_endpoints.tpl delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-deployment.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-ingress.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-route.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-service.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/cms/controller-cm.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-deployment.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-ingress.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-route.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-service.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_clients.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporteraccesspolicies.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporters.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_leases.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-ingress.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-route.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-service.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/metrics/metrics_service.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/metrics/monitor.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/client_editor_role.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/client_viewer_role.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/exporter_editor_role.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/exporter_viewer_role.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/leader_election_role.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/leader_election_role_binding.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role_binding.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/service_account.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-deployment.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-ingress.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-route.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-service.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/secrets-job.yaml delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/values.schema.json delete mode 100644 controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/values.yaml delete mode 100755 controller/deploy/helm/jumpstarter/model.py delete mode 100644 controller/deploy/helm/jumpstarter/values.kind.yaml delete mode 100644 controller/deploy/helm/jumpstarter/values.schema.json delete mode 100644 controller/deploy/helm/jumpstarter/values.yaml delete mode 100755 controller/hack/deploy_with_helm.sh delete mode 100755 controller/hack/install_helm.sh delete mode 100644 python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py delete mode 100644 python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py delete mode 100644 python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm.py delete mode 100644 python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm_test.py delete mode 100644 python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py delete mode 100644 python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install_test.py diff --git a/.cursor/rules/working-with-operator.mdc b/.cursor/rules/working-with-operator.mdc index 878c66c4f..b070569fb 100644 --- a/.cursor/rules/working-with-operator.mdc +++ b/.cursor/rules/working-with-operator.mdc @@ -25,7 +25,6 @@ controller/ │ │ ├── bundle/ # OLM bundle manifests │ │ ├── test/e2e/ # E2E tests │ │ └── Makefile # Operator-specific make targets -│ └── helm/ # Helm charts (alternative deployment) ├── api/ # Jumpstarter core CRDs (Client, Exporter, Lease, etc.) ├── internal/ # Controller business logic │ └── config/ # Config structs used by controller diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index 3f123d183..9d786c2e3 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -273,42 +273,3 @@ jobs: }); } - publish-helm-charts: - needs: build-and-push-image - if: ${{ github.repository_owner == 'jumpstarter-dev' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || startsWith(github.ref, 'refs/heads/release-')) }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Get version - run: | - VERSION=$(git describe --tags) - VERSION=${VERSION#v} # remove the leading v prefix for version - echo "VERSION=${VERSION}" >> $GITHUB_ENV - echo "VERSION=${VERSION}" - - - name: Build helm charts - run: | - echo packaging ${VERSION} - # patch the sub-chart app-version, because helm package won't do it - sed -i "s/^appVersion:.*/appVersion: $VERSION/" controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/Chart.yaml - helm package ./controller/deploy/helm/jumpstarter --version "${VERSION}" --app-version "${VERSION}" - - - name: Login helm - env: - PASSWORD: ${{ secrets.QUAY_TOKEN }} - USER: jumpstarter-dev+jumpstarter_ci - run: - helm registry login quay.io -u ${USER} -p ${PASSWORD} - - - name: Push helm charts - run: | - helm push jumpstarter-*.tgz oci://${{ env.QUAY_ORG }}/helm - - if [[ "${{ github.ref }}" == "refs/heads/release-*" ]]; then - RELEASE_BRANCH_NAME=$(basename "${{ github.ref }}") - helm chart save jumpstarter-*.tgz ${{ env.QUAY_ORG }}/helm:${RELEASE_BRANCH_NAME} - helm chart push ${{ env.QUAY_ORG }}/helm:${RELEASE_BRANCH_NAME} - fi diff --git a/.github/workflows/controller-kind.yaml b/.github/workflows/controller-kind.yaml index 59dee0e52..246f3d225 100644 --- a/.github/workflows/controller-kind.yaml +++ b/.github/workflows/controller-kind.yaml @@ -8,11 +8,6 @@ on: jobs: deploy-kind: - strategy: - matrix: - method: - - helm - - operator runs-on: ubuntu-latest steps: - name: Checkout repository @@ -20,11 +15,9 @@ jobs: with: fetch-depth: 0 - - name: Run make deploy (${{ matrix.method }}) + - name: Run make deploy working-directory: controller run: make deploy - env: - METHOD: ${{ matrix.method }} e2e-test-operator: runs-on: ubuntu-latest diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 56c7227cd..0df223888 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -19,7 +19,6 @@ jobs: runs-on: ubuntu-latest outputs: controller: ${{ steps.filter.outputs.controller }} - helm: ${{ steps.filter.outputs.helm }} protocol: ${{ steps.filter.outputs.protocol }} python: ${{ steps.filter.outputs.python }} steps: @@ -31,9 +30,6 @@ jobs: controller: - 'controller/**' - '.github/workflows/lint.yaml' - helm: - - 'controller/deploy/helm/**' - - '.github/workflow/lint.yaml' protocol: - 'protocol/**' - '.github/workflows/lint.yaml' @@ -60,20 +56,6 @@ jobs: working-directory: controller run: make lint - lint-helm: - needs: changes - if: needs.changes.outputs.helm == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Run helm linter - working-directory: controller - run: make lint-helm - lint-protobuf: needs: changes if: needs.changes.outputs.protocol == 'true' diff --git a/controller/Makefile b/controller/Makefile index 08c9aa00e..b7da7c872 100644 --- a/controller/Makefile +++ b/controller/Makefile @@ -30,7 +30,7 @@ endif # tools. (i.e. podman) CONTAINER_TOOL ?= podman -# Deployment method: operator (default) or helm +# Deployment method METHOD ?= operator # Cluster type: kind (default) or k3s @@ -67,17 +67,8 @@ help: ## Display this help. .PHONY: manifests manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. $(CONTROLLER_GEN) rbac:roleName=jumpstarter-manager-role crd webhook paths="./api/..." paths="./internal/..." \ - output:crd:artifacts:config=deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/ \ - output:rbac:artifacts:config=deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/ - - # Add ArgoCD sync-wave annotation to RBAC role for proper deployment ordering (PR #207) - @awk '/^ name: jumpstarter-manager-role$$/{print; print " annotations:"; print " argocd.argoproj.io/sync-wave: \"-1\""; next}1' \ - deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role.yaml > \ - deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role.yaml.tmp && \ - mv deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role.yaml.tmp \ - deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role.yaml - - cp deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/* deploy/operator/config/crd/bases/ + output:crd:artifacts:config=deploy/operator/config/crd/bases/ \ + output:rbac:artifacts:config=deploy/operator/config/rbac/ # Regenerate operator install.yaml to include updated CRDs $(MAKE) -C deploy/operator build-installer @@ -188,25 +179,12 @@ uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - .PHONY: deploy -deploy: cluster grpcurl ## Deploy controller using METHOD (operator or helm). Set SKIP_BUILD=1 to skip image builds. +deploy: cluster grpcurl ## Deploy controller using the operator. Set SKIP_BUILD=1 to skip image builds. Set CLUSTER_TYPE=k3s for k3s. ifeq ($(SKIP_BUILD),) $(MAKE) docker-build -endif -ifeq ($(METHOD),operator) -ifeq ($(SKIP_BUILD),) $(MAKE) build-operator endif ./hack/deploy_with_operator.sh -else ifeq ($(METHOD),helm) - ./hack/deploy_with_helm.sh -else - $(error Unknown METHOD=$(METHOD). Use 'operator' or 'helm') -endif - -# Backward compatibility alias -.PHONY: deploy-with-operator -deploy-with-operator: - $(MAKE) deploy METHOD=operator .PHONY: deploy-operator deploy-operator: docker-build build-operator cluster grpcurl ## Deploy only the operator (without Jumpstarter CR) @@ -227,11 +205,6 @@ deploy-with-operator-parallel: deploy-exporters: ./hack/demoenv/prepare_exporters.sh -.PHONY: lint-helm -lint-helm: - helm lint deploy/helm/jumpstarter - - .PHONY: undeploy undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - diff --git a/controller/deploy/helm/jumpstarter/.helmignore b/controller/deploy/helm/jumpstarter/.helmignore deleted file mode 100644 index 0e8a0eb36..000000000 --- a/controller/deploy/helm/jumpstarter/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/controller/deploy/helm/jumpstarter/Chart.yaml b/controller/deploy/helm/jumpstarter/Chart.yaml deleted file mode 100644 index c6cbd9782..000000000 --- a/controller/deploy/helm/jumpstarter/Chart.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v2 -name: jumpstarter -description: A helm chart for the jumpstarter project -type: application -version: 0.1.0 -appVersion: "0.1.0" -dependencies: - - name: jumpstarter-controller - condition: jumpstarter-controller.enabled \ No newline at end of file diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/Chart.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/Chart.yaml deleted file mode 100644 index 3c297a894..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/Chart.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v2 -name: jumpstarter-controller -description: A helm chart for jumpstarter-controller -type: application -version: 0.0.1 -appVersion: 0.0.1 - diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/model.py b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/model.py deleted file mode 100755 index e9d789d7d..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/model.py +++ /dev/null @@ -1,385 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = ["pydantic"] -# /// - -from __future__ import annotations - -import json - -from enum import Enum -from typing import List, Optional, Union - -from pydantic import BaseModel, ConfigDict, Field, RootModel, conint - - -class Provisioning(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = None - - -class Internal(BaseModel): - model_config = ConfigDict(extra="forbid") - - prefix: Optional[str] = None - - -class Keepalive(BaseModel): - model_config = ConfigDict(extra="forbid") - - minTime: Optional[str] = Field( - None, - description="The minimum amount of time a client should wait before sending a keepalive ping", - ) - permitWithoutStream: Optional[bool] = Field( - None, - description="Whether to allow keepalive pings even when there are no active streams(RPCs)", - ) - - -class Grpc(BaseModel): - model_config = ConfigDict(extra="forbid") - - keepalive: Optional[Keepalive] = None - - -class Metrics(BaseModel): - enabled: Optional[bool] = None - - -class Global(BaseModel): - baseDomain: Optional[str] = Field( - None, description="Base domain to construct the FQDN for the service endpoints" - ) - metrics: Optional[Metrics] = None - - -class Mode(Enum): - ingress = "ingress" - route = "route" - nodeport = "nodeport" - external = "external" # Disable ingress and route generation - - -class Mode1(Enum): - passthrough = "passthrough" - reencrypt = "reencrypt" - - -class Port(RootModel): - root: conint(ge=0, le=65535) - - -class Ingress(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = Field( - None, description="Whether to enable Ingress for the gRPC endpoint" - ) - class_: Optional[str] = Field( - None, alias="class", description="IngressClass to use for the gRPC endpoint" - ) - - -class Route(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = Field( - None, description="Whether to enable OpenShift Router for the gRPC endpoint" - ) - - -class LoginRoute(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = Field( - None, description="Whether to enable OpenShift Route for the login endpoint" - ) - annotations: Optional[dict[str, str]] = Field( - None, description="Annotations for the login route" - ) - labels: Optional[dict[str, str]] = Field( - None, description="Labels for the login route" - ) - - -class LoginIngressTls(BaseModel): - model_config = ConfigDict(extra="forbid") - - secretName: Optional[str] = Field( - None, description="Secret name for the TLS certificate" - ) - - -class LoginIngress(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = Field( - None, description="Whether to enable Ingress for the login endpoint" - ) - class_: Optional[str] = Field( - None, alias="class", description="IngressClass to use for the login endpoint" - ) - annotations: Optional[dict[str, str]] = Field( - None, description="Annotations for the login ingress" - ) - labels: Optional[dict[str, str]] = Field( - None, description="Labels for the login ingress" - ) - tls: Optional[LoginIngressTls] = None - - -class LoginNodeport(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = Field( - None, description="Whether to enable NodePort for the login endpoint" - ) - port: Optional[Port] = Field( - None, description="NodePort port number for the login service" - ) - - -class LoginTls(BaseModel): - model_config = ConfigDict(extra="forbid") - - secretName: Optional[str] = Field( - None, - description="Name of the Kubernetes secret containing tls.crt and tls.key for edge TLS termination", - ) - - -class Login(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = Field( - None, description="Whether to enable the login endpoint for simplified CLI login" - ) - hostname: Optional[str] = Field( - None, description="Hostname for the login endpoint" - ) - endpoint: Optional[str] = Field( - None, description="The endpoint URL to display in the login landing page" - ) - tls: Optional[LoginTls] = Field( - None, - description="TLS configuration for edge termination (used by both route and ingress)", - ) - route: Optional[LoginRoute] = None - ingress: Optional[LoginIngress] = None - nodeport: Optional[LoginNodeport] = None - - -class PrefixedClaimOrExpression1(BaseModel): - model_config = ConfigDict(extra="forbid") - - claim: str - prefix: str - - -class PrefixedClaimOrExpression2(BaseModel): - model_config = ConfigDict(extra="forbid") - - expression: str - - -class PrefixedClaimOrExpression(RootModel): - root: Union[PrefixedClaimOrExpression1, PrefixedClaimOrExpression2] - - -class ClaimOrExpression1(BaseModel): - model_config = ConfigDict(extra="forbid") - - claim: str - expression: Optional[str] = None - - -class ClaimOrExpression2(BaseModel): - model_config = ConfigDict(extra="forbid") - - claim: Optional[str] = None - expression: str - - -class ClaimOrExpression(RootModel): - root: Union[ClaimOrExpression1, ClaimOrExpression2] - - -class AudienceMatchPolicy(Enum): - MatchAny = "MatchAny" - - -class Issuer(BaseModel): - model_config = ConfigDict(extra="forbid") - - url: Optional[str] = None - discoveryURL: Optional[str] = None - certificateAuthority: Optional[str] = None - audiences: Optional[List[str]] = None - audienceMatchPolicy: Optional[AudienceMatchPolicy] = None - - -class ClaimValidationRule(BaseModel): - model_config = ConfigDict(extra="forbid") - - claim: Optional[str] = None - requiredValue: Optional[str] = None - expression: Optional[str] = None - message: Optional[str] = None - - -class ExtraItem(BaseModel): - model_config = ConfigDict(extra="forbid") - - key: Optional[str] = None - valueExpression: Optional[str] = None - - -class ClaimMappings(BaseModel): - model_config = ConfigDict(extra="forbid") - - username: Optional[PrefixedClaimOrExpression] = None - groups: Optional[PrefixedClaimOrExpression] = None - uid: Optional[ClaimOrExpression] = None - extra: Optional[List[ExtraItem]] = None - - -class UserValidationRule(BaseModel): - model_config = ConfigDict(extra="forbid") - - expression: Optional[str] = None - message: Optional[str] = None - - -class JWTAuthenticator(BaseModel): - model_config = ConfigDict(extra="forbid") - - issuer: Optional[Issuer] = None - claimValidationRules: Optional[List[ClaimValidationRule]] = None - claimMappings: Optional[ClaimMappings] = None - userValidationRules: Optional[List[UserValidationRule]] = None - - -class Authentication(BaseModel): - model_config = ConfigDict(extra="forbid") - - internal: Optional[Internal] = None - jwt: Optional[List[JWTAuthenticator]] = Field( - None, - description="External OIDC authentication, see https://kubernetes.io/docs/reference/access-authn-authz/authentication/#using-authentication-configuration for documentation", - ) - - -class JumpstarterConfig(BaseModel): - model_config = ConfigDict(extra="forbid") - - provisioning: Optional[Provisioning] = None - authentication: Optional[Authentication] = None - grpc: Optional[Grpc] = None - - -class Nodeport(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = None - port: Optional[Port] = None - routerPort: Optional[Port] = None - - -class Tls(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = None - secret: Optional[str] = None - controllerCertSecret: Optional[str] = Field( - None, - description="Secret containing the TLS certificate/key for the gRPC controller endpoint", - ) - routerCertSecret: Optional[str] = Field( - None, - description="Secret containing the TLS certificate/key for the gRPC router endpoints", - ) - port: Optional[Port] = Field( - None, - description="Port to use for the gRPC endpoints Ingress or Route, this can be useful for ingress routers on non-standard ports", - ) - mode: Optional[Mode1] = Field(None, description="TLS mode for gRPC endpoints") - - -class Grpc1(BaseModel): - model_config = ConfigDict(extra="forbid") - - hostname: Optional[str] = Field( - None, description="Hostname for the controller to use for the controller gRPC" - ) - routerHostname: Optional[str] = Field( - None, description="Hostname for the router to use for the router gRPC" - ) - endpoint: Optional[str] = Field( - None, - description="The endpoints are passed down to the services to know where to announce the endpoints to the clients", - ) - routerEndpoint: Optional[str] = Field( - None, - description="The endpoints are passed down to the services to know where to announce the endpoints to the clients", - ) - additionalRouters: dict[str, Router] | None = Field( - None, description="Additional routers to deploy" - ) - ingress: Optional[Ingress] = None - route: Optional[Route] = None - nodeport: Optional[Nodeport] = None - mode: Optional[Mode] = None - tls: Optional[Tls] = None - - -class Router(BaseModel): - model_config = ConfigDict(extra="forbid") - hostname: str | None = None - endpoint: str | None = None - labels: dict[str, str] | None = None - nodeSelector: dict[str, str] | None = None - - -class CertManager(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = Field( - None, - description="Enable cert-manager integration. When enabled, jumpstarter-service-ca-cert configmap is required.", - ) - - -class Model(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = Field( - None, description="Whether to enable jumpstarter controller" - ) - authenticationConfig: Optional[str] = None - config: Optional[JumpstarterConfig] = None - namespace: Optional[str] = Field( - None, - description="Namespace where the controller will be deployed, defaults to global.namespace", - ) - image: str = Field(..., description="Image for the controller") - tag: Optional[str] = Field(None, description="Image tag for the controller") - imagePullPolicy: str = Field( - ..., description="Image pull policy for the controller" - ) - global_: Optional[Global] = Field( - None, alias="global", description="Global parameters" - ) - certManager: Optional[CertManager] = Field( - None, - description="cert-manager integration for automatic TLS certificate management", - ) - grpc: Optional[Grpc1] = None - login: Optional[Login] = Field( - None, description="Login endpoint configuration for simplified CLI login" - ) - - -print(json.dumps(Model.model_json_schema(), indent=2)) diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/_endpoints.tpl b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/_endpoints.tpl deleted file mode 100644 index a925e71cb..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/_endpoints.tpl +++ /dev/null @@ -1,2 +0,0 @@ -{{- define "router.endpoint" }}{{ if .Values.grpc.routerHostname }}{{ .Values.grpc.routerHostname }}{{ else }}router.{{ .Values.global.baseDomain | required "grpc.routerHostname or global.baseDomain must be set"}}{{ end }}{{- end }} -{{- define "controller.endpoint" }}{{ if .Values.grpc.hostname }}{{ .Values.grpc.hostname }}{{ else }}grpc.{{ .Values.global.baseDomain | required "grpc.hostname or global.baseDomain must be set"}}{{ end }}{{- end }} \ No newline at end of file diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-deployment.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-deployment.yaml deleted file mode 100644 index 49cc02726..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-deployment.yaml +++ /dev/null @@ -1,111 +0,0 @@ -{{ range $k, $v := .Values.grpc.additionalRouters }} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: jumpstarter-router-{{ $k }} - namespace: {{ default $.Release.Namespace $.Values.namespace }} - labels: - control-plane: controller-router-{{ $k }} - app.kubernetes.io/name: jumpstarter-controller - {{ if $.Values.global.timestamp }} - deployment.timestamp: {{ $.Values.global.timestamp | quote }} - {{ end }} - annotations: - argocd.argoproj.io/sync-wave: "1" -spec: - selector: - matchLabels: - control-plane: controller-router-{{ $k }} - replicas: 1 - template: - metadata: - annotations: - kubectl.kubernetes.io/default-container: router - configmap-sha256: {{ include (print $.Template.BasePath "/cms/controller-cm.yaml") $ | sha256sum }} - labels: - control-plane: controller-router-{{ $k }} - {{ if $.Values.global.timestamp }} - deployment.timestamp: {{ $.Values.global.timestamp | quote }} - {{ end }} - spec: - # TODO(user): Uncomment the following code to configure the nodeAffinity expression - # according to the platforms which are supported by your solution. - # It is considered best practice to support multiple architectures. You can - # build your manager image using the makefile target docker-buildx. - # affinity: - # nodeAffinity: - # requiredDuringSchedulingIgnoredDuringExecution: - # nodeSelectorTerms: - # - matchExpressions: - # - key: kubernetes.io/arch - # operator: In - # values: - # - amd64 - # - arm64 - # - ppc64le - # - s390x - # - key: kubernetes.io/os - # operator: In - # values: - # - linux - {{ if $v.nodeSelector }} - nodeSelector: - {{ $v.nodeSelector | toYaml | indent 1 }} - {{ end }} - securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - containers: - - command: - - /router - env: - - name: GRPC_ROUTER_ENDPOINT - {{ if $v.endpoint }} - value: {{ $v.endpoint }} - {{ else if $v.hostname }} - value: {{ $v.hostname }}:{{ default 443 $.Values.grpc.tls.port }} - {{ else }} - value: router-{{ $k }}.{{ $.Values.global.baseDomain | required "set .global.baseDomain, or provide grpc.additionalRouters[...].endpoint/hostname" }}:{{ default 443 $.Values.grpc.tls.port }} - {{ end }} - - name: ROUTER_KEY - valueFrom: - secretKeyRef: - name: jumpstarter-router-secret - key: key - - name: NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - image: {{ $.Values.image }}:{{ default $.Chart.AppVersion $.Values.tag }} - imagePullPolicy: {{ $.Values.imagePullPolicy }} - name: router - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - "ALL" - # livenessProbe: - # httpGet: - # path: /healthz - # port: 8081 - # initialDelaySeconds: 15 - # periodSeconds: 20 - # readinessProbe: - # httpGet: - # path: /readyz - # port: 8081 - # initialDelaySeconds: 5 - # periodSeconds: 10 - resources: - limits: - cpu: 2000m - memory: 1024Mi - requests: - cpu: 1000m - memory: 256Mi - serviceAccountName: controller-manager - terminationGracePeriodSeconds: 10 -{{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-ingress.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-ingress.yaml deleted file mode 100644 index 92522341e..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-ingress.yaml +++ /dev/null @@ -1,47 +0,0 @@ -{{ if eq .Values.grpc.mode "ingress" }} -{{ range $k, $v := .Values.grpc.additionalRouters }} ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - nginx.ingress.kubernetes.io/ssl-redirect: "true" - nginx.ingress.kubernetes.io/backend-protocol: "GRPC" - nginx.ingress.kubernetes.io/proxy-read-timeout: "300" - nginx.ingress.kubernetes.io/proxy-send-timeout: "300" - {{ if eq $.Values.grpc.tls.mode "passthrough" }} - nginx.ingress.kubernetes.io/ssl-passthrough: "true" - {{ end }} - name: jumpstarter-router-ingress-{{ $k }} - namespace: {{ default $.Release.Namespace $.Values.namespace }} -spec: - {{ if $.Values.grpc.ingress.class }} - ingressClassName: {{ $.Values.grpc.ingress.class }} - {{ end }} - rules: - {{ if $v.hostname }} - - host: {{ $v.hostname }} - {{ else }} - - host: router-{{ $k }}.{{ $.Values.global.baseDomain | required "a global.baseDomain or a grpc.routerHostname must be provided"}} - {{ end }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: jumpstarter-router-grpc-{{ $k }} - port: - number: 8083 - tls: - - hosts: - {{ if $v.hostname }} - - {{ $v.hostname }} - {{ else }} - - router-{{ $k }}.{{ $.Values.global.baseDomain | required "a global.baseDomain or a grpc.routerHostname must be provided"}} - {{ end }} - {{ if $.Values.grpc.tls.routerCertSecret }} - secretName: {{ $.Values.grpc.tls.routerCertSecret }} - {{ end }} -{{ end }} -{{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-route.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-route.yaml deleted file mode 100644 index b001804a8..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-route.yaml +++ /dev/null @@ -1,42 +0,0 @@ -{{ if eq .Values.grpc.mode "route" }} -{{ range $k, $v := .Values.grpc.additionalRouters }} ---- -apiVersion: route.openshift.io/v1 -kind: Route -metadata: - labels: - external-exposed: "true" - shard: external - annotations: - haproxy.router.openshift.io/timeout: 2d - haproxy.router.openshift.io/timeout-tunnel: 2d - name: jumpstarter-router-route-{{ $k }} - namespace: {{ default $.Release.Namespace $.Values.namespace }} -spec: - {{ if $v.hostname }} - host: {{ $v.hostname }} - {{ else }} - host: router-{{ $k }}.{{ $.Values.global.baseDomain | required "a global.baseDomain or a grpc.routerHostname must be provided"}} - {{ end }} - port: - targetPort: 8083 - tls: - {{ if eq $.Values.grpc.tls.mode "passthrough" }} - termination: passthrough - {{ end }} - {{ if eq $.Values.grpc.tls.mode "reencrypt" }} - termination: reencrypt - {{ end }} - insecureEdgeTerminationPolicy: None - {{ if $.Values.grpc.tls.routerCertSecret }} - externalCertificate: - name: {{ $.Values.grpc.tls.routerCertSecret }} - {{ end }} - - to: - kind: Service - name: jumpstarter-router-grpc-{{ $k }} - weight: 100 - wildcardPolicy: None -{{ end }} -{{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-service.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-service.yaml deleted file mode 100644 index 0b0de609b..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/additional-router-service.yaml +++ /dev/null @@ -1,27 +0,0 @@ -{{ range $k, $v := .Values.grpc.additionalRouters }} ---- -apiVersion: v1 -kind: Service -metadata: - labels: - control-plane: controller-router-{{ $k }} - app.kubernetes.io/name: jumpstarter-controller - name: jumpstarter-router-grpc-{{ $k }} - namespace: {{ default $.Release.Namespace $.Values.namespace }} -spec: - {{ if .Values.grpc.nodeport.enabled }} - type: NodePort - {{ end }} - - ports: - - name: grpc - port: 8083 - protocol: TCP - targetPort: 8083 - appProtocol: h2c # HTTP/2 over cleartext for gRPC (fixes edge termination in ingress/router) - {{ if .Values.grpc.nodeport.enabled }} - nodePort: {{ .Values.grpc.nodeport.routerPort }} - {{ end }} - selector: - control-plane: controller-router-{{ $k }} -{{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/cms/controller-cm.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/cms/controller-cm.yaml deleted file mode 100644 index 7f75608d9..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/cms/controller-cm.yaml +++ /dev/null @@ -1,42 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: jumpstarter-controller - namespace: {{ default .Release.Namespace .Values.namespace }} - labels: - control-plane: controller-manager - app.kubernetes.io/name: jumpstarter-controller - {{ if .Values.global.timestamp }} - deployment.timestamp: {{ .Values.global.timestamp | quote }} - {{ end }} -data: - # backwards compatibility - # TODO: remove in 0.7.0 - {{ if .Values.authenticationConfig }} - authentication: {{- .Values.authenticationConfig | toYaml | indent 1 }} - {{ end }} - config: | -{{ .Values.config | toYaml | indent 4 }} - router: | - default: - {{ if .Values.grpc.routerEndpoint }} - endpoint: {{ .Values.grpc.routerEndpoint }} - {{ else if .Values.routerHostname }} - endpoint: {{ .Values.routerHostname }}:{{ .Values.grpc.tls.port }} - {{ else }} - endpoint: router.{{ .Values.global.baseDomain }}:{{ .Values.grpc.tls.port }} - {{ end }} - {{ range $k, $v := .Values.grpc.additionalRouters }} - {{ $k }}: - {{ if $v.endpoint }} - endpoint: {{ $v.endpoint }} - {{ else if $v.hostname }} - endpoint: {{ $v.hostname }}:{{ $.Values.grpc.tls.port }} - {{ else }} - endpoint: router-{{ $k }}.{{ $.Values.global.baseDomain }}:{{ $.Values.grpc.tls.port }} - {{ end }} - {{ if $v.labels }} - labels: - {{ $v.labels | toYaml | indent 1 }} - {{ end }} - {{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-deployment.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-deployment.yaml deleted file mode 100644 index f3b96a56d..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-deployment.yaml +++ /dev/null @@ -1,144 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: jumpstarter-controller - namespace: {{ default .Release.Namespace .Values.namespace }} - labels: - control-plane: controller-manager - app.kubernetes.io/name: jumpstarter-controller - {{ if .Values.global.timestamp }} - deployment.timestamp: {{ .Values.global.timestamp | quote }} - {{ end }} - annotations: - argocd.argoproj.io/sync-wave: "1" -spec: - selector: - matchLabels: - control-plane: controller-manager - replicas: 1 - template: - metadata: - annotations: - kubectl.kubernetes.io/default-container: manager - configmap-sha256: {{ include (print $.Template.BasePath "/cms/controller-cm.yaml") . | sha256sum }} - labels: - control-plane: controller-manager - {{ if .Values.global.timestamp }} - deployment.timestamp: {{ .Values.global.timestamp | quote }} - {{ end }} - spec: - # TODO(user): Uncomment the following code to configure the nodeAffinity expression - # according to the platforms which are supported by your solution. - # It is considered best practice to support multiple architectures. You can - # build your manager image using the makefile target docker-buildx. - # affinity: - # nodeAffinity: - # requiredDuringSchedulingIgnoredDuringExecution: - # nodeSelectorTerms: - # - matchExpressions: - # - key: kubernetes.io/arch - # operator: In - # values: - # - amd64 - # - arm64 - # - ppc64le - # - s390x - # - key: kubernetes.io/os - # operator: In - # values: - # - linux - securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - containers: - - args: - - --leader-elect - - --health-probe-bind-address=:8081 - - -metrics-bind-address=:8080 - env: - - name: GRPC_ENDPOINT - {{ if .Values.grpc.endpoint }} - value : {{ .Values.grpc.endpoint }} - {{ else if .Values.hostname }} - value: {{ .Values.hostname }}:{{ .Values.grpc.tls.port }} - {{ else }} - value: grpc.{{ .Values.global.baseDomain }}:{{ .Values.grpc.tls.port }} - {{ end }} - - name: LOGIN_ENDPOINT - {{ if .Values.login.endpoint }} - value: {{ .Values.login.endpoint }} - {{ else }} - value: login.{{ .Values.global.baseDomain }} - {{ end }} - - name: CONTROLLER_KEY - valueFrom: - secretKeyRef: - name: jumpstarter-controller-secret - key: key - - name: ROUTER_KEY - valueFrom: - secretKeyRef: - name: jumpstarter-router-secret - key: key - - name: NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - {{- if .Values.grpc.tls.controllerCertSecret }} - - name: EXTERNAL_CERT_PEM - value: /secrets/tls.crt - - name: EXTERNAL_KEY_PEM - value: /secrets/tls.key - {{- end }} - - name: CA_BUNDLE_PEM - valueFrom: - configMapKeyRef: - name: jumpstarter-service-ca-cert - key: ca.crt - # When cert-manager is enabled, require the configmap (fail fast on missing cert) - # When disabled, make it optional (users may not have TLS configured) - optional: {{ not .Values.certManager.enabled }} - - name: GIN_MODE - value: release - image: {{ .Values.image }}:{{ default .Chart.AppVersion .Values.tag }} - imagePullPolicy: {{ .Values.imagePullPolicy }} - name: manager - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - "ALL" - livenessProbe: - httpGet: - path: /healthz - port: 8081 - initialDelaySeconds: 15 - periodSeconds: 20 - readinessProbe: - httpGet: - path: /readyz - port: 8081 - initialDelaySeconds: 5 - periodSeconds: 10 - resources: - limits: - cpu: 2000m - memory: 1024Mi - requests: - cpu: 1000m - memory: 256Mi - {{- if .Values.grpc.tls.controllerCertSecret }} - volumeMounts: - - name: external-cert - mountPath: /secrets - readOnly: true - {{- end }} - {{- if .Values.grpc.tls.controllerCertSecret }} - volumes: - - name: external-cert - secret: - secretName: {{ .Values.grpc.tls.controllerCertSecret }} - {{- end }} - serviceAccountName: controller-manager - terminationGracePeriodSeconds: 10 diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-ingress.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-ingress.yaml deleted file mode 100644 index aba515c4b..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-ingress.yaml +++ /dev/null @@ -1,44 +0,0 @@ -{{ if eq .Values.grpc.mode "ingress" }} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - nginx.ingress.kubernetes.io/ssl-redirect: "true" - nginx.ingress.kubernetes.io/backend-protocol: "GRPC" - nginx.ingress.kubernetes.io/proxy-read-timeout: "300" - nginx.ingress.kubernetes.io/proxy-send-timeout: "300" - {{ if eq .Values.grpc.tls.mode "passthrough" }} - nginx.ingress.kubernetes.io/ssl-passthrough: "true" - {{ end }} - name: jumpstarter-controller-ingress - namespace: {{ default .Release.Namespace .Values.namespace }} -spec: - {{ if .Values.grpc.ingress.class }} - ingressClassName: {{ .Values.grpc.ingress.class }} - {{ end }} - rules: - {{ if .Values.grpc.hostname }} - - host: {{ .Values.grpc.hostname }} - {{ else }} - - host: grpc.{{ .Values.global.baseDomain | required "a global.baseDomain or a grpc.hostname must be provided"}} - {{ end }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: jumpstarter-grpc - port: - number: 8082 - tls: - - hosts: - {{ if .Values.grpc.hostname }} - - {{ .Values.grpc.hostname }} - {{ else }} - - grpc.{{ .Values.global.baseDomain | required "a global.baseDomain or a grpc.hostname must be provided"}} - {{ end }} - {{ if .Values.grpc.tls.controllerCertSecret }} - secretName: {{ .Values.grpc.tls.controllerCertSecret }} - {{ end }} -{{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-route.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-route.yaml deleted file mode 100644 index f7a781f0d..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-route.yaml +++ /dev/null @@ -1,34 +0,0 @@ -{{ if eq .Values.grpc.mode "route" }} -apiVersion: route.openshift.io/v1 -kind: Route -metadata: - labels: - external-exposed: "true" - shard: external - annotations: - haproxy.router.openshift.io/timeout: 2d - haproxy.router.openshift.io/timeout-tunnel: 2d - name: jumpstarter-controller-route - namespace: {{ default .Release.Namespace .Values.namespace }} -spec: - {{ if .Values.grpc.hostname }} - host: {{ .Values.grpc.hostname }} - {{ else }} - host: grpc.{{ .Values.global.baseDomain | required "a global.baseDomain or a grpc.hostname must be provided"}} - {{ end }} - port: - targetPort: 8082 - tls: - termination: {{ .Values.grpc.tls.mode }} - insecureEdgeTerminationPolicy: None - {{ if .Values.grpc.tls.controllerCertSecret }} - externalCertificate: - name: {{ .Values.grpc.tls.controllerCertSecret }} - {{ end }} - - to: - kind: Service - name: jumpstarter-grpc - weight: 100 - wildcardPolicy: None -{{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-service.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-service.yaml deleted file mode 100644 index c75803241..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/controller-service.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: jumpstarter-controller - name: jumpstarter-grpc - namespace: {{ default .Release.Namespace .Values.namespace }} -spec: - {{ if .Values.grpc.nodeport.enabled }} - type: NodePort - {{ end }} - - ports: - - name: grpc - port: 8082 - protocol: TCP - targetPort: 8082 - appProtocol: h2c # HTTP/2 over cleartext for gRPC (fixes edge termination in ingress/router) - {{ if .Values.grpc.nodeport.enabled }} - nodePort: {{ .Values.grpc.nodeport.port }} - {{ end }} - selector: - control-plane: controller-manager diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_clients.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_clients.yaml deleted file mode 100644 index d9dd6d0cb..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_clients.yaml +++ /dev/null @@ -1,69 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.16.3 - name: clients.jumpstarter.dev -spec: - group: jumpstarter.dev - names: - kind: Client - listKind: ClientList - plural: clients - singular: client - scope: Namespaced - versions: - - name: v1alpha1 - schema: - openAPIV3Schema: - description: Client is the Schema for the identities API - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: ClientSpec defines the desired state of Identity - properties: - username: - type: string - type: object - status: - description: ClientStatus defines the observed state of Identity - properties: - credential: - description: Status field for the clients - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - endpoint: - type: string - type: object - type: object - served: true - storage: true - subresources: - status: {} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporteraccesspolicies.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporteraccesspolicies.yaml deleted file mode 100644 index ec1b7878c..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporteraccesspolicies.yaml +++ /dev/null @@ -1,166 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.16.3 - name: exporteraccesspolicies.jumpstarter.dev -spec: - group: jumpstarter.dev - names: - kind: ExporterAccessPolicy - listKind: ExporterAccessPolicyList - plural: exporteraccesspolicies - singular: exporteraccesspolicy - scope: Namespaced - versions: - - name: v1alpha1 - schema: - openAPIV3Schema: - description: ExporterAccessPolicy is the Schema for the exporteraccesspolicies - API. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: ExporterAccessPolicySpec defines the desired state of ExporterAccessPolicy. - properties: - exporterSelector: - description: |- - A label selector is a label query over a set of resources. The result of matchLabels and - matchExpressions are ANDed. An empty label selector matches all objects. A null - label selector matches no objects. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. - The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies - to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - policies: - items: - properties: - from: - items: - properties: - clientSelector: - description: |- - A label selector is a label query over a set of resources. The result of matchLabels and - matchExpressions are ANDed. An empty label selector matches all objects. A null - label selector matches no objects. - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - type: object - type: array - maximumDuration: - type: string - priority: - type: integer - spotAccess: - type: boolean - type: object - type: array - type: object - status: - description: ExporterAccessPolicyStatus defines the observed state of - ExporterAccessPolicy. - type: object - type: object - served: true - storage: true - subresources: - status: {} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporters.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporters.yaml deleted file mode 100644 index 9e4d57bb8..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporters.yaml +++ /dev/null @@ -1,185 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.16.3 - name: exporters.jumpstarter.dev -spec: - group: jumpstarter.dev - names: - kind: Exporter - listKind: ExporterList - plural: exporters - singular: exporter - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .status.exporterStatus - name: Status - type: string - - jsonPath: .status.statusMessage - name: Message - priority: 1 - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - description: Exporter is the Schema for the exporters API - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: ExporterSpec defines the desired state of Exporter - properties: - username: - type: string - type: object - status: - description: ExporterStatus defines the observed state of Exporter - properties: - conditions: - description: Exporter status fields - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - credential: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - devices: - items: - properties: - labels: - additionalProperties: - type: string - type: object - parent_uuid: - type: string - uuid: - type: string - type: object - type: array - endpoint: - type: string - exporterStatus: - description: ExporterStatusValue is the current operational status - reported by the exporter - enum: - - Unspecified - - Offline - - Available - - BeforeLeaseHook - - LeaseReady - - AfterLeaseHook - - BeforeLeaseHookFailed - - AfterLeaseHookFailed - type: string - lastSeen: - format: date-time - type: string - leaseRef: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - statusMessage: - description: StatusMessage is an optional human-readable message describing - the current state - type: string - type: object - type: object - served: true - storage: true - subresources: - status: {} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_leases.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_leases.yaml deleted file mode 100644 index 2df4db845..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_leases.yaml +++ /dev/null @@ -1,256 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.16.3 - name: leases.jumpstarter.dev -spec: - group: jumpstarter.dev - names: - kind: Lease - listKind: LeaseList - plural: leases - singular: lease - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .status.ended - name: Ended - type: boolean - - jsonPath: .spec.clientRef.name - name: Client - type: string - - jsonPath: .status.exporterRef.name - name: Exporter - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - description: Lease is the Schema for the exporters API - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: LeaseSpec defines the desired state of Lease - properties: - beginTime: - description: |- - Requested start time. If omitted, lease starts when exporter is acquired. - Immutable after lease starts (cannot change the past). - format: date-time - type: string - clientRef: - description: The client that is requesting the lease - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - duration: - description: |- - Duration of the lease. Must be positive when provided. - Can be omitted (nil) when both BeginTime and EndTime are provided, - in which case it's calculated as EndTime - BeginTime. - type: string - endTime: - description: |- - Requested end time. If specified with BeginTime, Duration is calculated. - Can be updated to extend or shorten active leases. - format: date-time - type: string - exporterRef: - description: Optionally pin this lease to a specific exporter name. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - release: - description: The release flag requests the controller to end the lease - now - type: boolean - selector: - default: {} - description: The selector for the exporter to be used - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. - The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies - to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - required: - - clientRef - - selector - type: object - x-kubernetes-validations: - - message: one of selector or exporterRef.name is required - rule: ((has(self.selector.matchLabels) && size(self.selector.matchLabels) - > 0) || (has(self.selector.matchExpressions) && size(self.selector.matchExpressions) - > 0)) || (has(self.exporterRef) && has(self.exporterRef.name) && size(self.exporterRef.name) - > 0) - status: - description: LeaseStatus defines the observed state of Lease - properties: - beginTime: - description: |- - If the lease has been acquired an exporter name is assigned - and then it can be used, it will be empty while still pending - format: date-time - type: string - conditions: - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - endTime: - format: date-time - type: string - ended: - type: boolean - exporterRef: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - priority: - type: integer - spotAccess: - type: boolean - required: - - ended - type: object - type: object - served: true - storage: true - subresources: - status: {} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-ingress.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-ingress.yaml deleted file mode 100644 index cd0382c3d..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-ingress.yaml +++ /dev/null @@ -1,36 +0,0 @@ -{{ if and .Values.login.enabled .Values.login.ingress.enabled }} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - labels: - component: login - {{- if .Values.login.ingress.labels }} - {{- toYaml .Values.login.ingress.labels | nindent 4 }} - {{- end }} - {{- if .Values.login.ingress.annotations }} - annotations: - {{- toYaml .Values.login.ingress.annotations | nindent 4 }} - {{- end }} - name: jumpstarter-login-ing - namespace: {{ default .Release.Namespace .Values.namespace }} -spec: - {{- if .Values.login.ingress.class }} - ingressClassName: {{ .Values.login.ingress.class }} - {{- end }} - rules: - - host: {{ .Values.login.hostname | default (printf "login.%s" .Values.global.baseDomain) | required "a global.baseDomain or a login.hostname must be provided" }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: jumpstarter-login - port: - number: 8086 - tls: - - hosts: - - {{ .Values.login.hostname | default (printf "login.%s" .Values.global.baseDomain) }} - {{- /* Use login.tls.secretName (new), fallback to login.ingress.tls.secretName (legacy), then default */}} - secretName: {{ .Values.login.tls.secretName | default .Values.login.ingress.tls.secretName | default "jumpstarter-login-tls" }} -{{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-route.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-route.yaml deleted file mode 100644 index beab12d87..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-route.yaml +++ /dev/null @@ -1,38 +0,0 @@ -{{ if and .Values.login.enabled .Values.login.route.enabled }} -apiVersion: route.openshift.io/v1 -kind: Route -metadata: - labels: - component: login - {{- if .Values.login.route.labels }} - {{- toYaml .Values.login.route.labels | nindent 4 }} - {{- end }} - {{- if .Values.login.route.annotations }} - annotations: - {{- toYaml .Values.login.route.annotations | nindent 4 }} - {{- end }} - name: jumpstarter-login-route - namespace: {{ default .Release.Namespace .Values.namespace }} -spec: - {{ if .Values.login.hostname }} - host: {{ .Values.login.hostname }} - {{ else }} - host: login.{{ .Values.global.baseDomain | required "a global.baseDomain or a login.hostname must be provided"}} - {{ end }} - port: - targetPort: 8086 - tls: - # Edge termination - TLS terminated at the router, plain HTTP to backend - termination: edge - insecureEdgeTerminationPolicy: Redirect - {{- if .Values.login.tls.secretName }} - # Reference TLS certificate from secret (OpenShift 4.14+) - externalCertificate: - name: {{ .Values.login.tls.secretName }} - {{- end }} - to: - kind: Service - name: jumpstarter-login - weight: 100 - wildcardPolicy: None -{{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-service.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-service.yaml deleted file mode 100644 index 5e7dc71f1..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/login-service.yaml +++ /dev/null @@ -1,26 +0,0 @@ -{{ if .Values.login.enabled }} -apiVersion: v1 -kind: Service -metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: jumpstarter-controller - component: login - name: jumpstarter-login - namespace: {{ default .Release.Namespace .Values.namespace }} -spec: - {{ if .Values.login.nodeport.enabled }} - type: NodePort - {{ end }} - - ports: - - name: login - port: 8086 - protocol: TCP - targetPort: 8086 - {{ if .Values.login.nodeport.enabled }} - nodePort: {{ .Values.login.nodeport.port }} - {{ end }} - selector: - control-plane: controller-manager -{{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/metrics/metrics_service.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/metrics/metrics_service.yaml deleted file mode 100644 index b16a19ffc..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/metrics/metrics_service.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: jumpstarter-controller - name: controller-manager-metrics-service - namespace: {{ default .Release.Namespace .Values.namespace }} -spec: - ports: - - name: http - port: 8080 - protocol: TCP - targetPort: 8080 - selector: - control-plane: controller-manager diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/metrics/monitor.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/metrics/monitor.yaml deleted file mode 100644 index cb8098a7c..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/metrics/monitor.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# enable monitoring only if monitoring is enabled -{{- if .Values.global.metrics.enabled }} -# Prometheus Monitor Service (Metrics) -apiVersion: monitoring.coreos.com/v1 -kind: ServiceMonitor -metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: jumpstarter-controller - name: controller-manager-metrics-monitor - namespace: {{ default .Release.Namespace .Values.namespace }} -spec: - endpoints: - - path: /metrics - port: http # Ensure this is the name of the port that exposes HTTP metrics - scheme: http - selector: - matchLabels: - control-plane: controller-manager -{{- end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/client_editor_role.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/client_editor_role.yaml deleted file mode 100644 index 94773c031..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/client_editor_role.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# permissions for end users to edit identities. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: jumpstarter-router - name: client-editor-role -rules: -- apiGroups: - - jumpstarter.dev - resources: - - clients - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - jumpstarter.dev - resources: - - identities/status - verbs: - - get diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/client_viewer_role.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/client_viewer_role.yaml deleted file mode 100644 index 039ea624d..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/client_viewer_role.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# permissions for end users to view identities. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: jumpstarter-router - name: client-viewer-role -rules: -- apiGroups: - - jumpstarter.dev - resources: - - clients - verbs: - - get - - list - - watch -- apiGroups: - - jumpstarter.dev - resources: - - identities/status - verbs: - - get diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/exporter_editor_role.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/exporter_editor_role.yaml deleted file mode 100644 index c52b8c246..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/exporter_editor_role.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# permissions for end users to edit exporters. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: jumpstarter-router - name: exporter-editor-role -rules: -- apiGroups: - - jumpstarter.dev - resources: - - exporters - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - jumpstarter.dev - resources: - - exporters/status - verbs: - - get diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/exporter_viewer_role.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/exporter_viewer_role.yaml deleted file mode 100644 index 5ba5614c5..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/exporter_viewer_role.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# permissions for end users to view exporters. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: jumpstarter-router - name: exporter-viewer-role -rules: -- apiGroups: - - jumpstarter.dev - resources: - - exporters - verbs: - - get - - list - - watch -- apiGroups: - - jumpstarter.dev - resources: - - exporters/status - verbs: - - get diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/leader_election_role.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/leader_election_role.yaml deleted file mode 100644 index b0390bd15..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/leader_election_role.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# permissions to do leader election. -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - labels: - app.kubernetes.io/name: jumpstarter-router - name: leader-election-role - namespace: {{ default .Release.Namespace .Values.namespace }} -rules: -- apiGroups: - - "" - resources: - - configmaps - verbs: - - get - - list - - watch - - create - - update - - patch - - delete -- apiGroups: - - coordination.k8s.io - resources: - - leases - verbs: - - get - - list - - watch - - create - - update - - patch - - delete -- apiGroups: - - "" - resources: - - events - verbs: - - create - - patch diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/leader_election_role_binding.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/leader_election_role_binding.yaml deleted file mode 100644 index d60dc3c9f..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/leader_election_role_binding.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - labels: - app.kubernetes.io/name: jumpstarter-router - namespace: {{ default .Release.Namespace .Values.namespace }} - name: leader-election-rolebinding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: leader-election-role -subjects: -- kind: ServiceAccount - name: controller-manager - namespace: {{ default .Release.Namespace .Values.namespace }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role.yaml deleted file mode 100644 index 659678fe9..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role.yaml +++ /dev/null @@ -1,67 +0,0 @@ ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: jumpstarter-manager-role - annotations: - argocd.argoproj.io/sync-wave: "-1" -rules: -- apiGroups: - - "" - resources: - - events - verbs: - - create - - patch -- apiGroups: - - "" - resources: - - secrets - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - jumpstarter.dev - resources: - - clients - - exporters - - leases - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - jumpstarter.dev - resources: - - clients/finalizers - - exporters/finalizers - - leases/finalizers - verbs: - - update -- apiGroups: - - jumpstarter.dev - resources: - - clients/status - - exporters/status - - leases/status - verbs: - - get - - patch - - update -- apiGroups: - - jumpstarter.dev - resources: - - exporteraccesspolicies - verbs: - - get - - list - - watch diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role_binding.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role_binding.yaml deleted file mode 100644 index 71d864b05..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/role_binding.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - labels: - app.kubernetes.io/name: jumpstarter-router - annotations: - argocd.argoproj.io/sync-wave: "-1" - name: jumpstarter-manager-rolebinding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: jumpstarter-manager-role -subjects: -- kind: ServiceAccount - name: controller-manager - namespace: {{ default .Release.Namespace .Values.namespace }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/service_account.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/service_account.yaml deleted file mode 100644 index 5359726af..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/rbac/service_account.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - labels: - app.kubernetes.io/name: jumpstarter-router - annotations: - argocd.argoproj.io/sync-wave: "-1" - name: controller-manager - namespace: {{ default .Release.Namespace .Values.namespace }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-deployment.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-deployment.yaml deleted file mode 100644 index fa9978bf3..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-deployment.yaml +++ /dev/null @@ -1,109 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: jumpstarter-router - namespace: {{ default .Release.Namespace .Values.namespace }} - labels: - control-plane: controller-router - app.kubernetes.io/name: jumpstarter-controller - {{ if .Values.global.timestamp }} - deployment.timestamp: {{ .Values.global.timestamp | quote }} - {{ end }} - annotations: - argocd.argoproj.io/sync-wave: "1" -spec: - selector: - matchLabels: - control-plane: controller-router - replicas: 1 - template: - metadata: - annotations: - kubectl.kubernetes.io/default-container: router - configmap-sha256: {{ include (print $.Template.BasePath "/cms/controller-cm.yaml") . | sha256sum }} - labels: - control-plane: controller-router - {{ if .Values.global.timestamp }} - deployment.timestamp: {{ .Values.global.timestamp | quote }} - {{ end }} - spec: - # TODO(user): Uncomment the following code to configure the nodeAffinity expression - # according to the platforms which are supported by your solution. - # It is considered best practice to support multiple architectures. You can - # build your manager image using the makefile target docker-buildx. - # affinity: - # nodeAffinity: - # requiredDuringSchedulingIgnoredDuringExecution: - # nodeSelectorTerms: - # - matchExpressions: - # - key: kubernetes.io/arch - # operator: In - # values: - # - amd64 - # - arm64 - # - ppc64le - # - s390x - # - key: kubernetes.io/os - # operator: In - # values: - # - linux - securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - containers: - - command: - - /router - env: - - name: GRPC_ROUTER_ENDPOINT - {{ if .Values.grpc.routerEndpoint }} - value: {{ .Values.grpc.routerEndpoint }} - {{ else if .Values.routerHostname }} - value: {{ .Values.routerHostname }}:{{ .Values.grpc.tls.port }} - {{ else }} - value: router.{{ .Values.global.baseDomain }}:{{ .Values.grpc.tls.port }} - {{ end }} - - name: ROUTER_KEY - valueFrom: - secretKeyRef: - name: jumpstarter-router-secret - key: key - - name: NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - {{- if .Values.grpc.tls.routerCertSecret }} - - name: EXTERNAL_CERT_PEM - value: /secrets/tls.crt - - name: EXTERNAL_KEY_PEM - value: /secrets/tls.key - {{- end }} - image: {{ .Values.image }}:{{ default .Chart.AppVersion .Values.tag }} - imagePullPolicy: {{ .Values.imagePullPolicy }} - name: router - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - "ALL" - resources: - limits: - cpu: 2000m - memory: 1024Mi - requests: - cpu: 1000m - memory: 256Mi - {{- if .Values.grpc.tls.routerCertSecret }} - volumeMounts: - - name: external-cert - mountPath: /secrets - readOnly: true - {{- end }} - {{- if .Values.grpc.tls.routerCertSecret }} - volumes: - - name: external-cert - secret: - secretName: {{ .Values.grpc.tls.routerCertSecret }} - {{- end }} - serviceAccountName: controller-manager - terminationGracePeriodSeconds: 10 diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-ingress.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-ingress.yaml deleted file mode 100644 index 57e2235e5..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-ingress.yaml +++ /dev/null @@ -1,44 +0,0 @@ -{{ if eq .Values.grpc.mode "ingress" }} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - nginx.ingress.kubernetes.io/ssl-redirect: "true" - nginx.ingress.kubernetes.io/backend-protocol: "GRPC" - nginx.ingress.kubernetes.io/proxy-read-timeout: "300" - nginx.ingress.kubernetes.io/proxy-send-timeout: "300" - {{ if eq .Values.grpc.tls.mode "passthrough" }} - nginx.ingress.kubernetes.io/ssl-passthrough: "true" - {{ end }} - name: jumpstarter-router-ingress - namespace: {{ default .Release.Namespace .Values.namespace }} -spec: - {{ if .Values.grpc.ingress.class }} - ingressClassName: {{ .Values.grpc.ingress.class }} - {{ end }} - rules: - {{ if .Values.grpc.routerHostname }} - - host: {{ .Values.grpc.routerHostname }} - {{ else }} - - host: router.{{ .Values.global.baseDomain | required "a global.baseDomain or a grpc.routerHostname must be provided"}} - {{ end }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: jumpstarter-router-grpc - port: - number: 8083 - tls: - - hosts: - {{ if .Values.grpc.routerHostname }} - - {{ .Values.grpc.routerHostname }} - {{ else }} - - router.{{ .Values.global.baseDomain | required "a global.baseDomain or a grpc.routerHostname must be provided" }} - {{ end }} - {{ if .Values.grpc.tls.routerCertSecret }} - secretName: {{ .Values.grpc.tls.routerCertSecret }} - {{ end }} -{{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-route.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-route.yaml deleted file mode 100644 index e0659fbe8..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-route.yaml +++ /dev/null @@ -1,39 +0,0 @@ -{{ if eq .Values.grpc.mode "route" }} -apiVersion: route.openshift.io/v1 -kind: Route -metadata: - labels: - external-exposed: "true" - shard: external - annotations: - haproxy.router.openshift.io/timeout: 2d - haproxy.router.openshift.io/timeout-tunnel: 2d - name: jumpstarter-router-route - namespace: {{ default .Release.Namespace .Values.namespace }} -spec: - {{ if .Values.grpc.routerHostname }} - host: {{ .Values.grpc.routerHostname }} - {{ else }} - host: router.{{ .Values.global.baseDomain | required "a global.baseDomain or a grpc.routerHostname must be provided"}} - {{ end }} - port: - targetPort: 8083 - tls: - {{ if eq .Values.grpc.tls.mode "passthrough" }} - termination: passthrough - {{ end }} - {{ if eq .Values.grpc.tls.mode "reencrypt" }} - termination: reencrypt - {{ end }} - insecureEdgeTerminationPolicy: None - {{ if .Values.grpc.tls.routerCertSecret }} - externalCertificate: - name: {{ .Values.grpc.tls.routerCertSecret }} - {{ end }} - - to: - kind: Service - name: jumpstarter-router-grpc - weight: 100 - wildcardPolicy: None -{{ end }} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-service.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-service.yaml deleted file mode 100644 index ad4a2b1b3..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/router-service.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: jumpstarter-controller - name: jumpstarter-router-grpc - namespace: {{ default .Release.Namespace .Values.namespace }} -spec: - {{ if .Values.grpc.nodeport.enabled }} - type: NodePort - {{ end }} - - ports: - - name: grpc - port: 8083 - protocol: TCP - targetPort: 8083 - appProtocol: h2c # HTTP/2 over cleartext for gRPC (fixes edge termination in ingress/router) - {{ if .Values.grpc.nodeport.enabled }} - nodePort: {{ .Values.grpc.nodeport.routerPort }} - {{ end }} - selector: - control-plane: controller-router diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/secrets-job.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/secrets-job.yaml deleted file mode 100644 index 309d16fc9..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/secrets-job.yaml +++ /dev/null @@ -1,36 +0,0 @@ -{{- $namespace := default .Release.Namespace .Values.namespace }} -apiVersion: batch/v1 -kind: Job -metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: jumpstarter-controller - annotations: - # https://argo-cd.readthedocs.io/en/stable/user-guide/resource_hooks/#hook-deletion-policies - argocd.argoproj.io/hook: Sync - argocd.argoproj.io/hook-delete-policy: HookSucceeded - argocd.argoproj.io/sync-wave: "-1" - name: jumpstarter-secrets - namespace: {{ $namespace }} -spec: - ttlSecondsAfterFinished: 30 - template: - metadata: - name: jumpstarter-secrets - spec: - serviceAccountName: controller-manager - containers: - - name: jumpstarter-secrets - image: quay.io/jumpstarter-dev/jumpstarter-utils:latest - command: - - /bin/sh - - -c - - | - set -e - {{- range $name := tuple "jumpstarter-router-secret" "jumpstarter-controller-secret" }} - if ! kubectl get secret {{ $name }} -n {{ $namespace }} >/dev/null 2>&1; then - kubectl create secret generic {{ $name }} -n={{ $namespace }} \ - --from-literal=key="$(openssl rand -hex 32)" - fi - {{- end }} - restartPolicy: Never diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/values.schema.json b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/values.schema.json deleted file mode 100644 index 021e046dc..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/values.schema.json +++ /dev/null @@ -1,1458 +0,0 @@ -{ - "$defs": { - "AudienceMatchPolicy": { - "enum": [ - "MatchAny" - ], - "title": "AudienceMatchPolicy", - "type": "string" - }, - "Authentication": { - "additionalProperties": false, - "properties": { - "internal": { - "anyOf": [ - { - "$ref": "#/$defs/Internal" - }, - { - "type": "null" - } - ], - "default": null - }, - "jwt": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/JWTAuthenticator" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "External OIDC authentication, see https://kubernetes.io/docs/reference/access-authn-authz/authentication/#using-authentication-configuration for documentation", - "title": "Jwt" - } - }, - "title": "Authentication", - "type": "object" - }, - "CertManager": { - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Enable cert-manager integration. When enabled, jumpstarter-service-ca-cert configmap is required.", - "title": "Enabled" - } - }, - "title": "CertManager", - "type": "object" - }, - "ClaimMappings": { - "additionalProperties": false, - "properties": { - "username": { - "anyOf": [ - { - "$ref": "#/$defs/PrefixedClaimOrExpression" - }, - { - "type": "null" - } - ], - "default": null - }, - "groups": { - "anyOf": [ - { - "$ref": "#/$defs/PrefixedClaimOrExpression" - }, - { - "type": "null" - } - ], - "default": null - }, - "uid": { - "anyOf": [ - { - "$ref": "#/$defs/ClaimOrExpression" - }, - { - "type": "null" - } - ], - "default": null - }, - "extra": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/ExtraItem" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Extra" - } - }, - "title": "ClaimMappings", - "type": "object" - }, - "ClaimOrExpression": { - "anyOf": [ - { - "$ref": "#/$defs/ClaimOrExpression1" - }, - { - "$ref": "#/$defs/ClaimOrExpression2" - } - ], - "title": "ClaimOrExpression" - }, - "ClaimOrExpression1": { - "additionalProperties": false, - "properties": { - "claim": { - "title": "Claim", - "type": "string" - }, - "expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Expression" - } - }, - "required": [ - "claim" - ], - "title": "ClaimOrExpression1", - "type": "object" - }, - "ClaimOrExpression2": { - "additionalProperties": false, - "properties": { - "claim": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Claim" - }, - "expression": { - "title": "Expression", - "type": "string" - } - }, - "required": [ - "expression" - ], - "title": "ClaimOrExpression2", - "type": "object" - }, - "ClaimValidationRule": { - "additionalProperties": false, - "properties": { - "claim": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Claim" - }, - "requiredValue": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Requiredvalue" - }, - "expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Expression" - }, - "message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Message" - } - }, - "title": "ClaimValidationRule", - "type": "object" - }, - "ExtraItem": { - "additionalProperties": false, - "properties": { - "key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Key" - }, - "valueExpression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Valueexpression" - } - }, - "title": "ExtraItem", - "type": "object" - }, - "Global": { - "properties": { - "baseDomain": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base domain to construct the FQDN for the service endpoints", - "title": "Basedomain" - }, - "metrics": { - "anyOf": [ - { - "$ref": "#/$defs/Metrics" - }, - { - "type": "null" - } - ], - "default": null - } - }, - "title": "Global", - "type": "object" - }, - "Grpc": { - "additionalProperties": false, - "properties": { - "keepalive": { - "anyOf": [ - { - "$ref": "#/$defs/Keepalive" - }, - { - "type": "null" - } - ], - "default": null - } - }, - "title": "Grpc", - "type": "object" - }, - "Grpc1": { - "additionalProperties": false, - "properties": { - "hostname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Hostname for the controller to use for the controller gRPC", - "title": "Hostname" - }, - "routerHostname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Hostname for the router to use for the router gRPC", - "title": "Routerhostname" - }, - "endpoint": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The endpoints are passed down to the services to know where to announce the endpoints to the clients", - "title": "Endpoint" - }, - "routerEndpoint": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The endpoints are passed down to the services to know where to announce the endpoints to the clients", - "title": "Routerendpoint" - }, - "additionalRouters": { - "anyOf": [ - { - "additionalProperties": { - "$ref": "#/$defs/Router" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Additional routers to deploy", - "title": "Additionalrouters" - }, - "ingress": { - "anyOf": [ - { - "$ref": "#/$defs/Ingress" - }, - { - "type": "null" - } - ], - "default": null - }, - "route": { - "anyOf": [ - { - "$ref": "#/$defs/Route" - }, - { - "type": "null" - } - ], - "default": null - }, - "nodeport": { - "anyOf": [ - { - "$ref": "#/$defs/Nodeport" - }, - { - "type": "null" - } - ], - "default": null - }, - "mode": { - "anyOf": [ - { - "$ref": "#/$defs/Mode" - }, - { - "type": "null" - } - ], - "default": null - }, - "tls": { - "anyOf": [ - { - "$ref": "#/$defs/Tls" - }, - { - "type": "null" - } - ], - "default": null - } - }, - "title": "Grpc1", - "type": "object" - }, - "Ingress": { - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Whether to enable Ingress for the gRPC endpoint", - "title": "Enabled" - }, - "class": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "IngressClass to use for the gRPC endpoint", - "title": "Class" - } - }, - "title": "Ingress", - "type": "object" - }, - "Internal": { - "additionalProperties": false, - "properties": { - "prefix": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Prefix" - } - }, - "title": "Internal", - "type": "object" - }, - "Issuer": { - "additionalProperties": false, - "properties": { - "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Url" - }, - "discoveryURL": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Discoveryurl" - }, - "certificateAuthority": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Certificateauthority" - }, - "audiences": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Audiences" - }, - "audienceMatchPolicy": { - "anyOf": [ - { - "$ref": "#/$defs/AudienceMatchPolicy" - }, - { - "type": "null" - } - ], - "default": null - } - }, - "title": "Issuer", - "type": "object" - }, - "JWTAuthenticator": { - "additionalProperties": false, - "properties": { - "issuer": { - "anyOf": [ - { - "$ref": "#/$defs/Issuer" - }, - { - "type": "null" - } - ], - "default": null - }, - "claimValidationRules": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/ClaimValidationRule" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Claimvalidationrules" - }, - "claimMappings": { - "anyOf": [ - { - "$ref": "#/$defs/ClaimMappings" - }, - { - "type": "null" - } - ], - "default": null - }, - "userValidationRules": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/UserValidationRule" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Uservalidationrules" - } - }, - "title": "JWTAuthenticator", - "type": "object" - }, - "JumpstarterConfig": { - "additionalProperties": false, - "properties": { - "provisioning": { - "anyOf": [ - { - "$ref": "#/$defs/Provisioning" - }, - { - "type": "null" - } - ], - "default": null - }, - "authentication": { - "anyOf": [ - { - "$ref": "#/$defs/Authentication" - }, - { - "type": "null" - } - ], - "default": null - }, - "grpc": { - "anyOf": [ - { - "$ref": "#/$defs/Grpc" - }, - { - "type": "null" - } - ], - "default": null - } - }, - "title": "JumpstarterConfig", - "type": "object" - }, - "Keepalive": { - "additionalProperties": false, - "properties": { - "minTime": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The minimum amount of time a client should wait before sending a keepalive ping", - "title": "Mintime" - }, - "permitWithoutStream": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Whether to allow keepalive pings even when there are no active streams(RPCs)", - "title": "Permitwithoutstream" - } - }, - "title": "Keepalive", - "type": "object" - }, - "Login": { - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Whether to enable the login endpoint for simplified CLI login", - "title": "Enabled" - }, - "hostname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Hostname for the login endpoint", - "title": "Hostname" - }, - "endpoint": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The endpoint URL to display in the login landing page", - "title": "Endpoint" - }, - "tls": { - "anyOf": [ - { - "$ref": "#/$defs/LoginTls" - }, - { - "type": "null" - } - ], - "default": null, - "description": "TLS configuration for edge termination (used by both route and ingress)" - }, - "route": { - "anyOf": [ - { - "$ref": "#/$defs/LoginRoute" - }, - { - "type": "null" - } - ], - "default": null - }, - "ingress": { - "anyOf": [ - { - "$ref": "#/$defs/LoginIngress" - }, - { - "type": "null" - } - ], - "default": null - }, - "nodeport": { - "anyOf": [ - { - "$ref": "#/$defs/LoginNodeport" - }, - { - "type": "null" - } - ], - "default": null - } - }, - "title": "Login", - "type": "object" - }, - "LoginIngress": { - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Whether to enable Ingress for the login endpoint", - "title": "Enabled" - }, - "class": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "IngressClass to use for the login endpoint", - "title": "Class" - }, - "annotations": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Annotations for the login ingress", - "title": "Annotations" - }, - "labels": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Labels for the login ingress", - "title": "Labels" - }, - "tls": { - "anyOf": [ - { - "$ref": "#/$defs/LoginIngressTls" - }, - { - "type": "null" - } - ], - "default": null - } - }, - "title": "LoginIngress", - "type": "object" - }, - "LoginIngressTls": { - "additionalProperties": false, - "properties": { - "secretName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Secret name for the TLS certificate", - "title": "Secretname" - } - }, - "title": "LoginIngressTls", - "type": "object" - }, - "LoginNodeport": { - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Whether to enable NodePort for the login endpoint", - "title": "Enabled" - }, - "port": { - "anyOf": [ - { - "$ref": "#/$defs/Port" - }, - { - "type": "null" - } - ], - "default": null, - "description": "NodePort port number for the login service" - } - }, - "title": "LoginNodeport", - "type": "object" - }, - "LoginRoute": { - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Whether to enable OpenShift Route for the login endpoint", - "title": "Enabled" - }, - "annotations": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Annotations for the login route", - "title": "Annotations" - }, - "labels": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Labels for the login route", - "title": "Labels" - } - }, - "title": "LoginRoute", - "type": "object" - }, - "LoginTls": { - "additionalProperties": false, - "properties": { - "secretName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Name of the Kubernetes secret containing tls.crt and tls.key for edge TLS termination", - "title": "Secretname" - } - }, - "title": "LoginTls", - "type": "object" - }, - "Metrics": { - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Enabled" - } - }, - "title": "Metrics", - "type": "object" - }, - "Mode": { - "enum": [ - "ingress", - "route", - "nodeport", - "external" - ], - "title": "Mode", - "type": "string" - }, - "Mode1": { - "enum": [ - "passthrough", - "reencrypt" - ], - "title": "Mode1", - "type": "string" - }, - "Nodeport": { - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Enabled" - }, - "port": { - "anyOf": [ - { - "$ref": "#/$defs/Port" - }, - { - "type": "null" - } - ], - "default": null - }, - "routerPort": { - "anyOf": [ - { - "$ref": "#/$defs/Port" - }, - { - "type": "null" - } - ], - "default": null - } - }, - "title": "Nodeport", - "type": "object" - }, - "Port": { - "maximum": 65535, - "minimum": 0, - "title": "Port", - "type": "integer" - }, - "PrefixedClaimOrExpression": { - "anyOf": [ - { - "$ref": "#/$defs/PrefixedClaimOrExpression1" - }, - { - "$ref": "#/$defs/PrefixedClaimOrExpression2" - } - ], - "title": "PrefixedClaimOrExpression" - }, - "PrefixedClaimOrExpression1": { - "additionalProperties": false, - "properties": { - "claim": { - "title": "Claim", - "type": "string" - }, - "prefix": { - "title": "Prefix", - "type": "string" - } - }, - "required": [ - "claim", - "prefix" - ], - "title": "PrefixedClaimOrExpression1", - "type": "object" - }, - "PrefixedClaimOrExpression2": { - "additionalProperties": false, - "properties": { - "expression": { - "title": "Expression", - "type": "string" - } - }, - "required": [ - "expression" - ], - "title": "PrefixedClaimOrExpression2", - "type": "object" - }, - "Provisioning": { - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Enabled" - } - }, - "title": "Provisioning", - "type": "object" - }, - "Route": { - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Whether to enable OpenShift Router for the gRPC endpoint", - "title": "Enabled" - } - }, - "title": "Route", - "type": "object" - }, - "Router": { - "additionalProperties": false, - "properties": { - "hostname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Hostname" - }, - "endpoint": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Endpoint" - }, - "labels": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Labels" - }, - "nodeSelector": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Nodeselector" - } - }, - "title": "Router", - "type": "object" - }, - "Tls": { - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Enabled" - }, - "secret": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Secret" - }, - "controllerCertSecret": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Secret containing the TLS certificate/key for the gRPC controller endpoint", - "title": "Controllercertsecret" - }, - "routerCertSecret": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Secret containing the TLS certificate/key for the gRPC router endpoints", - "title": "Routercertsecret" - }, - "port": { - "anyOf": [ - { - "$ref": "#/$defs/Port" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Port to use for the gRPC endpoints Ingress or Route, this can be useful for ingress routers on non-standard ports" - }, - "mode": { - "anyOf": [ - { - "$ref": "#/$defs/Mode1" - }, - { - "type": "null" - } - ], - "default": null, - "description": "TLS mode for gRPC endpoints" - } - }, - "title": "Tls", - "type": "object" - }, - "UserValidationRule": { - "additionalProperties": false, - "properties": { - "expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Expression" - }, - "message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Message" - } - }, - "title": "UserValidationRule", - "type": "object" - } - }, - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Whether to enable jumpstarter controller", - "title": "Enabled" - }, - "authenticationConfig": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Authenticationconfig" - }, - "config": { - "anyOf": [ - { - "$ref": "#/$defs/JumpstarterConfig" - }, - { - "type": "null" - } - ], - "default": null - }, - "namespace": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Namespace where the controller will be deployed, defaults to global.namespace", - "title": "Namespace" - }, - "image": { - "description": "Image for the controller", - "title": "Image", - "type": "string" - }, - "tag": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Image tag for the controller", - "title": "Tag" - }, - "imagePullPolicy": { - "description": "Image pull policy for the controller", - "title": "Imagepullpolicy", - "type": "string" - }, - "global": { - "anyOf": [ - { - "$ref": "#/$defs/Global" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Global parameters" - }, - "certManager": { - "anyOf": [ - { - "$ref": "#/$defs/CertManager" - }, - { - "type": "null" - } - ], - "default": null, - "description": "cert-manager integration for automatic TLS certificate management" - }, - "grpc": { - "anyOf": [ - { - "$ref": "#/$defs/Grpc1" - }, - { - "type": "null" - } - ], - "default": null - }, - "login": { - "anyOf": [ - { - "$ref": "#/$defs/Login" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Login endpoint configuration for simplified CLI login" - } - }, - "required": [ - "image", - "imagePullPolicy" - ], - "title": "Model", - "type": "object" -} diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/values.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/values.yaml deleted file mode 100644 index 068d2b29b..000000000 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/values.yaml +++ /dev/null @@ -1,76 +0,0 @@ - -namespace: "" - -# cert-manager integration for automatic TLS certificate management -# When enabled, the jumpstarter-service-ca-cert configmap is expected to be -# created by cert-manager and is required (not optional). -# When disabled, the configmap is optional - users must pre-create it manually -# if they want TLS, otherwise TLS will be disabled. -certManager: - enabled: false - -grpc: - hostname: "" - routerHostname: "" - - # the endpoints are passed down to the service to know where - # to announce the endpoints to the clients - endpoint: "" - routerEndpoint: "" - - additionalRouters: {} - - tls: - enabled: false - secret: "" - - # enabling ingress route - ingress: - enabled: false - class: "" - - # enabling openshift route - route: - enabled: false - - # NodePort service for grpc, useful for local development - nodeport: - enabled: false - port: 30010 - routerPort: 30011 - -# Login endpoint configuration for simplified CLI login -# The login service runs on HTTP with TLS terminated at Route/Ingress level -login: - enabled: false - hostname: "" - # the endpoint is passed down to the service to know where to announce the login endpoint - endpoint: "" - # TLS configuration for edge termination (used by both route and ingress) - tls: - # Name of the Kubernetes secret containing tls.crt and tls.key - # If not specified, defaults to "jumpstarter-login-tls" for ingress - # For routes, uses externalCertificate to reference the secret (OpenShift 4.14+) - secretName: "" - # OpenShift Route configuration (uses edge TLS termination) - route: - enabled: false - annotations: {} - labels: {} - # Kubernetes Ingress configuration (uses edge TLS termination) - ingress: - enabled: false - class: "" - annotations: {} - labels: {} - tls: - # Deprecated: use login.tls.secretName instead (kept for backward compatibility) - secretName: "" - # NodePort service for login, useful for local development - nodeport: - enabled: false - port: 30014 - -image: quay.io/jumpstarter-dev/jumpstarter-controller -tag: "" -imagePullPolicy: IfNotPresent diff --git a/controller/deploy/helm/jumpstarter/model.py b/controller/deploy/helm/jumpstarter/model.py deleted file mode 100755 index 2a57634a3..000000000 --- a/controller/deploy/helm/jumpstarter/model.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = ["pydantic"] -# /// - -from __future__ import annotations - -import json - -from typing import Any, Dict, Optional, Union - -from pydantic import BaseModel, ConfigDict, Field - - -class Metrics(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled: Optional[bool] = Field( - None, description="Whether to enable metrics exporting and service" - ) - - -class Global(BaseModel): - model_config = ConfigDict(extra="forbid") - - namespace: Optional[str] = Field( - None, description="Namespace where the components will be deployed" - ) - timestamp: Optional[Union[int, str]] = Field( - None, - description="Timestamp to be used to trigger a new deployment, i.e. if you want pods to be restarted and pickup the latest tag", - ) - baseDomain: Optional[str] = Field( - None, description="Base domain to construct the FQDN for the service endpoints" - ) - storageClassName: Optional[str] = Field( - None, description="Storage class name for multiple reader/writer PVC" - ) - storageClassNameRWM: Optional[str] = Field( - None, description="Storage class name for the PVCs" - ) - metrics: Optional[Metrics] = None - - -class Login(BaseModel): - """Login endpoint configuration for simplified CLI login""" - enabled: Optional[bool] = None - hostname: Optional[str] = None - endpoint: Optional[str] = None - route: Optional[Dict[str, Any]] = None - ingress: Optional[Dict[str, Any]] = None - nodeport: Optional[Dict[str, Any]] = None - - -class Model(BaseModel): - model_config = ConfigDict(extra="forbid") - - jumpstarter_controller: Optional[Dict[str, Any]] = Field( - None, alias="jumpstarter-controller" - ) - global_: Optional[Global] = Field(None, alias="global") - login: Optional[Login] = Field( - None, description="Login endpoint configuration (passed to subchart)" - ) - - -print(json.dumps(Model.model_json_schema(), indent=2)) diff --git a/controller/deploy/helm/jumpstarter/values.kind.yaml b/controller/deploy/helm/jumpstarter/values.kind.yaml deleted file mode 100644 index d95f0283e..000000000 --- a/controller/deploy/helm/jumpstarter/values.kind.yaml +++ /dev/null @@ -1,10 +0,0 @@ -global: - baseDomain: jumpstarter.127.0.0.1.nip.io - metrics: - enabled: false - -jumpstarter-controller: - # image: quay.io/jumpstarter-dev/jumpstarter-controller - # tag: latest - grpc: - mode: "ingress" diff --git a/controller/deploy/helm/jumpstarter/values.schema.json b/controller/deploy/helm/jumpstarter/values.schema.json deleted file mode 100644 index f015467e6..000000000 --- a/controller/deploy/helm/jumpstarter/values.schema.json +++ /dev/null @@ -1,233 +0,0 @@ -{ - "$defs": { - "Global": { - "additionalProperties": false, - "properties": { - "namespace": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Namespace where the components will be deployed", - "title": "Namespace" - }, - "timestamp": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Timestamp to be used to trigger a new deployment, i.e. if you want pods to be restarted and pickup the latest tag", - "title": "Timestamp" - }, - "baseDomain": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base domain to construct the FQDN for the service endpoints", - "title": "Basedomain" - }, - "storageClassName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Storage class name for multiple reader/writer PVC", - "title": "Storageclassname" - }, - "storageClassNameRWM": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Storage class name for the PVCs", - "title": "Storageclassnamerwm" - }, - "metrics": { - "anyOf": [ - { - "$ref": "#/$defs/Metrics" - }, - { - "type": "null" - } - ], - "default": null - } - }, - "title": "Global", - "type": "object" - }, - "Login": { - "description": "Login endpoint configuration for simplified CLI login", - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Enabled" - }, - "hostname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Hostname" - }, - "endpoint": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Endpoint" - }, - "route": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Route" - }, - "ingress": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Ingress" - }, - "nodeport": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Nodeport" - } - }, - "title": "Login", - "type": "object" - }, - "Metrics": { - "additionalProperties": false, - "properties": { - "enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Whether to enable metrics exporting and service", - "title": "Enabled" - } - }, - "title": "Metrics", - "type": "object" - } - }, - "additionalProperties": false, - "properties": { - "jumpstarter-controller": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Jumpstarter-Controller" - }, - "global": { - "anyOf": [ - { - "$ref": "#/$defs/Global" - }, - { - "type": "null" - } - ], - "default": null - }, - "login": { - "anyOf": [ - { - "$ref": "#/$defs/Login" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Login endpoint configuration (passed to subchart)" - } - }, - "title": "Model", - "type": "object" -} diff --git a/controller/deploy/helm/jumpstarter/values.yaml b/controller/deploy/helm/jumpstarter/values.yaml deleted file mode 100644 index 98247a4e8..000000000 --- a/controller/deploy/helm/jumpstarter/values.yaml +++ /dev/null @@ -1,125 +0,0 @@ -## @section Global parameters -## @descriptionStart This section contains parameters common to all the -## components in the deployment. -## @descriptionEnd -## -## @param global.baseDomain Base domain to construct the FQDN for the service endpoints. -## @param global.namespace Namespace where the components will be deployed. -## @param global.storageClassName Storage class name for the PVCs. -## @param global.storageClassNameRWM Storage class name for multiple reader/writer PVCs. -## @param global.metrics.enabled Enable metrics exporting and service -## @param global.timestamp Timestamp to be used to trigger a new deployment, i.e. if you want pods to be restarted and pickup ":latest" - -global: - baseDomain: jumpstarter.my.domain.com - namespace: jumpstarter-lab - storageClassName: standard - storageClassNameRWM: standard - metrics: - enabled: false - timestamp: "" # can be used to timestamp deployments and make them reload - -## @section Jumpstarter Controller parameters -## @descriptionStart This section contains parameters for the Jumpstarter Controller. -## @descriptionEnd -## -## @param jumpstarter-controller.enabled Enable the Jumpstarter Controller. - -## @param jumpstarter-controller.image Image for the controller. -## @param jumpstarter-controller.tag Tag for the controller image. -## @param jumpstarter-controller.imagePullPolicy Image pull policy for the controller. - -## @param jumpstarter-controller.namespace Namespace where the controller will be deployed, defaults to global.namespace. - -## @param jumpstarter-controller.config.grpc.keepalive.minTime. The minimum amount of time a client should wait before sending a keepalive ping. -## @param jumpstarter-controller.config.grpc.keepalive.permitWithoutStream. Whether to allow keepalive pings even when there are no active streams(RPCs). - -## @param jumpstarter-controller.config.authentication.internal.prefix. Prefix to add to the subject claim of the tokens issued by the builtin authenticator. -## @param jumpstarter-controller.config.authentication.jwt. External OIDC authentication, see https://kubernetes.io/docs/reference/access-authn-authz/authentication/#using-authentication-configuration for documentation - -## @section Ingress And Route parameters -## @descriptionStart This section contains parameters for the Ingress and Route configurations. -## You can enable either the gRPC ingress or the OpenShift route but not both. -## @descriptionEnd -## -## @param jumpstarter-controller.grpc.hostname Hostname for the controller to use for the controller gRPC. -## @param jumpstarter-controller.grpc.routerHostname Hostname for the controller to use for the router gRPC. -## -## @param jumpstarter-controller.grpc.tls.mode Setup the TLS mode for endpoints, either "passthrough" or "reencrypt". -## @param jumpstarter-controller.grpc.tls.port Port to use for the gRPC endpoints ingress or route, this can be useful for ingress routers on non-standard ports. -## @param jumpstarter-controller.grpc.tls.controllerCertSecret Secret containing the TLS certificate/key for the gRPC endpoint. -## @param jumpstarter-controller.grpc.tls.routerCertSecret Secret containing the TLS certificate/key for the gRPC router endpoints. -## -## @param jumpstarter-controller.grpc.endpoint The endpoints are passed down to the services to -## know where to announce the endpoints to the clients. -## -## @param jumpstarter-controller.grpc.routerEndpoint The endpoints are passed down to the services to -## know where to announce the endpoints to the clients. -## -## @param jumpstarter-controller.grpc.ingress.enabled Enable the gRPC ingress configuration. -## -## @param jumpstarter-controller.grpc.mode Mode to use for the gRPC endpoints, either route or ingress. -## -## @param jumpstarter-controller.certManager.enabled Enable cert-manager integration for TLS certificates. -## When enabled, the jumpstarter-service-ca-cert configmap is required (deployment fails if missing). -## When disabled, the configmap is optional - pre-create it manually if TLS is needed. - - - -jumpstarter-controller: - enabled: true - - image: quay.io/jumpstarter-dev/jumpstarter-controller - tag: "" - imagePullPolicy: IfNotPresent - - namespace: "" - - # cert-manager integration for automatic TLS certificate management - # When enabled, the jumpstarter-service-ca-cert configmap is required - # When disabled (default), the configmap is optional - certManager: - enabled: false - - config: - grpc: - keepalive: - # Safety: potentially makes server vulnerable to DDoS - # https://grpc.io/docs/guides/keepalive/#how-configuring-keepalive-affects-a-call - minTime: 3s - permitWithoutStream: true - authentication: - internal: - prefix: "internal:" - # To trust service account tokens, first execute: - # kubectl create clusterrolebinding oidc-reviewer \ - # --clusterrole=system:service-account-issuer-discovery \ - # --group=system:unauthenticated - # Then uncomment: - # - # jwt: - # - issuer: - # url: https://kubernetes.default.svc.cluster.local - # audiences: - # - https://kubernetes.default.svc.cluster.local - # audienceMatchPolicy: MatchAny - # certificateAuthority: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt - # claimMappings: - # username: - # claim: "sub" - # prefix: "kubernetes:" - - grpc: - hostname: "" - routerHostname: "" - - endpoint: "" - routerEndpoint: "" - - tls: - mode: "passthrough" - port: 443 - routerCertSecret: "" - controllerCertSecret: "" - - mode: "route" # route, ingress or external(user created) diff --git a/controller/deploy/operator/README.md b/controller/deploy/operator/README.md index 5e9d8d49c..f03cb1359 100644 --- a/controller/deploy/operator/README.md +++ b/controller/deploy/operator/README.md @@ -92,24 +92,6 @@ the project, i.e.: kubectl apply -f https://raw.githubusercontent.com//jumpstarter-operator//dist/install.yaml ``` -### By providing a Helm Chart - -1. Build the chart using the optional helm plugin - -```sh -operator-sdk edit --plugins=helm/v1-alpha -``` - -2. See that a chart was generated under 'dist/chart', and users -can obtain this solution from there. - -**NOTE:** If you change the project, you need to update the Helm Chart -using the same command above to sync the latest changes. Furthermore, -if you create webhooks, you need to use the above command with -the '--force' flag and manually ensure that any custom configuration -previously added to 'dist/chart/values.yaml' or 'dist/chart/manager/manager.yaml' -is manually re-applied afterwards. - ## Contributing // TODO(user): Add detailed information on how you would like others to contribute to this project diff --git a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go index a9c9f240f..62cc92c1e 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go @@ -543,7 +543,7 @@ func (r *JumpstarterReconciler) reconcileSecrets(ctx context.Context, jumpstarte log := logf.FromContext(ctx) // Create controller secret if it doesn't exist - // Use fixed name to match Helm chart for migration compatibility + // Use fixed name for stable secret references across CR updates controllerSecretName := "jumpstarter-controller-secret" if err := r.ensureSecretExists(ctx, jumpstarter, controllerSecretName); err != nil { log.Error(err, "Failed to ensure controller secret exists", "secret", controllerSecretName) @@ -551,7 +551,7 @@ func (r *JumpstarterReconciler) reconcileSecrets(ctx context.Context, jumpstarte } // Create router secret if it doesn't exist - // Use fixed name to match Helm chart for migration compatibility + // Use fixed name for stable secret references across CR updates routerSecretName := "jumpstarter-router-secret" if err := r.ensureSecretExists(ctx, jumpstarter, routerSecretName); err != nil { log.Error(err, "Failed to ensure router secret exists", "secret", routerSecretName) diff --git a/controller/hack/deploy_with_helm.sh b/controller/hack/deploy_with_helm.sh deleted file mode 100755 index 9c598ec76..000000000 --- a/controller/hack/deploy_with_helm.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" - -# Source common utilities -source "${SCRIPT_DIR}/utils" - -# Source common deployment variables -source "${SCRIPT_DIR}/deploy_vars" - -METHOD=install - -kubectl config use-context kind-jumpstarter - -# Install nginx ingress if in ingress mode -if [ "${NETWORKING_MODE}" == "ingress" ]; then - install_nginx_ingress -else - echo -e "${GREEN}Deploying with nodeport ...${NC}" -fi - -# Build Helm sets based on configuration -HELM_SETS="" -HELM_SETS="${HELM_SETS} --set global.baseDomain=${BASEDOMAIN}" -HELM_SETS="${HELM_SETS} --set jumpstarter-controller.grpc.endpoint=${GRPC_ENDPOINT}" -HELM_SETS="${HELM_SETS} --set jumpstarter-controller.grpc.routerEndpoint=${GRPC_ROUTER_ENDPOINT}" -HELM_SETS="${HELM_SETS} --set jumpstarter-controller.image=${IMAGE_REPO}" -HELM_SETS="${HELM_SETS} --set jumpstarter-controller.tag=${IMAGE_TAG}" - -# Enable appropriate networking mode -if [ "${NETWORKING_MODE}" == "ingress" ]; then - HELM_SETS="${HELM_SETS} --set jumpstarter-controller.grpc.ingress.enabled=true" - # Login endpoint via ingress - HELM_SETS="${HELM_SETS} --set jumpstarter-controller.login.enabled=true" - HELM_SETS="${HELM_SETS} --set jumpstarter-controller.login.ingress.enabled=true" - HELM_SETS="${HELM_SETS} --set jumpstarter-controller.login.ingress.class=nginx" - HELM_SETS="${HELM_SETS} --set jumpstarter-controller.login.hostname=login.${BASEDOMAIN}" - HELM_SETS="${HELM_SETS} --set jumpstarter-controller.login.endpoint=${LOGIN_ENDPOINT}" -else - HELM_SETS="${HELM_SETS} --set jumpstarter-controller.grpc.nodeport.enabled=true" - # Login endpoint via nodeport (30014 -> 8086 on host) - HELM_SETS="${HELM_SETS} --set jumpstarter-controller.login.enabled=true" - HELM_SETS="${HELM_SETS} --set jumpstarter-controller.login.nodeport.enabled=true" - HELM_SETS="${HELM_SETS} --set jumpstarter-controller.login.nodeport.port=30014" - HELM_SETS="${HELM_SETS} --set jumpstarter-controller.login.endpoint=${LOGIN_ENDPOINT}" -fi - -echo -e "${GREEN}Loading the ${IMG} in kind ...${NC}" -# load the docker image into the kind cluster -kind_load_image ${IMG} - - -# if we have an existing deployment, try to upgrade it instead -if helm list -A | grep jumpstarter > /dev/null; then - METHOD=upgrade -fi - -echo -e "${GREEN}Performing helm ${METHOD} ...${NC}" - -# install/update with helm -helm ${METHOD} --namespace jumpstarter-lab \ - --create-namespace \ - ${HELM_SETS} \ - --set global.timestamp=$(date +%s) \ - --values ./deploy/helm/jumpstarter/values.kind.yaml ${EXTRA_VALUES} jumpstarter \ - ./deploy/helm/jumpstarter/ - -kubectl config set-context --current --namespace=jumpstarter-lab - -# Check gRPC endpoints are ready -check_grpc_endpoints - -# Print success banner -print_deployment_success "Helm" diff --git a/controller/hack/install_helm.sh b/controller/hack/install_helm.sh deleted file mode 100755 index c2ea718a0..000000000 --- a/controller/hack/install_helm.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -x -euo pipefail - -which helm 2>/dev/null 1>/dev/null -if [ $? -eq 0 ]; then - echo "Helm already installed" - exit 0 -fi - -# Get the remote shell script and make sure it's the one we expect, inside the script there is also -# verification of the downloaded binaries -curl -fsSL -o /tmp/get_helm.sh https://raw.githubusercontent.com/helm/helm/0d0f91d1ce277b2c8766cdc4c7aa04dbafbf2503/scripts/get-helm-3 -echo "6701e269a95eec0a5f67067f504f43ad94e9b4a52ec1205d26b3973d6f5cb3dc /tmp/get_helm.sh" | sha256sum --check || exit 1 -chmod a+x /tmp/get_helm.sh -/tmp/get_helm.sh - -rm /tmp/get_helm.sh diff --git a/controller/hack/utils b/controller/hack/utils index e9437873c..a0c55ff4e 100755 --- a/controller/hack/utils +++ b/controller/hack/utils @@ -252,7 +252,7 @@ check_grpc_endpoints() { # Print deployment success banner # Args: -# $1: deployment method (e.g., "Helm", "operator") - optional +# $1: deployment method (e.g., "operator") - optional print_deployment_success() { local method=${1:-""} local method_text="" diff --git a/controller/internal/controller/suite_test.go b/controller/internal/controller/suite_test.go index ce79acd67..972f9fc0c 100644 --- a/controller/internal/controller/suite_test.go +++ b/controller/internal/controller/suite_test.go @@ -63,7 +63,7 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "deploy", "helm", "jumpstarter", "charts", "jumpstarter-controller", "templates", "crds")}, + CRDDirectoryPaths: []string{filepath.Join("..", "..", "deploy", "operator", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, // The BinaryAssetsDirectory is only required if you want to run the tests directly diff --git a/controller/internal/service/suite_test.go b/controller/internal/service/suite_test.go index 607698e22..33117fd3d 100644 --- a/controller/internal/service/suite_test.go +++ b/controller/internal/service/suite_test.go @@ -54,7 +54,7 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "deploy", "helm", "jumpstarter", "charts", "jumpstarter-controller", "templates", "crds")}, + CRDDirectoryPaths: []string{filepath.Join("..", "..", "deploy", "operator", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, // The BinaryAssetsDirectory is only required if you want to run the tests directly diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index 43150f44d..953edd0b8 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -2,8 +2,8 @@ # Jumpstarter Compatibility E2E Testing Setup Script # This script sets up the environment for cross-version compatibility tests. # -# No OIDC/dex is needed — tests use legacy auth (--unsafe --save). -# Uses helm directly (no operator) for simplicity. +# No OIDC/dex is needed -- tests use legacy auth (--unsafe --save). +# Uses operator-based deployment. # # Environment variables: # COMPAT_SCENARIO - "old-controller" or "old-client" (required) @@ -24,8 +24,7 @@ REPO_ROOT="$(cd "$E2E_DIR/.." && pwd)" # Default namespace for tests export JS_NAMESPACE="${JS_NAMESPACE:-jumpstarter-lab}" -# Always use helm for compat tests (simpler, direct control) -export METHOD="helm" +export METHOD="operator" # Scenario configuration COMPAT_SCENARIO="${COMPAT_SCENARIO:-old-controller}" @@ -92,15 +91,11 @@ create_cluster() { log_info "Kind cluster created" } -# Deploy old controller using the OCI helm chart from quay.io deploy_old_controller() { log_info "Deploying old controller (version: $COMPAT_CONTROLLER_TAG)..." cd "$REPO_ROOT" - # Strip leading 'v' for helm version (v0.7.1 -> 0.7.1) - local HELM_VERSION="${COMPAT_CONTROLLER_TAG#v}" - # Compute networking variables local IP IP=$(get_external_ip) @@ -110,18 +105,10 @@ deploy_old_controller() { kubectl config use-context kind-jumpstarter - # Install old controller from OCI helm chart - log_info "Installing old controller via helm (version: ${HELM_VERSION})..." - helm install --namespace jumpstarter-lab \ - --create-namespace \ - --set global.baseDomain="${BASEDOMAIN}" \ - --set jumpstarter-controller.grpc.endpoint="${GRPC_ENDPOINT}" \ - --set jumpstarter-controller.grpc.routerEndpoint="${GRPC_ROUTER_ENDPOINT}" \ - --set jumpstarter-controller.grpc.nodeport.enabled=true \ - --set jumpstarter-controller.grpc.mode=nodeport \ - --set global.metrics.enabled=false \ - --version="${HELM_VERSION}" \ - jumpstarter oci://quay.io/jumpstarter-dev/helm/jumpstarter + # Install old controller using operator installer from the release tag + local INSTALLER_URL="https://raw.githubusercontent.com/jumpstarter-dev/jumpstarter/${COMPAT_CONTROLLER_TAG}/controller/deploy/operator/dist/install.yaml" + log_info "Installing old controller via operator (version: ${COMPAT_CONTROLLER_TAG})..." + kubectl apply -f "${INSTALLER_URL}" || log_warn "Operator installer may not be available for ${COMPAT_CONTROLLER_TAG}, skipping" kubectl config set-context --current --namespace=jumpstarter-lab @@ -144,20 +131,12 @@ deploy_old_controller() { log_info "Old controller deployed" } -# Deploy new controller from HEAD deploy_new_controller() { log_info "Deploying new controller from HEAD..." cd "$REPO_ROOT" - if [ -z "${SKIP_BUILD:-}" ]; then - make -C controller docker-build - else - log_info "Skipping controller image build (SKIP_BUILD is set)" - fi - cd controller - ./hack/deploy_with_helm.sh - cd "$REPO_ROOT" + make -C controller deploy log_info "New controller deployed" } @@ -199,9 +178,9 @@ setup_test_environment() { cd "$REPO_ROOT" - # Get the controller endpoint from helm values + # Get the controller endpoint from Jumpstarter CR export ENDPOINT - ENDPOINT=$(helm get values jumpstarter --output json | jq -r '."jumpstarter-controller".grpc.endpoint') + ENDPOINT="grpc.$(kubectl get jumpstarter -n "${JS_NAMESPACE}" jumpstarter -o jsonpath='{.spec.baseDomain}' 2>/dev/null || echo 'jumpstarter.127.0.0.1.nip.io'):8082" log_info "Controller endpoint: $ENDPOINT" diff --git a/e2e/setup-e2e.sh b/e2e/setup-e2e.sh index fc8bce67f..0579e56da 100755 --- a/e2e/setup-e2e.sh +++ b/e2e/setup-e2e.sh @@ -13,7 +13,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # Default namespace for tests export JS_NAMESPACE="${JS_NAMESPACE:-jumpstarter-lab}" -# Deployment method: operator (default) or helm +# Deployment method export METHOD="${METHOD:-operator}" # Color output @@ -266,26 +266,8 @@ deploy_controller() { cd "$REPO_ROOT" - # Validate METHOD - if [ "$METHOD" != "operator" ] && [ "$METHOD" != "helm" ]; then - log_error "Unknown deployment method: $METHOD (expected 'operator' or 'helm')" - exit 1 - fi - - # Use parallel make on Linux (GNU Make 4+); macOS ships an old make without --output-sync - local make_parallel="" - if [ "$(uname -s)" = "Linux" ]; then - make_parallel="-j5 --output-sync=target" - fi - - log_info "Deploying controller with CA certificate using $METHOD..." - if [ "$METHOD" = "operator" ]; then - OPERATOR_USE_DEX=true DEX_CA_FILE="$REPO_ROOT/ca.pem" METHOD=$METHOD \ - make -C controller deploy $make_parallel - else - EXTRA_VALUES="--values $REPO_ROOT/.e2e/values.kind.yaml" METHOD=$METHOD \ - make -C controller deploy $make_parallel - fi + log_info "Deploying controller with CA certificate using operator..." + OPERATOR_USE_DEX=true DEX_CA_FILE="$REPO_ROOT/ca.pem" make -C controller deploy log_info "✓ Controller deployed" } @@ -300,23 +282,13 @@ setup_test_environment() { cd "$REPO_ROOT" - # Get the controller endpoint based on deployment method + # Get the controller endpoint from the Jumpstarter CR # Note: We declare BASEDOMAIN separately from assignment so that command # failures propagate under set -e (local VAR=$(...) masks exit codes). local BASEDOMAIN - if [ "$METHOD" = "operator" ]; then - # For operator deployment, construct the endpoint from the Jumpstarter CR - # The operator uses nodeport mode by default with port 8082 - BASEDOMAIN=$(kubectl get jumpstarter -n "${JS_NAMESPACE}" jumpstarter -o jsonpath='{.spec.baseDomain}') - export ENDPOINT="grpc.${BASEDOMAIN}:8082" - export LOGIN_ENDPOINT="login.${BASEDOMAIN}:8086" - else - # For helm deployment, get the endpoint from helm values - export ENDPOINT=$(helm get values jumpstarter --output json | jq -r '."jumpstarter-controller".grpc.endpoint') - # Login endpoint is on nodeport 30014 mapped to host port 8086 - BASEDOMAIN=$(helm get values jumpstarter --output json | jq -r '.global.baseDomain') - export LOGIN_ENDPOINT="login.${BASEDOMAIN}:8086" - fi + BASEDOMAIN=$(kubectl get jumpstarter -n "${JS_NAMESPACE}" jumpstarter -o jsonpath='{.spec.baseDomain}') + export ENDPOINT="grpc.${BASEDOMAIN}:8082" + export LOGIN_ENDPOINT="login.${BASEDOMAIN}:8086" log_info "Controller endpoint: $ENDPOINT" log_info "Login endpoint: $LOGIN_ENDPOINT" diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/__init__.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/__init__.py index 41eb3f9f3..81a739798 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/__init__.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/__init__.py @@ -7,7 +7,6 @@ from .delete import delete from .get import get from .import_res import import_res -from .install import install, ip, uninstall @click.group(cls=AliasedGroup) @@ -19,12 +18,8 @@ def admin(): admin.add_command(get) admin.add_command(create) admin.add_command(delete) -admin.add_command(install) -admin.add_command(uninstall) -admin.add_command(ip) admin.add_command(import_res) admin.add_command(version) -admin.add_command(ip) if __name__ == "__main__": admin() diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py index 2b32c83c8..5b5134dc7 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py @@ -223,31 +223,12 @@ async def create_exporter( is_flag=True, help="Skip installing Jumpstarter after creating the cluster", ) -@click.option( - "--install-method", - type=click.Choice(["operator", "helm"]), - default=None, - help="Method to install Jumpstarter (default: helm for kind/minikube, operator for k3s)", -) @click.option( "--operator-installer", type=str, default=None, help="Path or URL to the operator installer YAML (auto-detected from version if not specified)", ) -@click.option( - "--helm", - type=str, - help="Path or name of a helm executable (only used with --install-method helm)", - default="helm", -) -@click.option( - "--chart", - type=str, - help="The URL of a Jumpstarter helm chart to install", - default="oci://quay.io/jumpstarter-dev/helm/jumpstarter", -) -@click.option("--chart-name", type=str, help="The name of the chart installation", default="jumpstarter") @click.option( "-n", "--namespace", type=str, help="Namespace to install Jumpstarter components in", default="jumpstarter-lab" ) @@ -256,14 +237,6 @@ async def create_exporter( @click.option("-g", "--grpc-endpoint", type=str, help="The gRPC endpoint to use for the Jumpstarter API", default=None) @click.option("-r", "--router-endpoint", type=str, help="The gRPC endpoint to use for the router", default=None) @click.option("-v", "--version", help="The version of the service to install", default=None) -@click.option( - "-f", - "--values-file", - "values_files", - type=click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True), - multiple=True, - help="Path to custom helm values file (can be specified multiple times)", -) @opt_kubeconfig @opt_context @opt_nointeractive @@ -279,18 +252,13 @@ async def create_cluster( minikube_extra_args: str, extra_certs: Optional[str], skip_install: bool, - install_method: str, operator_installer: Optional[str], - helm: str, - chart: str, - chart_name: str, namespace: str, ip: Optional[str], basedomain: Optional[str], grpc_endpoint: Optional[str], router_endpoint: Optional[str], version: Optional[str], - values_files: tuple[str, ...], kubeconfig: Optional[str], context: Optional[str], nointeractive: bool, @@ -329,9 +297,6 @@ async def create_cluster( minikube or "minikube", extra_certs, install_jumpstarter=not skip_install, - helm=helm, - chart=chart, - chart_name=chart_name, namespace=namespace, version=version, kubeconfig=kubeconfig, @@ -341,9 +306,7 @@ async def create_cluster( grpc_endpoint=grpc_endpoint, router_endpoint=router_endpoint, callback=callback, - values_files=list(values_files) if values_files else None, k3s_ssh_host=k3s, - install_method=install_method, operator_installer=operator_installer, ) except JumpstarterKubernetesError as e: diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py index e6b60685e..d5c76acfa 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py @@ -489,29 +489,6 @@ def test_create_cluster_skip_install(self, mock_validate, mock_create): kwargs = mock_create.call_args[1] assert kwargs.get("install_jumpstarter", True) is False - @patch("jumpstarter_cli_admin.create.create_cluster_and_install") - @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") - def test_create_cluster_with_custom_chart(self, mock_validate, mock_create): - """Test with custom chart and version options""" - mock_validate.return_value = "kind" - mock_create.return_value = None - - result = self.runner.invoke(create, [ - "cluster", "test-cluster", "--kind", "kind", - "--chart", "oci://custom.registry/helm/chart", - "--chart-name", "custom-jumpstarter", - "--version", "v1.2.3" - ]) - - assert result.exit_code == 0 - mock_create.assert_called_once() - - # Verify custom chart options - kwargs = mock_create.call_args[1] - assert kwargs.get("chart") == "oci://custom.registry/helm/chart" - assert kwargs.get("chart_name") == "custom-jumpstarter" - assert kwargs.get("version") == "v1.2.3" - @patch("jumpstarter_cli_admin.create.create_cluster_and_install") @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") def test_create_cluster_with_custom_endpoints(self, mock_validate, mock_create): @@ -622,18 +599,6 @@ def test_create_cluster_with_extra_certs(self, mock_validate, mock_create): import os os.unlink(cert_path) - @patch("jumpstarter_cli_admin.create.create_cluster_and_install") - @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") - def test_create_cluster_helm_not_installed(self, mock_validate, mock_create): - """Test error when helm is not installed (for installation)""" - mock_validate.return_value = "kind" - mock_create.side_effect = click.ClickException("helm is not installed") - - result = self.runner.invoke(create, ["cluster", "test-cluster", "--kind", "kind"]) - - assert result.exit_code != 0 - assert "helm is not installed" in result.output - @patch("jumpstarter_cli_admin.create.create_cluster_and_install") @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") def test_create_cluster_kind_not_installed(self, mock_validate, mock_create): diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index 09c63fd43..0f0904355 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -119,19 +119,18 @@ async def get_lease( "--type", type=click.Choice(["kind", "minikube", "remote", "all"]), default="all", help="Filter clusters by type" ) @click.option("--kubectl", type=str, help="Path or name of kubectl executable", default="kubectl") -@click.option("--helm", type=str, help="Path or name of helm executable", default="helm") @click.option("--kind", type=str, help="Path or name of kind executable", default="kind") @click.option("--minikube", type=str, help="Path or name of minikube executable", default="minikube") @opt_output_all @blocking async def get_cluster( - name: Optional[str], type: str, kubectl: str, helm: str, kind: str, minikube: str, output: OutputType + name: Optional[str], type: str, kubectl: str, kind: str, minikube: str, output: OutputType ): """Get information about a specific cluster or list all clusters""" try: if name is not None: # Get specific cluster by context name - cluster_info = await get_cluster_info(name, kubectl, helm, minikube) + cluster_info = await get_cluster_info(name, kubectl, minikube) # Check if the cluster context was found if cluster_info.error and "not found" in cluster_info.error: @@ -140,7 +139,7 @@ async def get_cluster( model_print(cluster_info, output) else: # List all clusters if no name provided - cluster_list = await list_clusters(type, kubectl, helm, kind, minikube) + cluster_list = await list_clusters(type, kubectl, kind, minikube) model_print(cluster_list, output) except click.ClickException: raise @@ -153,15 +152,14 @@ async def get_cluster( "--type", type=click.Choice(["kind", "minikube", "remote", "all"]), default="all", help="Filter clusters by type" ) @click.option("--kubectl", type=str, help="Path or name of kubectl executable", default="kubectl") -@click.option("--helm", type=str, help="Path or name of helm executable", default="helm") @click.option("--kind", type=str, help="Path or name of kind executable", default="kind") @click.option("--minikube", type=str, help="Path or name of minikube executable", default="minikube") @opt_output_all @blocking -async def get_clusters(type: str, kubectl: str, helm: str, kind: str, minikube: str, output: OutputType): +async def get_clusters(type: str, kubectl: str, kind: str, minikube: str, output: OutputType): """List all Kubernetes clusters with Jumpstarter status""" try: - cluster_list = await list_clusters(type, kubectl, helm, kind, minikube) + cluster_list = await list_clusters(type, kubectl, kind, minikube) # Use model_print for all output formats model_print(cluster_list, output) diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py deleted file mode 100644 index 6f3ced67a..000000000 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py +++ /dev/null @@ -1,255 +0,0 @@ -from typing import Literal, Optional - -import click -from jumpstarter_cli_common.blocking import blocking -from jumpstarter_cli_common.opt import opt_context, opt_kubeconfig -from jumpstarter_cli_common.version import get_client_version -from jumpstarter_kubernetes import ( - get_latest_compatible_controller_version, - helm_installed, - install_helm_chart, - minikube_installed, - uninstall_helm_chart, -) - -from jumpstarter.common.ipaddr import get_ip_address, get_minikube_ip - - -def _validate_cluster_type( - kind: Optional[str], minikube: Optional[str] -) -> Optional[Literal["kind"] | Literal["minikube"]]: - """Validate cluster type selection - returns None if neither is specified""" - if kind and minikube: - raise click.ClickException('You can only select one local cluster type "kind" or "minikube"') - - if kind is not None: - return "kind" - elif minikube is not None: - return "minikube" - else: - return None - - -def _validate_prerequisites(helm: str) -> None: - if helm_installed(helm) is False: - raise click.ClickException( - "helm is not installed (or not in your PATH), please specify a helm executable with --helm " - ) - - -async def _configure_endpoints( - cluster_type: Optional[str], - minikube: str, - cluster_name: str, - ip: Optional[str], - basedomain: Optional[str], - grpc_endpoint: Optional[str], - router_endpoint: Optional[str], -) -> tuple[str, str, str, str]: - if ip is None: - ip = await get_ip_generic(cluster_type, minikube, cluster_name) - if basedomain is None: - basedomain = f"jumpstarter.{ip}.nip.io" - if grpc_endpoint is None: - grpc_endpoint = f"grpc.{basedomain}:8082" - if router_endpoint is None: - router_endpoint = f"router.{basedomain}:8083" - - return ip, basedomain, grpc_endpoint, router_endpoint - - -async def _install_jumpstarter_helm_chart( - chart: str, - name: str, - namespace: str, - basedomain: str, - grpc_endpoint: str, - router_endpoint: str, - mode: str, - version: str, - kubeconfig: Optional[str], - context: Optional[str], - helm: str, - ip: str, - values_files: Optional[list[str]] = None, -) -> None: - click.echo(f'Installing Jumpstarter service v{version} in namespace "{namespace}" with Helm\n') - click.echo(f"Chart URI: {chart}") - click.echo(f"Chart Version: {version}") - click.echo(f"IP Address: {ip}") - click.echo(f"Basedomain: {basedomain}") - click.echo(f"Service Endpoint: {grpc_endpoint}") - click.echo(f"Router Endpoint: {router_endpoint}") - click.echo(f"gPRC Mode: {mode}\n") - - await install_helm_chart( - chart, - name, - namespace, - basedomain, - grpc_endpoint, - router_endpoint, - mode, - version, - kubeconfig, - context, - helm, - values_files, - ) - - click.echo(f'Installed Helm release "{name}" in namespace "{namespace}"') - - -async def get_ip_generic(cluster_type: Optional[str], minikube: str, cluster_name: str) -> str: - if cluster_type == "minikube": - if not minikube_installed(minikube): - raise click.ClickException("minikube is not installed (or not in your PATH)") - try: - ip = await get_minikube_ip(cluster_name, minikube) - except Exception as e: - raise click.ClickException(f"Could not determine Minikube IP address.\n{e}") from e - else: - ip = get_ip_address() - if ip == "0.0.0.0": - raise click.ClickException("Could not determine IP address, use --ip to specify an IP address") - - return ip - - -@click.command -@click.option("--helm", type=str, help="Path or name of a helm executable", default="helm") -@click.option("--name", type=str, help="The name of the chart installation", default="jumpstarter") -@click.option( - "-c", - "--chart", - type=str, - help="The URL of a Jumpstarter helm chart to install", - default="oci://quay.io/jumpstarter-dev/helm/jumpstarter", -) -@click.option( - "-n", "--namespace", type=str, help="Namespace to install Jumpstarter components in", default="jumpstarter-lab" -) -@click.option("-i", "--ip", type=str, help="IP address of your host machine", default=None) -@click.option("-b", "--basedomain", type=str, help="Base domain of the Jumpstarter service", default=None) -@click.option("-g", "--grpc-endpoint", type=str, help="The gRPC endpoint to use for the Jumpstarter API", default=None) -@click.option("-r", "--router-endpoint", type=str, help="The gRPC endpoint to use for the router", default=None) -@click.option("--nodeport", "mode", flag_value="nodeport", help="Use Nodeport routing (recommended)", default=True) -@click.option("--ingress", "mode", flag_value="ingress", help="Use a Kubernetes ingress") -@click.option("--route", "mode", flag_value="route", help="Use an OpenShift route") -@click.option( - "--kind", is_flag=False, flag_value="kind", default=None, help="Use default settings for a local Kind cluster" -) -@click.option( - "--minikube", - is_flag=False, - flag_value="minikube", - default=None, - help="Use default settings for a local Minikube cluster", -) -@click.option("-v", "--version", help="The version of the service to install", default=None) -@click.option( - "-f", - "--values-file", - "values_files", - type=click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True), - multiple=True, - help="Path to custom helm values file (can be specified multiple times)", -) -@opt_kubeconfig -@opt_context -@blocking -async def install( - helm: str, - chart: str, - name: str, - namespace: str, - ip: Optional[str], - basedomain: Optional[str], - grpc_endpoint: Optional[str], - router_endpoint: Optional[str], - mode: Literal["nodeport"] | Literal["ingress"] | Literal["route"], - kind: Optional[str], - minikube: Optional[str], - version: str, - values_files: tuple[str, ...], - kubeconfig: Optional[str], - context: Optional[str], -): - """Install the Jumpstarter service in a Kubernetes cluster""" - _validate_prerequisites(helm) - - cluster_type = _validate_cluster_type(kind, minikube) - - ip, basedomain, grpc_endpoint, router_endpoint = await _configure_endpoints( - cluster_type, minikube or "minikube", "jumpstarter-lab", ip, basedomain, grpc_endpoint, router_endpoint - ) - - if version is None: - version = await get_latest_compatible_controller_version(get_client_version()) - - await _install_jumpstarter_helm_chart( - chart, - name, - namespace, - basedomain, - grpc_endpoint, - router_endpoint, - mode, - version, - kubeconfig, - context, - helm, - ip, - list(values_files) if values_files else None, - ) - - -@click.command -@click.option( - "--kind", is_flag=False, flag_value="kind", default=None, help="Use default settings for a local Kind cluster" -) -@click.option( - "--minikube", - is_flag=False, - flag_value="minikube", - default=None, - help="Use default settings for a local Minikube cluster", -) -@click.option("--cluster-name", type=str, help="The name of the cluster", default="jumpstarter-lab") -@blocking -async def ip( - kind: Optional[str], - minikube: Optional[str], - cluster_name: str, -): - """Attempt to determine the IP address of your computer""" - cluster_type = _validate_cluster_type(kind, minikube) - minikube_binary = minikube or "minikube" - ip = await get_ip_generic(cluster_type, minikube_binary, cluster_name) - click.echo(ip) - - -@click.command -@click.option("--helm", type=str, help="Path or name of a helm executable", default="helm") -@click.option("--name", type=str, help="The name of the chart installation", default="jumpstarter") -@click.option( - "-n", "--namespace", type=str, help="Namespace to install Jumpstarter components in", default="jumpstarter-lab" -) -@opt_kubeconfig -@opt_context -@blocking -async def uninstall( - helm: str, - name: str, - namespace: str, - kubeconfig: Optional[str], - context: Optional[str], -): - """Uninstall the Jumpstarter service in a Kubernetes cluster""" - _validate_prerequisites(helm) - - click.echo(f'Uninstalling Jumpstarter service in namespace "{namespace}" with Helm') - - await uninstall_helm_chart(name, namespace, kubeconfig, context, helm) - - click.echo(f'Uninstalled Helm release "{name}" from namespace "{namespace}"') diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py deleted file mode 100644 index 5a9f1ff61..000000000 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py +++ /dev/null @@ -1,137 +0,0 @@ -from unittest.mock import patch - -import click -import pytest -from click.testing import CliRunner -from jumpstarter_kubernetes.callbacks import SilentCallback -from jumpstarter_kubernetes.cluster.kind import create_kind_cluster_with_options -from jumpstarter_kubernetes.cluster.minikube import create_minikube_cluster_with_options -from jumpstarter_kubernetes.cluster.operations import validate_cluster_type_selection - -from jumpstarter_cli_admin.install import ( - _validate_prerequisites, - install, - uninstall, -) - - -class TestValidationFunctions: - """Test validation helper functions.""" - - @patch("jumpstarter_cli_admin.install.helm_installed") - def test_validate_prerequisites_helm_installed(self, mock_helm_installed): - mock_helm_installed.return_value = True - # Should not raise any exception - _validate_prerequisites("helm") - - @patch("jumpstarter_cli_admin.install.helm_installed") - def test_validate_prerequisites_helm_not_installed(self, mock_helm_installed): - mock_helm_installed.return_value = False - with pytest.raises(click.ClickException, match="helm is not installed"): - _validate_prerequisites("helm") - - def test_validate_cluster_type_both_specified(self): - """Test that error is raised when both kind and minikube are specified.""" - from jumpstarter_kubernetes.exceptions import ClusterTypeValidationError - - with pytest.raises( - ClusterTypeValidationError, match='You can only select one cluster type' - ): - validate_cluster_type_selection("kind", "minikube") - - def test_validate_cluster_type_kind_only(self): - """Test that 'kind' is returned when only kind is specified.""" - result = validate_cluster_type_selection("kind", None) - assert result == "kind" - - def test_validate_cluster_type_minikube_only(self): - """Test that 'minikube' is returned when only minikube is specified.""" - result = validate_cluster_type_selection(None, "minikube") - assert result == "minikube" - - # Note: test_validate_cluster_type_auto_detect removed as this function - # is now tested in the jumpstarter-kubernetes library - - -class TestEndpointConfiguration: - """Test endpoint configuration functions.""" - - # Note: test_configure_endpoints_minikube removed as this function - # is now tested in the jumpstarter-kubernetes library - - -class TestClusterCreation: - """Test cluster creation functions.""" - - # Note: Tests for _handle_cluster_creation, _create_kind_cluster, and _create_minikube_cluster - # have been removed as these functions no longer exist after the refactoring. - # The functionality is now tested through the new create_kind_cluster_with_options - # and create_minikube_cluster_with_options functions in their respective modules. - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") - @patch("jumpstarter_kubernetes.cluster.kind.create_kind_cluster") - async def test_create_kind_cluster_with_options_success(self, mock_create_kind, mock_kind_installed): - """Test creating a Kind cluster with the new function structure.""" - - mock_kind_installed.return_value = True - mock_create_kind.return_value = True - callback = SilentCallback() - - await create_kind_cluster_with_options( - "kind", "test-cluster", "--verbosity=1", False, None, callback - ) - - mock_create_kind.assert_called_once() - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") - async def test_create_kind_cluster_with_options_not_installed(self, mock_kind_installed): - """Test that ToolNotInstalledError is raised when kind is not installed.""" - from jumpstarter_kubernetes.exceptions import ToolNotInstalledError - - mock_kind_installed.return_value = False - callback = SilentCallback() - - with pytest.raises(ToolNotInstalledError, match="kind is not installed"): - await create_kind_cluster_with_options( - "kind", "test-cluster", "", False, None, callback - ) - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") - @patch("jumpstarter_kubernetes.cluster.minikube.create_minikube_cluster") - async def test_create_minikube_cluster_with_options_success(self, mock_create_minikube, mock_minikube_installed): - """Test creating a Minikube cluster with the new function structure.""" - mock_minikube_installed.return_value = True - mock_create_minikube.return_value = True - callback = SilentCallback() - - await create_minikube_cluster_with_options( - "minikube", "test-cluster", "--memory=4096", False, None, callback - ) - - mock_create_minikube.assert_called_once() - - -class TestIPDetection: - """Test IP address detection functions.""" - - # Note: test_get_ip_generic_minikube and test_get_ip_generic_fallback removed - # as these functions are now tested in the jumpstarter-kubernetes library - - -class TestCLICommands: - """Test CLI command execution.""" - - def test_install_command_help(self): - runner = CliRunner() - result = runner.invoke(install, ["--help"]) - assert result.exit_code == 0 - assert "Install the Jumpstarter service" in result.output - - def test_uninstall_command_help(self): - runner = CliRunner() - result = runner.invoke(uninstall, ["--help"]) - assert result.exit_code == 0 - assert "Uninstall the Jumpstarter service" in result.output diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py index 668093345..dd785de75 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py @@ -26,11 +26,6 @@ V1Alpha1ExporterList, V1Alpha1ExporterStatus, ) -from .install import ( - helm_installed, - install_helm_chart, - uninstall_helm_chart, -) from .leases import ( LeasesV1Alpha1Api, V1Alpha1Lease, @@ -61,9 +56,6 @@ "V1Alpha1LeaseSelector", "V1Alpha1LeaseSpec", "V1Alpha1List", - "helm_installed", - "install_helm_chart", - "uninstall_helm_chart", "minikube_installed", "kind_installed", "create_minikube_cluster", diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py index 1f4535426..dbbfb0def 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py @@ -2,7 +2,6 @@ This module provides comprehensive cluster management functionality including: - Kind and Minikube cluster operations -- Helm chart management - Kubectl operations - Cluster detection and endpoint configuration - High-level orchestration operations @@ -15,7 +14,6 @@ # Common utilities and types from .common import ( ClusterType, - InstallMethod, extract_host_from_ssh, format_cluster_name, get_extra_certs_path, @@ -29,9 +27,6 @@ from .detection import auto_detect_cluster_type, detect_cluster_type, detect_existing_cluster_type from .endpoints import configure_endpoints, get_ip_generic -# Helm operations -from .helm import install_jumpstarter_helm_chart - # k3s cluster operations from .k3s import ( create_k3s_cluster, @@ -87,7 +82,6 @@ __all__ = [ # Types "ClusterType", - "InstallMethod", # Common utilities "extract_host_from_ssh", "validate_cluster_name", @@ -113,8 +107,6 @@ "delete_minikube_cluster", "list_minikube_clusters", "get_minikube_cluster_ip", - # Helm operations - "install_jumpstarter_helm_chart", # Operator operations "install_jumpstarter_operator", # Kubectl operations diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common.py index 36899d8d6..9ba5ef0f8 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common.py @@ -7,7 +7,6 @@ from ..exceptions import ClusterTypeValidationError ClusterType = Literal["kind"] | Literal["minikube"] | Literal["k3s"] -InstallMethod = Literal["operator", "helm"] # NodePort assignments (must match kind_cluster.yaml extraPortMappings and operator CR) GRPC_NODEPORT = 30010 diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm.py deleted file mode 100644 index 0b3c6b903..000000000 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Helm chart management operations.""" - -from typing import Optional - -from ..callbacks import OutputCallback, SilentCallback -from ..install import install_helm_chart - - -async def install_jumpstarter_helm_chart( - chart: str, - name: str, - namespace: str, - basedomain: str, - grpc_endpoint: str, - router_endpoint: str, - mode: str, - version: str, - kubeconfig: Optional[str], - context: Optional[str], - helm: str, - ip: str, - callback: OutputCallback = None, - values_files: Optional[list[str]] = None, -) -> None: - """Install Jumpstarter Helm chart.""" - if callback is None: - callback = SilentCallback() - - callback.progress(f'Installing Jumpstarter service v{version} in namespace "{namespace}" with Helm\n') - callback.progress(f"Chart URI: {chart}") - callback.progress(f"Chart Version: {version}") - callback.progress(f"IP Address: {ip}") - callback.progress(f"Basedomain: {basedomain}") - callback.progress(f"Service Endpoint: {grpc_endpoint}") - callback.progress(f"Router Endpoint: {router_endpoint}") - callback.progress(f"gRPC Mode: {mode}\n") - - await install_helm_chart( - chart, - name, - namespace, - basedomain, - grpc_endpoint, - router_endpoint, - mode, - version, - kubeconfig, - context, - helm, - values_files, - ) - - callback.success(f'Installed Helm release "{name}" in namespace "{namespace}"') diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm_test.py deleted file mode 100644 index 3dce9f60d..000000000 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm_test.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Tests for Helm chart management operations.""" - -from unittest.mock import patch - -import pytest - -from jumpstarter_kubernetes.cluster.helm import install_jumpstarter_helm_chart - - -class TestInstallJumpstarterHelmChart: - """Test Jumpstarter Helm chart installation.""" - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") - async def test_install_jumpstarter_helm_chart_all_params(self, mock_install_helm_chart): - from unittest.mock import MagicMock - - mock_install_helm_chart.return_value = None - mock_callback = MagicMock() - - await install_jumpstarter_helm_chart( - chart="oci://registry.example.com/jumpstarter", - name="jumpstarter", - namespace="jumpstarter-system", - basedomain="jumpstarter.192.168.1.100.nip.io", - grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", - router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", - mode="insecure", - version="1.0.0", - kubeconfig="/path/to/kubeconfig", - context="test-context", - helm="helm", - ip="192.168.1.100", - callback=mock_callback, - ) - - # Verify that install_helm_chart was called with correct parameters - mock_install_helm_chart.assert_called_once_with( - "oci://registry.example.com/jumpstarter", - "jumpstarter", - "jumpstarter-system", - "jumpstarter.192.168.1.100.nip.io", - "grpc.jumpstarter.192.168.1.100.nip.io:8082", - "router.jumpstarter.192.168.1.100.nip.io:8083", - "insecure", - "1.0.0", - "/path/to/kubeconfig", - "test-context", - "helm", - None, - ) - - # Verify callback was called - assert mock_callback.progress.call_count >= 7 # Multiple progress messages - assert mock_callback.success.call_count == 1 # One success message - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") - async def test_install_jumpstarter_helm_chart_with_none_values(self, mock_install_helm_chart): - mock_install_helm_chart.return_value = None - - await install_jumpstarter_helm_chart( - chart="jumpstarter/jumpstarter", - name="my-jumpstarter", - namespace="default", - basedomain="test.example.com", - grpc_endpoint="grpc.test.example.com:443", - router_endpoint="router.test.example.com:443", - mode="secure", - version="2.1.0", - kubeconfig=None, - context=None, - helm="helm3", - ip="10.0.0.1", - ) - - # Verify that install_helm_chart was called with None values preserved - mock_install_helm_chart.assert_called_once_with( - "jumpstarter/jumpstarter", - "my-jumpstarter", - "default", - "test.example.com", - "grpc.test.example.com:443", - "router.test.example.com:443", - "secure", - "2.1.0", - None, - None, - "helm3", - None, - ) - - # Verify success message with correct values - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") - async def test_install_jumpstarter_helm_chart_secure_mode(self, mock_install_helm_chart): - mock_install_helm_chart.return_value = None - - await install_jumpstarter_helm_chart( - chart="https://example.com/charts/jumpstarter-1.5.0.tgz", - name="production-jumpstarter", - namespace="production", - basedomain="jumpstarter.prod.example.com", - grpc_endpoint="grpc.jumpstarter.prod.example.com:443", - router_endpoint="router.jumpstarter.prod.example.com:443", - mode="secure", - version="1.5.0", - kubeconfig="/etc/kubernetes/admin.conf", - context="production-cluster", - helm="/usr/local/bin/helm", - ip="203.0.113.1", - ) - - # Verify gRPC mode is correctly displayed - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") - async def test_install_jumpstarter_helm_chart_custom_endpoints(self, mock_install_helm_chart): - mock_install_helm_chart.return_value = None - - await install_jumpstarter_helm_chart( - chart="jumpstarter", - name="dev-jumpstarter", - namespace="development", - basedomain="dev.local", - grpc_endpoint="grpc-custom.dev.local:9090", - router_endpoint="router-custom.dev.local:9091", - mode="insecure", - version="0.9.0-beta", - kubeconfig="~/.kube/config", - context="dev-context", - helm="helm", - ip="172.16.0.10", - ) - - # Verify custom endpoints are displayed correctly - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") - async def test_install_jumpstarter_helm_chart_install_helm_chart_error(self, mock_install_helm_chart): - # Test that exceptions from install_helm_chart propagate - mock_install_helm_chart.side_effect = Exception("Helm installation failed") - - with pytest.raises(Exception, match="Helm installation failed"): - await install_jumpstarter_helm_chart( - chart="jumpstarter", - name="test-jumpstarter", - namespace="test", - basedomain="test.local", - grpc_endpoint="grpc.test.local:8082", - router_endpoint="router.test.local:8083", - mode="insecure", - version="1.0.0", - kubeconfig=None, - context=None, - helm="helm", - ip="192.168.1.1", - ) - - # Exception was raised correctly - test complete - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") - async def test_install_jumpstarter_helm_chart_minimal_params(self, mock_install_helm_chart): - mock_install_helm_chart.return_value = None - - await install_jumpstarter_helm_chart( - chart="minimal", - name="min", - namespace="min-ns", - basedomain="min.io", - grpc_endpoint="grpc.min.io:80", - router_endpoint="router.min.io:80", - mode="test", - version="0.1.0", - kubeconfig=None, - context=None, - helm="h", - ip="1.1.1.1", - ) - - # Verify all required parameters work with minimal values - mock_install_helm_chart.assert_called_once_with( - "minimal", - "min", - "min-ns", - "min.io", - "grpc.min.io:80", - "router.min.io:80", - "test", - "0.1.0", - None, - None, - "h", - None, - ) - - # Verify appropriate echo calls were made - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") - async def test_install_jumpstarter_helm_chart_with_values_files(self, mock_install_helm_chart): - """Test that values_files parameter is passed through correctly.""" - from unittest.mock import MagicMock - - mock_install_helm_chart.return_value = None - mock_callback = MagicMock() - - values_files = ["/path/to/values1.yaml", "/path/to/values2.yaml"] - - await install_jumpstarter_helm_chart( - chart="oci://registry.example.com/jumpstarter", - name="jumpstarter", - namespace="jumpstarter-system", - basedomain="jumpstarter.192.168.1.100.nip.io", - grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", - router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", - mode="insecure", - version="1.0.0", - kubeconfig="/path/to/kubeconfig", - context="test-context", - helm="helm", - ip="192.168.1.100", - callback=mock_callback, - values_files=values_files, - ) - - # Verify that install_helm_chart was called with values_files - mock_install_helm_chart.assert_called_once_with( - "oci://registry.example.com/jumpstarter", - "jumpstarter", - "jumpstarter-system", - "jumpstarter.192.168.1.100.nip.io", - "grpc.jumpstarter.192.168.1.100.nip.io:8082", - "router.jumpstarter.192.168.1.100.nip.io:8083", - "insecure", - "1.0.0", - "/path/to/kubeconfig", - "test-context", - "helm", - values_files, - ) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 001c1aef1..d9e12804d 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -73,9 +73,9 @@ async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[Dict[str, str]] async def check_jumpstarter_installation( # noqa: C901 - context: str, namespace: Optional[str] = None, helm: str = "helm", kubectl: str = "kubectl" + context: str, namespace: Optional[str] = None, kubectl: str = "kubectl" ) -> V1Alpha1JumpstarterInstance: - """Check if Jumpstarter is installed in the cluster.""" + """Check if Jumpstarter is installed in the cluster using CRD detection.""" result_data = { "installed": False, "version": None, @@ -90,86 +90,18 @@ async def check_jumpstarter_installation( # noqa: C901 } try: - # Check for Helm installation first - helm_cmd = [helm, "list", "--all-namespaces", "-o", "json", "--kube-context", context] - returncode, stdout, _ = await run_command(helm_cmd) - - if returncode == 0: - # Extract JSON from output (handle case where warnings are printed before JSON) - json_start = stdout.find("[") - if json_start >= 0: - json_output = stdout[json_start:] - releases = json.loads(json_output) - else: - releases = json.loads(stdout) # Fallback to original parsing - for release in releases: - # Look for Jumpstarter chart - if "jumpstarter" in release.get("chart", "").lower(): - result_data["installed"] = True - result_data["version"] = release.get("app_version") or release.get("chart", "").split("-")[-1] - result_data["namespace"] = release.get("namespace") - result_data["chart_name"] = release.get("name") - result_data["status"] = release.get("status") - - # Try to get Helm values to extract basedomain and endpoints - try: - values_cmd = [ - helm, - "get", - "values", - release.get("name"), - "-n", - release.get("namespace"), - "-o", - "json", - "--kube-context", - context, - ] - values_returncode, values_stdout, _ = await run_command(values_cmd) - - if values_returncode == 0: - # Extract JSON from values output (handle warnings) - json_start = values_stdout.find("{") - if json_start >= 0: - json_output = values_stdout[json_start:] - values = json.loads(json_output) - else: - values = json.loads(values_stdout) # Fallback - - # Extract basedomain - basedomain = values.get("global", {}).get("baseDomain") - if basedomain: - result_data["basedomain"] = basedomain - # Construct default endpoints from basedomain - result_data["controller_endpoint"] = f"grpc.{basedomain}:8082" - result_data["router_endpoint"] = f"router.{basedomain}:8083" - - # Check for explicit endpoints in values - controller_config = values.get("jumpstarter-controller", {}).get("grpc", {}) - if controller_config.get("endpoint"): - result_data["controller_endpoint"] = controller_config["endpoint"] - if controller_config.get("routerEndpoint"): - result_data["router_endpoint"] = controller_config["routerEndpoint"] - - except (json.JSONDecodeError, RuntimeError): - # Failed to get Helm values, but we still have basic info - pass - - break - - # Check for Jumpstarter CRDs as secondary verification + # Check for Jumpstarter CRDs try: crd_cmd = [kubectl, "--context", context, "get", "crd", "-o", "json"] returncode, stdout, _ = await run_command(crd_cmd) if returncode == 0: - # Extract JSON from CRD output (handle warnings) json_start = stdout.find("{") if json_start >= 0: json_output = stdout[json_start:] crds = json.loads(json_output) else: - crds = json.loads(stdout) # Fallback + crds = json.loads(stdout) jumpstarter_crds = [] for item in crds.get("items", []): name = item.get("metadata", {}).get("name", "") @@ -178,17 +110,14 @@ async def check_jumpstarter_installation( # noqa: C901 if jumpstarter_crds: result_data["has_crds"] = True - if not result_data["installed"]: - # CRDs exist but no Helm release found - manual installation? - result_data["installed"] = True - result_data["version"] = "unknown" - result_data["namespace"] = namespace or "unknown" - result_data["status"] = "manual-install" + result_data["installed"] = True + result_data["namespace"] = namespace or "unknown" + result_data["status"] = "installed" except RuntimeError: - pass # CRD check failed, continue with Helm results + pass except json.JSONDecodeError as e: - result_data["error"] = f"Failed to parse Helm output: {e}" + result_data["error"] = f"Failed to parse output: {e}" except RuntimeError as e: result_data["error"] = f"Command failed: {e}" @@ -198,7 +127,6 @@ async def check_jumpstarter_installation( # noqa: C901 async def get_cluster_info( context: str, kubectl: str = "kubectl", - helm: str = "helm", minikube: str = "minikube", ) -> V1Alpha1ClusterInfo: """Get comprehensive cluster information.""" @@ -249,7 +177,7 @@ async def get_cluster_info( # Check Jumpstarter installation if cluster_accessible: - jumpstarter_info = await check_jumpstarter_installation(context, None, helm, kubectl) + jumpstarter_info = await check_jumpstarter_installation(context, None, kubectl) else: jumpstarter_info = V1Alpha1JumpstarterInstance(installed=False, error="Cluster not accessible") @@ -284,7 +212,6 @@ async def get_cluster_info( async def list_clusters( cluster_type_filter: str = "all", kubectl: str = "kubectl", - helm: str = "helm", kind: str = "kind", minikube: str = "minikube", ) -> V1Alpha1ClusterList: @@ -294,7 +221,7 @@ async def list_clusters( cluster_infos = [] for context in contexts: - cluster_info = await get_cluster_info(context["name"], kubectl, helm, minikube) + cluster_info = await get_cluster_info(context["name"], kubectl, minikube) # Filter by type if specified if cluster_type_filter != "all" and cluster_info.type != cluster_type_filter: diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 284bce382..5689869a6 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -190,44 +190,25 @@ async def test_get_kubectl_contexts_custom_kubectl(self, mock_run_command): class TestCheckJumpstarterInstallation: - """Test Jumpstarter installation checking.""" + """Test Jumpstarter installation checking via CRD detection.""" @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") - async def test_check_jumpstarter_installation_helm_found(self, mock_run_command): - helm_releases = [ - { - "chart": "jumpstarter-1.0.0", - "app_version": "1.0.0", - "namespace": "jumpstarter-system", - "name": "jumpstarter-release", - "status": "deployed", - } - ] - # Mock calls: helm list, kubectl get namespaces, kubectl get crds - mock_run_command.side_effect = [ - (0, json.dumps(helm_releases), ""), # helm list success - (0, '{"items": []}', ""), # kubectl get namespaces - (0, '{"items": []}', ""), # kubectl get crds - ] + async def test_check_jumpstarter_installation_crds_found(self, mock_run_command): + crds_response = {"items": [{"metadata": {"name": "exporters.jumpstarter.dev"}}]} + + mock_run_command.return_value = (0, json.dumps(crds_response), "") result = await check_jumpstarter_installation("test-context") assert result.installed is True - assert result.version == "1.0.0" - assert result.namespace == "jumpstarter-system" - assert result.chart_name == "jumpstarter-release" - assert result.status == "deployed" + assert result.has_crds is True + assert result.status == "installed" @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") - async def test_check_jumpstarter_installation_no_helm(self, mock_run_command): - # Helm command fails, fallback to kubectl - mock_run_command.side_effect = [ - (1, "", "helm not found"), # helm list fails - (0, '{"items": []}', ""), # kubectl get namespaces - (1, "", "not found"), # kubectl get crds - ] + async def test_check_jumpstarter_installation_no_crds(self, mock_run_command): + mock_run_command.return_value = (0, '{"items": []}', "") result = await check_jumpstarter_installation("test-context") @@ -236,35 +217,24 @@ async def test_check_jumpstarter_installation_no_helm(self, mock_run_command): @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") - async def test_check_jumpstarter_installation_namespace_found(self, mock_run_command): - crds_response = {"items": [{"metadata": {"name": "exporter.jumpstarter.dev"}}]} - - mock_run_command.side_effect = [ - (1, "", "helm not found"), # helm list fails - (0, json.dumps(crds_response), ""), # kubectl get crds - ] + async def test_check_jumpstarter_installation_command_failure(self, mock_run_command): + mock_run_command.side_effect = RuntimeError("kubectl not found") result = await check_jumpstarter_installation("test-context") - assert result.installed is True - assert result.namespace == "unknown" - assert result.has_crds is True - assert result.status == "manual-install" + assert result.installed is False + assert result.has_crds is False @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") async def test_check_jumpstarter_installation_custom_namespace(self, mock_run_command): - mock_run_command.side_effect = [ - (0, "[]", ""), # helm list - (0, '{"items": []}', ""), # kubectl get crds - ] + crds_response = {"items": [{"metadata": {"name": "exporters.jumpstarter.dev"}}]} + mock_run_command.return_value = (0, json.dumps(crds_response), "") - await check_jumpstarter_installation("test-context", namespace="custom-ns") + result = await check_jumpstarter_installation("test-context", namespace="custom-ns") - # Verify the helm command was called (namespace parameter not used in current implementation) - helm_call = mock_run_command.call_args_list[0] - assert "helm" in helm_call[0][0] - assert "list" in helm_call[0][0] + assert result.installed is True + assert result.namespace == "custom-ns" class TestGetClusterInfo: @@ -387,6 +357,6 @@ async def test_list_clusters_context_error(self, mock_get_contexts): async def test_list_clusters_custom_parameters(self, mock_get_contexts): mock_get_contexts.return_value = [] - await list_clusters(kubectl="custom-kubectl", helm="custom-helm", minikube="custom-minikube") + await list_clusters(kubectl="custom-kubectl", minikube="custom-minikube") mock_get_contexts.assert_called_once_with("custom-kubectl") diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py index 8967f7430..40b50f157 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py @@ -10,11 +10,9 @@ ClusterTypeValidationError, ToolNotInstalledError, ) -from ..install import helm_installed -from .common import ClusterType, InstallMethod, extract_host_from_ssh, validate_cluster_name +from .common import ClusterType, extract_host_from_ssh, validate_cluster_name from .detection import auto_detect_cluster_type, detect_existing_cluster_type from .endpoints import configure_endpoints -from .helm import install_jumpstarter_helm_chart from .k3s import ( create_k3s_cluster_with_options, ) @@ -119,9 +117,6 @@ async def create_cluster_and_install( # noqa: C901 minikube: str, extra_certs: Optional[str] = None, install_jumpstarter: bool = True, - helm: str = "helm", - chart: str = "oci://quay.io/jumpstarter-dev/helm/jumpstarter", - chart_name: str = "jumpstarter", namespace: str = "jumpstarter-lab", version: Optional[str] = None, kubeconfig: Optional[str] = None, @@ -131,9 +126,7 @@ async def create_cluster_and_install( # noqa: C901 grpc_endpoint: Optional[str] = None, router_endpoint: Optional[str] = None, callback: OutputCallback = None, - values_files: Optional[list[str]] = None, k3s_ssh_host: Optional[str] = None, - install_method: Optional[InstallMethod] = None, operator_installer: Optional[str] = None, ) -> None: """Create a cluster and optionally install Jumpstarter.""" @@ -147,12 +140,12 @@ async def create_cluster_and_install( # noqa: C901 raise ClusterNameValidationError(cluster_name, str(e)) from e if force_recreate_cluster: - callback.warning(f'⚠️ WARNING: Force recreating cluster "{cluster_name}" will destroy ALL data in the cluster!') + callback.warning(f'WARNING: Force recreating cluster "{cluster_name}" will destroy ALL data in the cluster!') callback.warning("This includes:") - callback.warning(" • All deployed applications and services") - callback.warning(" • All persistent volumes and data") - callback.warning(" • All configurations and secrets") - callback.warning(" • All custom resources") + callback.warning(" - All deployed applications and services") + callback.warning(" - All persistent volumes and data") + callback.warning(" - All configurations and secrets") + callback.warning(" - All custom resources") if not callback.confirm(f'Are you sure you want to recreate cluster "{cluster_name}"?'): callback.progress("Cluster recreation cancelled.") raise ClusterOperationError("recreate", cluster_name, cluster_type, Exception("User cancelled")) @@ -183,12 +176,6 @@ async def create_cluster_and_install( # noqa: C901 # Install Jumpstarter if requested if install_jumpstarter: - # k3s always uses the operator; kind/minikube default to helm - if cluster_type == "k3s": - install_method = "operator" - elif install_method is None: - install_method = "helm" - # For k3s, derive IP from SSH host if not specified if cluster_type == "k3s" and ip is None and k3s_ssh_host is not None: ip = extract_host_from_ssh(k3s_ssh_host) @@ -207,39 +194,18 @@ async def create_cluster_and_install( # noqa: C901 Exception("Version must be specified when installing Jumpstarter"), ) - if install_method == "operator": - await install_jumpstarter_operator( - version=version, - namespace=namespace, - basedomain=actual_basedomain, - grpc_endpoint=actual_grpc, - router_endpoint=actual_router, - mode="nodeport", - kubeconfig=kubeconfig, - context=context, - operator_installer=operator_installer, - callback=callback, - ) - else: - if not helm_installed(helm): - raise ToolNotInstalledError("helm", f"helm is not installed (or not in your PATH): {helm}") - - await install_jumpstarter_helm_chart( - chart, - chart_name, - namespace, - actual_basedomain, - actual_grpc, - actual_router, - "nodeport", - version, - kubeconfig, - context, - helm, - actual_ip, - callback, - values_files, - ) + await install_jumpstarter_operator( + version=version, + namespace=namespace, + basedomain=actual_basedomain, + grpc_endpoint=actual_grpc, + router_endpoint=actual_router, + mode="nodeport", + kubeconfig=kubeconfig, + context=context, + operator_installer=operator_installer, + callback=callback, + ) async def create_cluster_only( diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py index 03ff3cd97..5e966fbdb 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py @@ -126,17 +126,15 @@ async def test_create_cluster_only_minikube(self, mock_create_and_install): class TestCreateClusterAndInstall: - """Test cluster creation with installation.""" + """Test cluster creation with operator installation.""" @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.operations.helm_installed") @patch("jumpstarter_kubernetes.cluster.operations.create_kind_cluster_with_options") @patch("jumpstarter_kubernetes.cluster.operations.configure_endpoints") - @patch("jumpstarter_kubernetes.cluster.operations.install_jumpstarter_helm_chart") + @patch("jumpstarter_kubernetes.cluster.operations.install_jumpstarter_operator") async def test_create_cluster_and_install_success( - self, mock_install, mock_configure, mock_create, mock_helm_installed + self, mock_install, mock_configure, mock_create ): - mock_helm_installed.return_value = True mock_configure.return_value = ("192.168.1.100", "test.domain", "grpc.test:8082", "router.test:8083") await create_cluster_and_install("kind", False, "test-cluster", "", "", "kind", "minikube", version="1.0.0") @@ -146,32 +144,12 @@ async def test_create_cluster_and_install_success( mock_install.assert_called_once() @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.operations.helm_installed") @patch("jumpstarter_kubernetes.cluster.operations.create_kind_cluster_with_options") @patch("jumpstarter_kubernetes.cluster.operations.configure_endpoints") - async def test_create_cluster_and_install_no_helm( - self, mock_configure, mock_create_wrapper, mock_helm_installed - ): - from jumpstarter_kubernetes.exceptions import ToolNotInstalledError - - mock_create_wrapper.return_value = None - mock_helm_installed.return_value = False - mock_configure.return_value = ("192.168.1.100", "test.domain", "grpc.test:8082", "router.test:8083") - - with pytest.raises(ToolNotInstalledError): - await create_cluster_and_install( - "kind", False, "test-cluster", "", "", "kind", "minikube", version="v0.1.0" - ) - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.cluster.operations.helm_installed") - @patch("jumpstarter_kubernetes.cluster.operations.create_kind_cluster_with_options") - @patch("jumpstarter_kubernetes.cluster.operations.configure_endpoints") - async def test_create_cluster_and_install_no_version(self, mock_configure, mock_create, mock_helm_installed): + async def test_create_cluster_and_install_no_version(self, mock_configure, mock_create): from jumpstarter_kubernetes.exceptions import ClusterOperationError mock_create.return_value = None - mock_helm_installed.return_value = True mock_configure.return_value = ("192.168.1.100", "test.domain", "grpc.test:8082", "router.test:8083") with pytest.raises(ClusterOperationError): diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py index ce56cd2c7..fbc2b4cbb 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py @@ -12,7 +12,7 @@ class JumpstarterKubernetesError(Exception): class ToolNotInstalledError(JumpstarterKubernetesError): - """Raised when a required tool (kind, minikube, helm, kubectl) is not installed.""" + """Raised when a required tool (kind, minikube, kubectl) is not installed.""" def __init__(self, tool_name: str, additional_info: str = ""): self.tool_name = tool_name diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py deleted file mode 100644 index 34514f9e5..000000000 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py +++ /dev/null @@ -1,91 +0,0 @@ -import asyncio -import shutil -from typing import Literal, Optional - - -def helm_installed(name: str) -> bool: - """Check if Helm is installed and available in the PATH.""" - return shutil.which(name) is not None - - -async def install_helm_chart( - chart: str, - name: str, - namespace: str, - basedomain: str, - grpc_endpoint: str, - router_endpoint: str, - mode: Literal["nodeport"] | Literal["ingress"] | Literal["route"], - version: str, - kubeconfig: Optional[str], - context: Optional[str], - helm: Optional[str] = "helm", - values_files: Optional[list[str]] = None, -): - args = [ - helm, - "upgrade", - name, - "--install", - chart, - "--create-namespace", - "--namespace", - namespace, - "--set", - f"global.baseDomain={basedomain}", - "--set", - f"jumpstarter-controller.grpc.endpoint={grpc_endpoint}", - "--set", - f"jumpstarter-controller.grpc.routerEndpoint={router_endpoint}", - "--set", - "global.metrics.enabled=false", - "--set", - f"jumpstarter-controller.grpc.nodeport.enabled={'true' if mode == 'nodeport' else 'false'}", - "--set", - f"jumpstarter-controller.grpc.mode={mode}", - "--version", - version, - "--wait", - ] - - if kubeconfig is not None: - args.append("--kubeconfig") - args.append(kubeconfig) - - if context is not None: - args.append("--kube-context") - args.append(context) - - if values_files is not None: - for values_file in values_files: - args.append("-f") - args.append(values_file) - - # Attempt to install Jumpstarter using Helm - process = await asyncio.create_subprocess_exec(*args) - await process.wait() - - -async def uninstall_helm_chart( - name: str, namespace: str, kubeconfig: Optional[str], context: Optional[str], helm: Optional[str] = "helm" -): - args = [ - helm, - "uninstall", - name, - "--namespace", - namespace, - "--wait", - ] - - if kubeconfig is not None: - args.append("--kubeconfig") - args.append(kubeconfig) - - if context is not None: - args.append("--kube-context") - args.append(context) - - # Attempt to install Jumpstarter using Helm - process = await asyncio.create_subprocess_exec(*args) - await process.wait() diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install_test.py deleted file mode 100644 index cf0668960..000000000 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install_test.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Tests for Helm installation functions.""" - -from unittest.mock import AsyncMock, patch - -import pytest - -from jumpstarter_kubernetes.install import helm_installed, install_helm_chart - - -class TestHelmInstalled: - """Test helm_installed function.""" - - @patch("jumpstarter_kubernetes.install.shutil.which") - def test_helm_installed_true(self, mock_which): - mock_which.return_value = "/usr/local/bin/helm" - assert helm_installed("helm") is True - mock_which.assert_called_once_with("helm") - - @patch("jumpstarter_kubernetes.install.shutil.which") - def test_helm_installed_false(self, mock_which): - mock_which.return_value = None - assert helm_installed("helm") is False - mock_which.assert_called_once_with("helm") - - @patch("jumpstarter_kubernetes.install.shutil.which") - def test_helm_installed_custom_path(self, mock_which): - mock_which.return_value = "/custom/path/helm" - assert helm_installed("/custom/path/helm") is True - mock_which.assert_called_once_with("/custom/path/helm") - - -class TestInstallHelmChart: - """Test install_helm_chart function.""" - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") - async def test_install_helm_chart_basic(self, mock_subprocess): - """Test basic helm chart installation without values files.""" - mock_process = AsyncMock() - mock_process.wait = AsyncMock(return_value=0) - mock_subprocess.return_value = mock_process - - await install_helm_chart( - chart="oci://quay.io/jumpstarter/helm", - name="jumpstarter", - namespace="jumpstarter-lab", - basedomain="jumpstarter.192.168.1.100.nip.io", - grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", - router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", - mode="nodeport", - version="1.0.0", - kubeconfig=None, - context=None, - helm="helm", - values_files=None, - ) - - # Verify the subprocess was called with correct arguments - args = mock_subprocess.call_args[0] - assert args[0] == "helm" - assert args[1] == "upgrade" - assert args[2] == "jumpstarter" - assert "--install" in args - assert "oci://quay.io/jumpstarter/helm" in args - assert "--namespace" in args - assert "jumpstarter-lab" in args - assert "--version" in args - assert "1.0.0" in args - assert "--wait" in args - - # Verify no -f flags when values_files is None - assert "-f" not in args - - mock_process.wait.assert_called_once() - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") - async def test_install_helm_chart_with_single_values_file(self, mock_subprocess): - """Test helm chart installation with a single values file.""" - mock_process = AsyncMock() - mock_process.wait = AsyncMock(return_value=0) - mock_subprocess.return_value = mock_process - - values_files = ["/path/to/values.yaml"] - - await install_helm_chart( - chart="oci://quay.io/jumpstarter/helm", - name="jumpstarter", - namespace="jumpstarter-lab", - basedomain="jumpstarter.192.168.1.100.nip.io", - grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", - router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", - mode="nodeport", - version="1.0.0", - kubeconfig=None, - context=None, - helm="helm", - values_files=values_files, - ) - - # Verify the subprocess was called with correct arguments including -f - args = mock_subprocess.call_args[0] - assert "-f" in args - f_index = args.index("-f") - assert args[f_index + 1] == "/path/to/values.yaml" - - mock_process.wait.assert_called_once() - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") - async def test_install_helm_chart_with_multiple_values_files(self, mock_subprocess): - """Test helm chart installation with multiple values files.""" - mock_process = AsyncMock() - mock_process.wait = AsyncMock(return_value=0) - mock_subprocess.return_value = mock_process - - values_files = ["/path/to/values.yaml", "/path/to/values.kind.yaml"] - - await install_helm_chart( - chart="oci://quay.io/jumpstarter/helm", - name="jumpstarter", - namespace="jumpstarter-lab", - basedomain="jumpstarter.192.168.1.100.nip.io", - grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", - router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", - mode="nodeport", - version="1.0.0", - kubeconfig=None, - context=None, - helm="helm", - values_files=values_files, - ) - - # Verify the subprocess was called with correct arguments including multiple -f flags - args = mock_subprocess.call_args[0] - - # Find all -f flags - f_indices = [i for i, arg in enumerate(args) if arg == "-f"] - assert len(f_indices) == 2 - - # Verify the values files are in the correct order - assert args[f_indices[0] + 1] == "/path/to/values.yaml" - assert args[f_indices[1] + 1] == "/path/to/values.kind.yaml" - - mock_process.wait.assert_called_once() - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") - async def test_install_helm_chart_with_kubeconfig_and_context(self, mock_subprocess): - """Test helm chart installation with kubeconfig and context.""" - mock_process = AsyncMock() - mock_process.wait = AsyncMock(return_value=0) - mock_subprocess.return_value = mock_process - - await install_helm_chart( - chart="oci://quay.io/jumpstarter/helm", - name="jumpstarter", - namespace="jumpstarter-lab", - basedomain="jumpstarter.192.168.1.100.nip.io", - grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", - router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", - mode="nodeport", - version="1.0.0", - kubeconfig="/path/to/kubeconfig", - context="test-context", - helm="helm", - values_files=None, - ) - - # Verify the subprocess was called with kubeconfig and context - args = mock_subprocess.call_args[0] - assert "--kubeconfig" in args - kubeconfig_index = args.index("--kubeconfig") - assert args[kubeconfig_index + 1] == "/path/to/kubeconfig" - - assert "--kube-context" in args - context_index = args.index("--kube-context") - assert args[context_index + 1] == "test-context" - - mock_process.wait.assert_called_once() - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") - async def test_install_helm_chart_with_all_options(self, mock_subprocess): - """Test helm chart installation with all options including values files, kubeconfig, and context.""" - mock_process = AsyncMock() - mock_process.wait = AsyncMock(return_value=0) - mock_subprocess.return_value = mock_process - - values_files = ["/path/to/values1.yaml", "/path/to/values2.yaml", "/path/to/values3.yaml"] - - await install_helm_chart( - chart="oci://quay.io/jumpstarter/helm", - name="jumpstarter", - namespace="jumpstarter-lab", - basedomain="jumpstarter.192.168.1.100.nip.io", - grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", - router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", - mode="ingress", - version="1.0.0", - kubeconfig="/path/to/kubeconfig", - context="prod-context", - helm="/usr/local/bin/helm", - values_files=values_files, - ) - - # Verify all options are present - args = mock_subprocess.call_args[0] - - # Check helm binary - assert args[0] == "/usr/local/bin/helm" - - # Check kubeconfig and context - assert "--kubeconfig" in args - assert "/path/to/kubeconfig" in args - assert "--kube-context" in args - assert "prod-context" in args - - # Check values files - f_indices = [i for i, arg in enumerate(args) if arg == "-f"] - assert len(f_indices) == 3 - assert args[f_indices[0] + 1] == "/path/to/values1.yaml" - assert args[f_indices[1] + 1] == "/path/to/values2.yaml" - assert args[f_indices[2] + 1] == "/path/to/values3.yaml" - - # Check mode - assert "jumpstarter-controller.grpc.mode=ingress" in args - - mock_process.wait.assert_called_once() - - @pytest.mark.asyncio - @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") - async def test_install_helm_chart_empty_values_files_list(self, mock_subprocess): - """Test helm chart installation with empty values files list.""" - mock_process = AsyncMock() - mock_process.wait = AsyncMock(return_value=0) - mock_subprocess.return_value = mock_process - - await install_helm_chart( - chart="oci://quay.io/jumpstarter/helm", - name="jumpstarter", - namespace="jumpstarter-lab", - basedomain="jumpstarter.192.168.1.100.nip.io", - grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", - router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", - mode="nodeport", - version="1.0.0", - kubeconfig=None, - context=None, - helm="helm", - values_files=[], - ) - - # Verify no -f flags when values_files is empty list - args = mock_subprocess.call_args[0] - assert "-f" not in args - - mock_process.wait.assert_called_once() From 982fbf9381fac013d3b43789b23e567fe1d2474c Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 9 Apr 2026 12:27:05 +0200 Subject: [PATCH 02/54] fix: propagate RuntimeError in check_jumpstarter_installation instead of swallowing Flatten nested try/except in check_jumpstarter_installation so that RuntimeError from run_command (e.g. kubectl not found, permission errors) propagates to the outer handler that properly reports the error. Previously the inner `except RuntimeError: pass` silently swallowed the error, making it impossible for callers to distinguish "not installed" from "failed to check". Generated-By: Forge/20260409_114748_239149_5e586097 Co-Authored-By: Claude Opus 4.6 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 46 +++++++++---------- .../cluster/kubectl_test.py | 2 + 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index d9e12804d..7a6a3957c 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -90,31 +90,27 @@ async def check_jumpstarter_installation( # noqa: C901 } try: - # Check for Jumpstarter CRDs - try: - crd_cmd = [kubectl, "--context", context, "get", "crd", "-o", "json"] - returncode, stdout, _ = await run_command(crd_cmd) - - if returncode == 0: - json_start = stdout.find("{") - if json_start >= 0: - json_output = stdout[json_start:] - crds = json.loads(json_output) - else: - crds = json.loads(stdout) - jumpstarter_crds = [] - for item in crds.get("items", []): - name = item.get("metadata", {}).get("name", "") - if "jumpstarter.dev" in name: - jumpstarter_crds.append(name) - - if jumpstarter_crds: - result_data["has_crds"] = True - result_data["installed"] = True - result_data["namespace"] = namespace or "unknown" - result_data["status"] = "installed" - except RuntimeError: - pass + crd_cmd = [kubectl, "--context", context, "get", "crd", "-o", "json"] + returncode, stdout, _ = await run_command(crd_cmd) + + if returncode == 0: + json_start = stdout.find("{") + if json_start >= 0: + json_output = stdout[json_start:] + crds = json.loads(json_output) + else: + crds = json.loads(stdout) + jumpstarter_crds = [] + for item in crds.get("items", []): + name = item.get("metadata", {}).get("name", "") + if "jumpstarter.dev" in name: + jumpstarter_crds.append(name) + + if jumpstarter_crds: + result_data["has_crds"] = True + result_data["installed"] = True + result_data["namespace"] = namespace or "unknown" + result_data["status"] = "installed" except json.JSONDecodeError as e: result_data["error"] = f"Failed to parse output: {e}" diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 5689869a6..439528c46 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -224,6 +224,8 @@ async def test_check_jumpstarter_installation_command_failure(self, mock_run_com assert result.installed is False assert result.has_crds is False + assert result.error is not None + assert "kubectl not found" in result.error @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") From 5703171936ce9e5d3ca903cd374147ac55e3bb49 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 9 Apr 2026 12:27:29 +0200 Subject: [PATCH 03/54] fix: remove stale noqa C901 complexity suppression from check_jumpstarter_installation After removing the nested Helm detection logic, the function complexity is well under the C901 threshold. The suppression comment is no longer needed. Generated-By: Forge/20260409_114748_239149_5e586097 Co-Authored-By: Claude Opus 4.6 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 7a6a3957c..10dd90a1d 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -72,7 +72,7 @@ async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[Dict[str, str]] raise KubeconfigError(f"Error listing kubectl contexts: {e}") from e -async def check_jumpstarter_installation( # noqa: C901 +async def check_jumpstarter_installation( context: str, namespace: Optional[str] = None, kubectl: str = "kubectl" ) -> V1Alpha1JumpstarterInstance: """Check if Jumpstarter is installed in the cluster using CRD detection.""" From 7d81dbd1c2df7de27f7eefb53523b581e02e878d Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 9 Apr 2026 12:27:41 +0200 Subject: [PATCH 04/54] fix: remove stale METHOD=helm references from root Makefile The Helm deployment method has been removed. Update the Makefile comment and help text to no longer advertise helm as a valid option. Generated-By: Forge/20260409_114748_239149_5e586097 Co-Authored-By: Claude Opus 4.6 --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 1dc17acbd..e34e7f629 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ # Subdirectories containing projects SUBDIRS := python protocol controller e2e -# Deployment method for e2e tests: operator (default) or helm +# Deployment method for e2e tests METHOD ?= operator # Default target @@ -33,8 +33,8 @@ help: @echo " make e2e-full - Full setup + run (for CI or first time)" @echo " make e2e-clean - Clean up e2e test environment (delete cluster, certs, etc.)" @echo "" - @echo " Use METHOD=operator (default) or METHOD=helm to select deployment method" - @echo " Example: make e2e-setup METHOD=helm" + @echo " Use METHOD=operator (default) to select deployment method" + @echo " Example: make e2e-setup METHOD=operator" @echo "" @echo "Per-project targets:" @echo " make build- - Build specific project" From 68b33701093ebf70612da2a26e29d4ee0af3fa1e Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 9 Apr 2026 12:28:12 +0200 Subject: [PATCH 05/54] fix: remove vestigial chart_name field from V1Alpha1JumpstarterInstance The chart_name/chartName field was only populated by the Helm detection code path which has been removed. The field was permanently None, making it dead structure in the API model. Generated-By: Forge/20260409_114748_239149_5e586097 Co-Authored-By: Claude Opus 4.6 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 1 - .../jumpstarter-kubernetes/jumpstarter_kubernetes/clusters.py | 1 - 2 files changed, 2 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 10dd90a1d..51157bae6 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -80,7 +80,6 @@ async def check_jumpstarter_installation( "installed": False, "version": None, "namespace": None, - "chart_name": None, "status": None, "has_crds": False, "error": None, diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters.py index 1031712af..bf7cf5e1b 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters.py @@ -14,7 +14,6 @@ class V1Alpha1JumpstarterInstance(JsonBaseModel): installed: bool version: Optional[str] = None namespace: Optional[str] = None - chart_name: Optional[str] = Field(alias="chartName", default=None) status: Optional[str] = None has_crds: bool = Field(alias="hasCrds", default=False) error: Optional[str] = None From 5bddf6bdb0b51667f872ed3ae198cf34d0965865 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 9 Apr 2026 12:28:20 +0200 Subject: [PATCH 06/54] fix: correct stale default version comment in compat setup script The comment said COMPAT_CONTROLLER_TAG defaults to v0.7.1 but the actual default on line 31 is v0.7.0. Generated-By: Forge/20260409_114748_239149_5e586097 Co-Authored-By: Claude Opus 4.6 --- e2e/compat/setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index 953edd0b8..b4c9deaf6 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -7,7 +7,7 @@ # # Environment variables: # COMPAT_SCENARIO - "old-controller" or "old-client" (required) -# COMPAT_CONTROLLER_TAG - Controller image tag for old-controller scenario (default: v0.7.1) +# COMPAT_CONTROLLER_TAG - Controller image tag for old-controller scenario (default: v0.7.0) # COMPAT_CLIENT_VERSION - PyPI version for old-client scenario (default: 0.7.1) set -euo pipefail From 8e6fc6d373ebf285fa3341ba094fc3f8d24a3a68 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 9 Apr 2026 12:29:13 +0200 Subject: [PATCH 07/54] fix: update version discovery to query operator image repo instead of Helm chart repo The Helm chart publishing CI job is being removed, so the function querying quay.io/jumpstarter-dev/helm/jumpstarter for version tags will return stale results. Switch to querying the operator image repository (jumpstarter-dev/jumpstarter-operator) which continues to receive new version tags with each release. Generated-By: Forge/20260409_114748_239149_5e586097 Co-Authored-By: Claude Opus 4.6 --- .../jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py index 6d532468c..1cb99fa27 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py @@ -29,7 +29,7 @@ async def get_latest_compatible_controller_version(client_version: Optional[str] ) as session: try: async with session.get( - "https://quay.io/api/v1/repository/jumpstarter-dev/helm/jumpstarter/tag/", + "https://quay.io/api/v1/repository/jumpstarter-dev/jumpstarter-operator/tag/", timeout=aiohttp.ClientTimeout(total=30), ) as resp: resp = await resp.json() From 1b338883cd952a09f9aaa218efe8d7d71e296646 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 9 Apr 2026 12:29:31 +0200 Subject: [PATCH 08/54] fix: fail fast when Jumpstarter CR baseDomain is not found in compat setup Instead of silently falling back to a default domain when the Jumpstarter CR cannot be found, fail immediately with an actionable error message. This prevents misleading test failures caused by connecting to the wrong endpoint. Generated-By: Forge/20260409_114748_239149_5e586097 Co-Authored-By: Claude Opus 4.6 --- e2e/compat/setup.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index b4c9deaf6..c9c948672 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -180,7 +180,13 @@ setup_test_environment() { # Get the controller endpoint from Jumpstarter CR export ENDPOINT - ENDPOINT="grpc.$(kubectl get jumpstarter -n "${JS_NAMESPACE}" jumpstarter -o jsonpath='{.spec.baseDomain}' 2>/dev/null || echo 'jumpstarter.127.0.0.1.nip.io'):8082" + local BASEDOMAIN + BASEDOMAIN=$(kubectl get jumpstarter -n "${JS_NAMESPACE}" jumpstarter -o jsonpath='{.spec.baseDomain}' 2>/dev/null) || true + if [ -z "${BASEDOMAIN}" ]; then + log_error "Failed to get baseDomain from Jumpstarter CR in namespace ${JS_NAMESPACE}. Is the controller deployed with a Jumpstarter CR?" + exit 1 + fi + ENDPOINT="grpc.${BASEDOMAIN}:8082" log_info "Controller endpoint: $ENDPOINT" From da45f155288cd4250525e2f078df4000bc734b76 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 9 Apr 2026 12:40:44 +0200 Subject: [PATCH 09/54] fix: remove vestigial METHOD variable from Makefiles Only one deployment method (operator) remains, so the METHOD variable is unnecessary indirection. Remove it from the root Makefile and the controller Makefile, and drop redundant METHOD=operator arguments from deploy-with-operator targets. Generated-By: Forge/20260409_114748_239149_5e586097 Co-Authored-By: Claude Opus 4.6 --- Makefile | 16 +++++----------- controller/Makefile | 6 ++---- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index e34e7f629..85cc645de 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,6 @@ # Subdirectories containing projects SUBDIRS := python protocol controller e2e -# Deployment method for e2e tests -METHOD ?= operator - # Default target .PHONY: all all: build @@ -33,9 +30,6 @@ help: @echo " make e2e-full - Full setup + run (for CI or first time)" @echo " make e2e-clean - Clean up e2e test environment (delete cluster, certs, etc.)" @echo "" - @echo " Use METHOD=operator (default) to select deployment method" - @echo " Example: make e2e-setup METHOD=operator" - @echo "" @echo "Per-project targets:" @echo " make build- - Build specific project" @echo " make test- - Test specific project" @@ -121,14 +115,14 @@ test-controller: # Setup e2e testing environment (one-time) .PHONY: e2e-setup e2e-setup: - @echo "Setting up e2e test environment (method: $(METHOD))..." - @METHOD=$(METHOD) bash e2e/setup-e2e.sh + @echo "Setting up e2e test environment..." + @bash e2e/setup-e2e.sh # Run e2e tests .PHONY: e2e-run e2e-run: - @echo "Running e2e tests (method: $(METHOD))..." - @METHOD=$(METHOD) bash e2e/run-e2e.sh + @echo "Running e2e tests..." + @bash e2e/run-e2e.sh # Convenience alias for running e2e tests .PHONY: e2e @@ -137,7 +131,7 @@ e2e: e2e-run # Full e2e setup + run .PHONY: e2e-full e2e-full: - @METHOD=$(METHOD) bash e2e/run-e2e.sh --full + @bash e2e/run-e2e.sh --full # Clean up e2e test environment .PHONY: e2e-clean diff --git a/controller/Makefile b/controller/Makefile index b7da7c872..33934b3f8 100644 --- a/controller/Makefile +++ b/controller/Makefile @@ -30,9 +30,6 @@ endif # tools. (i.e. podman) CONTAINER_TOOL ?= podman -# Deployment method -METHOD ?= operator - # Cluster type: kind (default) or k3s CLUSTER_TYPE ?= kind export CLUSTER_TYPE @@ -186,6 +183,7 @@ ifeq ($(SKIP_BUILD),) endif ./hack/deploy_with_operator.sh + .PHONY: deploy-operator deploy-operator: docker-build build-operator cluster grpcurl ## Deploy only the operator (without Jumpstarter CR) NETWORKING_MODE=ingress DEPLOY_JUMPSTARTER=false ./hack/deploy_with_operator.sh @@ -199,7 +197,7 @@ operator-logs: .PHONY: deploy-with-operator-parallel deploy-with-operator-parallel: - make deploy METHOD=operator -j5 --output-sync=target + make deploy -j5 --output-sync=target .PHONY: deploy-exporters deploy-exporters: From eb514ef2204388b3dae9669ce1170b9903a14714 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 9 Apr 2026 13:58:54 +0200 Subject: [PATCH 10/54] fix: remove vestigial METHOD deployment abstraction Since Helm support was removed, "operator" is the only deployment method. Remove the METHOD variable, its branching logic, and the parameterized print_deployment_success function throughout the e2e scripts, CI workflows, and hack utilities. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e.yaml | 2 -- controller/hack/deploy_with_operator.sh | 2 +- controller/hack/utils | 11 +---------- e2e/compat/setup.sh | 2 -- e2e/setup-e2e.sh | 7 +------ 5 files changed, 3 insertions(+), 21 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index e2c045e84..c33e58b07 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -231,7 +231,6 @@ jobs: run: make e2e-setup env: CI: true - METHOD: operator SKIP_BUILD: "true" PREBUILT_WHEELS_DIR: /tmp/python-wheels OPERATOR_IMG: quay.io/jumpstarter-dev/jumpstarter-operator:latest @@ -240,7 +239,6 @@ jobs: run: make e2e-run env: CI: true - METHOD: operator # ============================================================================ # Compatibility tests: cross-version interop between controller and client/exporter diff --git a/controller/hack/deploy_with_operator.sh b/controller/hack/deploy_with_operator.sh index 2f3e60ae6..9df80c3ff 100755 --- a/controller/hack/deploy_with_operator.sh +++ b/controller/hack/deploy_with_operator.sh @@ -217,5 +217,5 @@ wait_for_jumpstarter_resources check_grpc_endpoints # Print success banner -print_deployment_success "operator" +print_deployment_success diff --git a/controller/hack/utils b/controller/hack/utils index a0c55ff4e..0b142398c 100755 --- a/controller/hack/utils +++ b/controller/hack/utils @@ -251,17 +251,8 @@ check_grpc_endpoints() { } # Print deployment success banner -# Args: -# $1: deployment method (e.g., "operator") - optional print_deployment_success() { - local method=${1:-""} - local method_text="" - - if [ -n "${method}" ]; then - method_text=" via ${method}" - fi - - echo -e "${GREEN}Jumpstarter controller deployed successfully${method_text}!${NC}" + echo -e "${GREEN}Jumpstarter controller deployed successfully!${NC}" echo -e " gRPC endpoint: ${GRPC_ENDPOINT}" echo -e " gRPC router endpoint: ${GRPC_ROUTER_ENDPOINT}" } diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index c9c948672..85306ac6c 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -24,8 +24,6 @@ REPO_ROOT="$(cd "$E2E_DIR/.." && pwd)" # Default namespace for tests export JS_NAMESPACE="${JS_NAMESPACE:-jumpstarter-lab}" -export METHOD="operator" - # Scenario configuration COMPAT_SCENARIO="${COMPAT_SCENARIO:-old-controller}" COMPAT_CONTROLLER_TAG="${COMPAT_CONTROLLER_TAG:-v0.7.0}" diff --git a/e2e/setup-e2e.sh b/e2e/setup-e2e.sh index 0579e56da..8df7e5ad6 100755 --- a/e2e/setup-e2e.sh +++ b/e2e/setup-e2e.sh @@ -13,9 +13,6 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # Default namespace for tests export JS_NAMESPACE="${JS_NAMESPACE:-jumpstarter-lab}" -# Deployment method -export METHOD="${METHOD:-operator}" - # Color output RED='\033[0;31m' GREEN='\033[0;32m' @@ -262,7 +259,7 @@ deploy_dex() { # Step 4: Deploy jumpstarter controller deploy_controller() { - log_info "Deploying jumpstarter controller (method: $METHOD)..." + log_info "Deploying jumpstarter controller..." cd "$REPO_ROOT" @@ -312,7 +309,6 @@ setup_test_environment() { echo "E2E_TEST_NS=$JS_NAMESPACE" >> "$REPO_ROOT/.e2e-setup-complete" echo "REPO_ROOT=$REPO_ROOT" >> "$REPO_ROOT/.e2e-setup-complete" echo "SCRIPT_DIR=$SCRIPT_DIR" >> "$REPO_ROOT/.e2e-setup-complete" - # Set SSL certificate paths for Python to use the generated CA echo "SSL_CERT_FILE=$REPO_ROOT/ca.pem" >> "$REPO_ROOT/.e2e-setup-complete" echo "REQUESTS_CA_BUNDLE=$REPO_ROOT/ca.pem" >> "$REPO_ROOT/.e2e-setup-complete" @@ -327,7 +323,6 @@ setup_test_environment() { main() { log_info "=== Jumpstarter E2E Setup ===" log_info "Namespace: $JS_NAMESPACE" - log_info "Deployment Method: $METHOD" log_info "Repository Root: $REPO_ROOT" log_info "Script Directory: $SCRIPT_DIR" echo "" From 4fbbf9467353f4f748c79464360432e1ea1c845d Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 9 Apr 2026 14:49:52 +0200 Subject: [PATCH 11/54] fix: remove old-controller compat test (v0.7.0 has no operator installer) v0.7.0 predates the monorepo merge and was only deployable via OCI Helm chart. With Helm fully removed, the old-controller compat test cannot deploy a v0.7.0 controller. Remove the test, its CI job, and simplify the compat infrastructure to only support the old-client scenario. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e.yaml | 46 +----- Makefile | 12 +- e2e/compat/run.sh | 11 +- e2e/compat/setup.sh | 88 ++---------- e2e/test/compat_old_controller_test.go | 192 ------------------------- 5 files changed, 20 insertions(+), 329 deletions(-) delete mode 100644 e2e/test/compat_old_controller_test.go diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index c33e58b07..f71106644 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -240,46 +240,6 @@ jobs: env: CI: true - # ============================================================================ - # Compatibility tests: cross-version interop between controller and client/exporter - # These jobs can be removed once 0.7.x controller support is no longer needed. - # ============================================================================ - - e2e-compat-old-controller: - needs: [changes, build-python-wheels] - if: needs.changes.outputs.should_run == 'true' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v7 - - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: "1.22" - - - name: Download python wheels - uses: actions/download-artifact@v4 - with: - name: python-wheels - path: /tmp/python-wheels - - - name: Setup compat environment (old controller v0.7.0) - run: make e2e-compat-setup COMPAT_SCENARIO=old-controller - env: - CI: true - COMPAT_CONTROLLER_TAG: v0.7.0 - PREBUILT_WHEELS_DIR: /tmp/python-wheels - - - name: Run compat tests (old controller + new client/exporter) - run: make e2e-compat-run COMPAT_TEST=old-controller - env: - CI: true - e2e-compat-old-client: needs: [changes, build-controller-image, build-python-wheels] if: needs.changes.outputs.should_run == 'true' || github.event_name == 'workflow_dispatch' @@ -312,8 +272,8 @@ jobs: - name: Load controller image run: docker load < /tmp/artifacts/controller-image.tar - - name: Setup compat environment (old client v0.7.0) - run: make e2e-compat-setup COMPAT_SCENARIO=old-client + - name: Setup compat environment (old client v0.7.1) + run: make e2e-compat-setup env: CI: true COMPAT_CLIENT_VERSION: "0.7.1" @@ -321,6 +281,6 @@ jobs: PREBUILT_WHEELS_DIR: /tmp/python-wheels - name: Run compat tests (new controller + old client/exporter) - run: make e2e-compat-run COMPAT_TEST=old-client + run: make e2e-compat-run env: CI: true diff --git a/Makefile b/Makefile index 85cc645de..f39f541b0 100644 --- a/Makefile +++ b/Makefile @@ -164,21 +164,17 @@ e2e-clean: test-e2e: e2e-run # Compatibility E2E testing (cross-version tests, separate from main e2e) -COMPAT_SCENARIO ?= old-controller -COMPAT_TEST ?= old-controller -COMPAT_CONTROLLER_TAG ?= v0.7.0 COMPAT_CLIENT_VERSION ?= 0.7.1 .PHONY: e2e-compat-setup e2e-compat-setup: - @echo "Setting up compat e2e (scenario: $(COMPAT_SCENARIO))..." - @COMPAT_SCENARIO=$(COMPAT_SCENARIO) COMPAT_CONTROLLER_TAG=$(COMPAT_CONTROLLER_TAG) \ - COMPAT_CLIENT_VERSION=$(COMPAT_CLIENT_VERSION) bash e2e/compat/setup.sh + @echo "Setting up compat e2e (old client v$(COMPAT_CLIENT_VERSION))..." + @COMPAT_CLIENT_VERSION=$(COMPAT_CLIENT_VERSION) bash e2e/compat/setup.sh .PHONY: e2e-compat-run e2e-compat-run: - @echo "Running compat e2e (test: $(COMPAT_TEST))..." - @COMPAT_TEST=$(COMPAT_TEST) bash e2e/compat/run.sh + @echo "Running compat e2e (old client)..." + @COMPAT_TEST=old-client bash e2e/compat/run.sh # Per-project clean targets .PHONY: clean-python clean-protocol clean-controller clean-e2e diff --git a/e2e/compat/run.sh b/e2e/compat/run.sh index 741c21b63..301feea83 100755 --- a/e2e/compat/run.sh +++ b/e2e/compat/run.sh @@ -3,7 +3,7 @@ # This script runs the compatibility test suite (assumes setup.sh was run first) # # Environment variables: -# COMPAT_TEST - Which test to run: "old-controller" or "old-client" +# COMPAT_TEST - Which test to run (default: "old-client") set -euo pipefail @@ -48,19 +48,16 @@ run_tests() { # Use insecure GRPC for testing export JUMPSTARTER_GRPC_INSECURE=1 - COMPAT_TEST="${COMPAT_TEST:-old-controller}" + COMPAT_TEST="${COMPAT_TEST:-old-client}" log_info "Running compat test: $COMPAT_TEST" local label_filter="" case "$COMPAT_TEST" in - old-controller) - label_filter="old-controller" - ;; old-client) label_filter="old-client" ;; *) - log_error "Unknown COMPAT_TEST: $COMPAT_TEST (expected 'old-controller' or 'old-client')" + log_error "Unknown COMPAT_TEST: $COMPAT_TEST (expected 'old-client')" exit 1 ;; esac @@ -73,7 +70,7 @@ main() { export E2E_TEST_NS="${E2E_TEST_NS:-jumpstarter-lab}" log_info "=== Jumpstarter Compatibility E2E Test Runner ===" - log_info "Test: ${COMPAT_TEST:-old-controller}" + log_info "Test: ${COMPAT_TEST:-old-client}" log_info "Namespace: $E2E_TEST_NS" log_info "Repository Root: $REPO_ROOT" echo "" diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index 85306ac6c..6444ce711 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -6,8 +6,6 @@ # Uses operator-based deployment. # # Environment variables: -# COMPAT_SCENARIO - "old-controller" or "old-client" (required) -# COMPAT_CONTROLLER_TAG - Controller image tag for old-controller scenario (default: v0.7.0) # COMPAT_CLIENT_VERSION - PyPI version for old-client scenario (default: 0.7.1) set -euo pipefail @@ -24,9 +22,6 @@ REPO_ROOT="$(cd "$E2E_DIR/.." && pwd)" # Default namespace for tests export JS_NAMESPACE="${JS_NAMESPACE:-jumpstarter-lab}" -# Scenario configuration -COMPAT_SCENARIO="${COMPAT_SCENARIO:-old-controller}" -COMPAT_CONTROLLER_TAG="${COMPAT_CONTROLLER_TAG:-v0.7.0}" COMPAT_CLIENT_VERSION="${COMPAT_CLIENT_VERSION:-0.7.1}" # Color output @@ -68,17 +63,6 @@ install_dependencies() { log_info "Dependencies installed" } -# Get the external IP for baseDomain -get_external_ip() { - if which ip 2>/dev/null 1>/dev/null; then - ip route get 1.1.1.1 | grep -oP 'src \K\S+' - else - local INTERFACE - INTERFACE=$(route get 1.1.1.1 | grep interface | awk '{print $2}') - ifconfig | grep "$INTERFACE" -A 10 | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -n 1 - fi -} - # Create kind cluster and install grpcurl create_cluster() { log_info "Creating kind cluster..." @@ -89,46 +73,6 @@ create_cluster() { log_info "Kind cluster created" } -deploy_old_controller() { - log_info "Deploying old controller (version: $COMPAT_CONTROLLER_TAG)..." - - cd "$REPO_ROOT" - - # Compute networking variables - local IP - IP=$(get_external_ip) - BASEDOMAIN="jumpstarter.${IP}.nip.io" - GRPC_ENDPOINT="grpc.${BASEDOMAIN}:8082" - GRPC_ROUTER_ENDPOINT="router.${BASEDOMAIN}:8083" - - kubectl config use-context kind-jumpstarter - - # Install old controller using operator installer from the release tag - local INSTALLER_URL="https://raw.githubusercontent.com/jumpstarter-dev/jumpstarter/${COMPAT_CONTROLLER_TAG}/controller/deploy/operator/dist/install.yaml" - log_info "Installing old controller via operator (version: ${COMPAT_CONTROLLER_TAG})..." - kubectl apply -f "${INSTALLER_URL}" || log_warn "Operator installer may not be available for ${COMPAT_CONTROLLER_TAG}, skipping" - - kubectl config set-context --current --namespace=jumpstarter-lab - - # Wait for gRPC endpoints - local GRPCURL="${REPO_ROOT}/controller/bin/grpcurl" - log_info "Waiting for gRPC endpoints..." - for ep in ${GRPC_ENDPOINT} ${GRPC_ROUTER_ENDPOINT}; do - local retries=60 - log_info " Checking ${ep}..." - while ! ${GRPCURL} -insecure "${ep}" list > /dev/null 2>&1; do - sleep 2 - retries=$((retries - 1)) - if [ ${retries} -eq 0 ]; then - log_error "${ep} not ready after 120s" - exit 1 - fi - done - done - - log_info "Old controller deployed" -} - deploy_new_controller() { log_info "Deploying new controller from HEAD..." @@ -211,7 +155,7 @@ EOF # Main execution main() { log_info "=== Jumpstarter Compatibility E2E Setup ===" - log_info "Scenario: $COMPAT_SCENARIO" + log_info "Scenario: New Controller + Old Client/Exporter ($COMPAT_CLIENT_VERSION)" log_info "Namespace: $JS_NAMESPACE" log_info "Repository Root: $REPO_ROOT" echo "" @@ -222,34 +166,20 @@ main() { create_cluster echo "" - case "$COMPAT_SCENARIO" in - old-controller) - log_info "Scenario: Old Controller ($COMPAT_CONTROLLER_TAG) + New Client/Exporter" - deploy_old_controller - echo "" - install_jumpstarter - ;; - old-client) - log_info "Scenario: New Controller + Old Client/Exporter ($COMPAT_CLIENT_VERSION)" - deploy_new_controller - echo "" - install_jumpstarter - echo "" - install_old_client - ;; - *) - log_error "Unknown COMPAT_SCENARIO: $COMPAT_SCENARIO (expected 'old-controller' or 'old-client')" - exit 1 - ;; - esac + deploy_new_controller + echo "" + + install_jumpstarter + echo "" + + install_old_client echo "" setup_test_environment echo "" log_info "=== Compat setup complete! ===" - log_info "Scenario: $COMPAT_SCENARIO" - log_info "To run tests: make e2e-compat-run COMPAT_TEST=" + log_info "To run tests: make e2e-compat-run COMPAT_TEST=old-client" } main "$@" diff --git a/e2e/test/compat_old_controller_test.go b/e2e/test/compat_old_controller_test.go deleted file mode 100644 index 3664ff53b..000000000 --- a/e2e/test/compat_old_controller_test.go +++ /dev/null @@ -1,192 +0,0 @@ -/* -Copyright 2026. The Jumpstarter Authors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package e2e - -import ( - "os/exec" - "path/filepath" - "syscall" - "time" - - . "github.com/onsi/ginkgo/v2" //nolint:revive - . "github.com/onsi/gomega" //nolint:revive -) - -var _ = Describe("Compat: Old Controller E2E Tests", Label("compat", "old-controller"), Ordered, func() { - var ( - tracker *ProcessTracker - ns string - ) - - waitForCompatExporter := func() { - time.Sleep(exporterPostDelay) - MustKubectl("-n", ns, "wait", "--timeout", "5m", - "--for=condition=Online", "--for=condition=Registered", - "exporters.jumpstarter.dev/compat-exporter") - } - - BeforeAll(func() { - tracker = NewProcessTracker() - ns = Namespace() - }) - - AfterAll(func() { - if tracker != nil { - tracker.StopAll() - } - - _ = exec.Command("pkill", "-9", "-f", "jmp run --exporter compat-").Run() - - _, _ = Jmp("admin", "delete", "client", "--namespace", ns, "compat-client", "--delete") - _, _ = Jmp("admin", "delete", "exporter", "--namespace", ns, "compat-exporter", "--delete") - _, _ = Jmp("admin", "delete", "client", "--namespace", ns, "compat-client-wait", "--delete") - _, _ = Jmp("admin", "delete", "exporter", "--namespace", ns, "compat-exporter-wait", "--delete") - - if tracker != nil { - tracker.Cleanup() - } - }) - - // --- Core compatibility --- - Context("Core compatibility", func() { - It("can create client with admin cli", func() { - out, err := Jmp("admin", "create", "client", "-n", ns, "compat-client", - "--unsafe", "--save") - Expect(err).NotTo(HaveOccurred(), out) - }) - - It("can create exporter with admin cli", func() { - out, err := Jmp("admin", "create", "exporter", "-n", ns, "compat-exporter", - "--save", "--label", "example.com/board=compat") - Expect(err).NotTo(HaveOccurred(), out) - - overlayPath := filepath.Join(RepoRoot(), "e2e", "exporters", "exporter.yaml") - MergeExporterConfig("/etc/jumpstarter/exporters/compat-exporter.yaml", overlayPath) - }) - - It("new exporter registers with old controller", func() { - tracker.StartExporterLoop("compat-exporter") - waitForCompatExporter() - }) - - It("exporter shows as Online and Registered", func() { - waitForCompatExporter() - - out := MustKubectl("-n", ns, "get", "exporters.jumpstarter.dev/compat-exporter", - "-o", `jsonpath={.status.conditions[?(@.type=="Online")].status}`) - Expect(out).To(Equal("True")) - - out = MustKubectl("-n", ns, "get", "exporters.jumpstarter.dev/compat-exporter", - "-o", `jsonpath={.status.conditions[?(@.type=="Registered")].status}`) - Expect(out).To(Equal("True")) - }) - - It("new client can lease and connect through old controller", func() { - waitForCompatExporter() - out, err := Jmp("shell", "--client", "compat-client", - "--selector", "example.com/board=compat", "j", "power", "on") - Expect(err).NotTo(HaveOccurred(), out) - }) - - It("can operate on leases through old controller", func() { - waitForCompatExporter() - - MustJmp("config", "client", "use", "compat-client") - MustJmp("create", "lease", "--selector", "example.com/board=compat", "--duration", "1d") - MustJmp("get", "leases") - MustJmp("get", "exporters") - - out, err := Jmp("get", "leases", "--selector", "example.com/board=compat", "-o", "yaml") - Expect(err).NotTo(HaveOccurred(), out) - Expect(out).To(ContainSubstring("example.com/board=compat")) - - out, err = Jmp("get", "leases", "--selector", "example.com/board=doesnotexist") - Expect(err).NotTo(HaveOccurred(), out) - Expect(out).To(Equal("No resources found.")) - - MustJmp("delete", "leases", "--all") - }) - - It("exporter stays Online after lease cycle", func() { - waitForCompatExporter() - - out := MustKubectl("-n", ns, "get", "exporters.jumpstarter.dev/compat-exporter", - "-o", `jsonpath={.status.conditions[?(@.type=="Online")].status}`) - Expect(out).To(Equal("True")) - - out = MustKubectl("-n", ns, "get", "exporters.jumpstarter.dev/compat-exporter", - "-o", `jsonpath={.status.conditions[?(@.type=="Registered")].status}`) - Expect(out).To(Equal("True")) - }) - }) - - // --- Client started before exporter --- - Context("Client started before exporter", func() { - It("client started before exporter connects", func() { - _ = exec.Command("pkill", "-9", "-f", "jmp run --exporter compat-exporter-wait").Run() - time.Sleep(3 * time.Second) - - _, _ = Jmp("admin", "delete", "exporter", "--namespace", ns, "compat-exporter-wait", "--delete") - _, _ = Jmp("admin", "delete", "client", "--namespace", ns, "compat-client-wait", "--delete") - - out, err := Jmp("admin", "create", "client", "-n", ns, "compat-client-wait", - "--unsafe", "--save") - Expect(err).NotTo(HaveOccurred(), out) - - out, err = Jmp("admin", "create", "exporter", "-n", ns, "compat-exporter-wait", - "--save", "--label", "example.com/board=compat-wait") - Expect(err).NotTo(HaveOccurred(), out) - - overlayPath := filepath.Join(RepoRoot(), "e2e", "exporters", "exporter.yaml") - MergeExporterConfig("/etc/jumpstarter/exporters/compat-exporter-wait.yaml", overlayPath) - - // Start client BEFORE exporter - clientCmd := JmpCmd("shell", "--client", "compat-client-wait", - "--selector", "example.com/board=compat-wait", "j", "power", "on") - clientCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - err = clientCmd.Start() - Expect(err).NotTo(HaveOccurred()) - - time.Sleep(5 * time.Second) - Expect(clientCmd.ProcessState).To(BeNil(), "Client exited before exporter was started") - - // Now start the exporter - tracker.StartExporterLoop("compat-exporter-wait") - - done := make(chan error, 1) - go func() { done <- clientCmd.Wait() }() - - select { - case err := <-done: - Expect(err).NotTo(HaveOccurred(), "client shell failed") - case <-time.After(120 * time.Second): - _ = clientCmd.Process.Kill() - Fail("Client shell timed out waiting for exporter (120s)") - } - }) - }) - - // --- Cleanup --- - Context("Cleanup", func() { - It("cleans up resources", func() { - _, _ = Jmp("admin", "delete", "client", "--namespace", ns, "compat-client", "--delete") - _, _ = Jmp("admin", "delete", "exporter", "--namespace", ns, "compat-exporter", "--delete") - _, _ = Jmp("admin", "delete", "client", "--namespace", ns, "compat-client-wait", "--delete") - _, _ = Jmp("admin", "delete", "exporter", "--namespace", ns, "compat-exporter-wait", "--delete") - }) - }) -}) From 0be94806914b398b730ccd0dd69a26b7345bb9fc Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Sat, 11 Apr 2026 09:12:26 +0200 Subject: [PATCH 12/54] Revert "fix: remove old-controller compat test (v0.7.0 has no operator installer)" This reverts commit fa102b379ca0353498ecf7b536ba71ed3c107924. --- .github/workflows/e2e.yaml | 37 +++- Makefile | 12 +- e2e/compat/run.sh | 11 +- e2e/compat/setup.sh | 88 +++++++++- e2e/compat/tests-old-controller.bats | 232 +++++++++++++++++++++++++ e2e/test/compat_old_controller_test.go | 192 ++++++++++++++++++++ 6 files changed, 553 insertions(+), 19 deletions(-) create mode 100644 e2e/compat/tests-old-controller.bats create mode 100644 e2e/test/compat_old_controller_test.go diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index f71106644..05e8f7125 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -240,6 +240,39 @@ jobs: env: CI: true + # ============================================================================ + # Compatibility tests: cross-version interop between controller and client/exporter + # These jobs can be removed once 0.7.x controller support is no longer needed. + # ============================================================================ + + e2e-compat-old-controller: + needs: changes + if: needs.changes.outputs.should_run == 'true' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Setup compat environment (old controller v0.7.0) + run: make e2e-compat-setup COMPAT_SCENARIO=old-controller + env: + CI: true + COMPAT_CONTROLLER_TAG: v0.7.0 + + - name: Run compat tests (old controller + new client/exporter) + run: make e2e-compat-run COMPAT_TEST=old-controller + env: + CI: true + e2e-compat-old-client: needs: [changes, build-controller-image, build-python-wheels] if: needs.changes.outputs.should_run == 'true' || github.event_name == 'workflow_dispatch' @@ -273,7 +306,7 @@ jobs: run: docker load < /tmp/artifacts/controller-image.tar - name: Setup compat environment (old client v0.7.1) - run: make e2e-compat-setup + run: make e2e-compat-setup COMPAT_SCENARIO=old-client env: CI: true COMPAT_CLIENT_VERSION: "0.7.1" @@ -281,6 +314,6 @@ jobs: PREBUILT_WHEELS_DIR: /tmp/python-wheels - name: Run compat tests (new controller + old client/exporter) - run: make e2e-compat-run + run: make e2e-compat-run COMPAT_TEST=old-client env: CI: true diff --git a/Makefile b/Makefile index f39f541b0..85cc645de 100644 --- a/Makefile +++ b/Makefile @@ -164,17 +164,21 @@ e2e-clean: test-e2e: e2e-run # Compatibility E2E testing (cross-version tests, separate from main e2e) +COMPAT_SCENARIO ?= old-controller +COMPAT_TEST ?= old-controller +COMPAT_CONTROLLER_TAG ?= v0.7.0 COMPAT_CLIENT_VERSION ?= 0.7.1 .PHONY: e2e-compat-setup e2e-compat-setup: - @echo "Setting up compat e2e (old client v$(COMPAT_CLIENT_VERSION))..." - @COMPAT_CLIENT_VERSION=$(COMPAT_CLIENT_VERSION) bash e2e/compat/setup.sh + @echo "Setting up compat e2e (scenario: $(COMPAT_SCENARIO))..." + @COMPAT_SCENARIO=$(COMPAT_SCENARIO) COMPAT_CONTROLLER_TAG=$(COMPAT_CONTROLLER_TAG) \ + COMPAT_CLIENT_VERSION=$(COMPAT_CLIENT_VERSION) bash e2e/compat/setup.sh .PHONY: e2e-compat-run e2e-compat-run: - @echo "Running compat e2e (old client)..." - @COMPAT_TEST=old-client bash e2e/compat/run.sh + @echo "Running compat e2e (test: $(COMPAT_TEST))..." + @COMPAT_TEST=$(COMPAT_TEST) bash e2e/compat/run.sh # Per-project clean targets .PHONY: clean-python clean-protocol clean-controller clean-e2e diff --git a/e2e/compat/run.sh b/e2e/compat/run.sh index 301feea83..741c21b63 100755 --- a/e2e/compat/run.sh +++ b/e2e/compat/run.sh @@ -3,7 +3,7 @@ # This script runs the compatibility test suite (assumes setup.sh was run first) # # Environment variables: -# COMPAT_TEST - Which test to run (default: "old-client") +# COMPAT_TEST - Which test to run: "old-controller" or "old-client" set -euo pipefail @@ -48,16 +48,19 @@ run_tests() { # Use insecure GRPC for testing export JUMPSTARTER_GRPC_INSECURE=1 - COMPAT_TEST="${COMPAT_TEST:-old-client}" + COMPAT_TEST="${COMPAT_TEST:-old-controller}" log_info "Running compat test: $COMPAT_TEST" local label_filter="" case "$COMPAT_TEST" in + old-controller) + label_filter="old-controller" + ;; old-client) label_filter="old-client" ;; *) - log_error "Unknown COMPAT_TEST: $COMPAT_TEST (expected 'old-client')" + log_error "Unknown COMPAT_TEST: $COMPAT_TEST (expected 'old-controller' or 'old-client')" exit 1 ;; esac @@ -70,7 +73,7 @@ main() { export E2E_TEST_NS="${E2E_TEST_NS:-jumpstarter-lab}" log_info "=== Jumpstarter Compatibility E2E Test Runner ===" - log_info "Test: ${COMPAT_TEST:-old-client}" + log_info "Test: ${COMPAT_TEST:-old-controller}" log_info "Namespace: $E2E_TEST_NS" log_info "Repository Root: $REPO_ROOT" echo "" diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index 6444ce711..85306ac6c 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -6,6 +6,8 @@ # Uses operator-based deployment. # # Environment variables: +# COMPAT_SCENARIO - "old-controller" or "old-client" (required) +# COMPAT_CONTROLLER_TAG - Controller image tag for old-controller scenario (default: v0.7.0) # COMPAT_CLIENT_VERSION - PyPI version for old-client scenario (default: 0.7.1) set -euo pipefail @@ -22,6 +24,9 @@ REPO_ROOT="$(cd "$E2E_DIR/.." && pwd)" # Default namespace for tests export JS_NAMESPACE="${JS_NAMESPACE:-jumpstarter-lab}" +# Scenario configuration +COMPAT_SCENARIO="${COMPAT_SCENARIO:-old-controller}" +COMPAT_CONTROLLER_TAG="${COMPAT_CONTROLLER_TAG:-v0.7.0}" COMPAT_CLIENT_VERSION="${COMPAT_CLIENT_VERSION:-0.7.1}" # Color output @@ -63,6 +68,17 @@ install_dependencies() { log_info "Dependencies installed" } +# Get the external IP for baseDomain +get_external_ip() { + if which ip 2>/dev/null 1>/dev/null; then + ip route get 1.1.1.1 | grep -oP 'src \K\S+' + else + local INTERFACE + INTERFACE=$(route get 1.1.1.1 | grep interface | awk '{print $2}') + ifconfig | grep "$INTERFACE" -A 10 | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -n 1 + fi +} + # Create kind cluster and install grpcurl create_cluster() { log_info "Creating kind cluster..." @@ -73,6 +89,46 @@ create_cluster() { log_info "Kind cluster created" } +deploy_old_controller() { + log_info "Deploying old controller (version: $COMPAT_CONTROLLER_TAG)..." + + cd "$REPO_ROOT" + + # Compute networking variables + local IP + IP=$(get_external_ip) + BASEDOMAIN="jumpstarter.${IP}.nip.io" + GRPC_ENDPOINT="grpc.${BASEDOMAIN}:8082" + GRPC_ROUTER_ENDPOINT="router.${BASEDOMAIN}:8083" + + kubectl config use-context kind-jumpstarter + + # Install old controller using operator installer from the release tag + local INSTALLER_URL="https://raw.githubusercontent.com/jumpstarter-dev/jumpstarter/${COMPAT_CONTROLLER_TAG}/controller/deploy/operator/dist/install.yaml" + log_info "Installing old controller via operator (version: ${COMPAT_CONTROLLER_TAG})..." + kubectl apply -f "${INSTALLER_URL}" || log_warn "Operator installer may not be available for ${COMPAT_CONTROLLER_TAG}, skipping" + + kubectl config set-context --current --namespace=jumpstarter-lab + + # Wait for gRPC endpoints + local GRPCURL="${REPO_ROOT}/controller/bin/grpcurl" + log_info "Waiting for gRPC endpoints..." + for ep in ${GRPC_ENDPOINT} ${GRPC_ROUTER_ENDPOINT}; do + local retries=60 + log_info " Checking ${ep}..." + while ! ${GRPCURL} -insecure "${ep}" list > /dev/null 2>&1; do + sleep 2 + retries=$((retries - 1)) + if [ ${retries} -eq 0 ]; then + log_error "${ep} not ready after 120s" + exit 1 + fi + done + done + + log_info "Old controller deployed" +} + deploy_new_controller() { log_info "Deploying new controller from HEAD..." @@ -155,7 +211,7 @@ EOF # Main execution main() { log_info "=== Jumpstarter Compatibility E2E Setup ===" - log_info "Scenario: New Controller + Old Client/Exporter ($COMPAT_CLIENT_VERSION)" + log_info "Scenario: $COMPAT_SCENARIO" log_info "Namespace: $JS_NAMESPACE" log_info "Repository Root: $REPO_ROOT" echo "" @@ -166,20 +222,34 @@ main() { create_cluster echo "" - deploy_new_controller - echo "" - - install_jumpstarter - echo "" - - install_old_client + case "$COMPAT_SCENARIO" in + old-controller) + log_info "Scenario: Old Controller ($COMPAT_CONTROLLER_TAG) + New Client/Exporter" + deploy_old_controller + echo "" + install_jumpstarter + ;; + old-client) + log_info "Scenario: New Controller + Old Client/Exporter ($COMPAT_CLIENT_VERSION)" + deploy_new_controller + echo "" + install_jumpstarter + echo "" + install_old_client + ;; + *) + log_error "Unknown COMPAT_SCENARIO: $COMPAT_SCENARIO (expected 'old-controller' or 'old-client')" + exit 1 + ;; + esac echo "" setup_test_environment echo "" log_info "=== Compat setup complete! ===" - log_info "To run tests: make e2e-compat-run COMPAT_TEST=old-client" + log_info "Scenario: $COMPAT_SCENARIO" + log_info "To run tests: make e2e-compat-run COMPAT_TEST=" } main "$@" diff --git a/e2e/compat/tests-old-controller.bats b/e2e/compat/tests-old-controller.bats new file mode 100644 index 000000000..0fc46197e --- /dev/null +++ b/e2e/compat/tests-old-controller.bats @@ -0,0 +1,232 @@ +#!/usr/bin/env bats +# Compatibility E2E tests: Old Controller (v0.7.x) + New Client/Exporter +# +# Tests that the new client and exporter code works correctly against +# an older controller version that doesn't support hooks protocol changes. +# Also includes the client-started-before-exporter scenario. + +JS_NAMESPACE="${JS_NAMESPACE:-jumpstarter-lab}" + +# File to track bash wrapper process PIDs across tests +COMPAT_PIDS_FILE="${BATS_RUN_TMPDIR:-/tmp}/compat_old_ctrl_pids.txt" + +setup_file() { + # Initialize the PIDs file at the start of all tests + echo "" > "$COMPAT_PIDS_FILE" +} + +setup() { + bats_load_library bats-support + bats_load_library bats-assert + + bats_require_minimum_version 1.5.0 +} + +teardown_file() { + echo "" >&2 + echo "========================================" >&2 + echo "COMPAT OLD-CONTROLLER TEARDOWN RUNNING" >&2 + echo "========================================" >&2 + + # Kill tracked PIDs + if [ -f "$COMPAT_PIDS_FILE" ]; then + while IFS= read -r pid; do + if [ -n "$pid" ]; then + if ps -p "$pid" > /dev/null 2>&1; then + echo " Killing PID $pid" >&2 + kill -9 "$pid" 2>/dev/null || true + fi + fi + done < "$COMPAT_PIDS_FILE" + fi + + # Kill any orphaned jmp processes for compat exporters + pkill -9 -f "jmp run --exporter compat-" 2>/dev/null || true + + # Clean up the PIDs file + rm -f "$COMPAT_PIDS_FILE" + + # Clean up CRDs (best effort) + jmp admin delete client --namespace "${JS_NAMESPACE}" compat-client --delete 2>/dev/null || true + jmp admin delete exporter --namespace "${JS_NAMESPACE}" compat-exporter --delete 2>/dev/null || true + jmp admin delete client --namespace "${JS_NAMESPACE}" compat-client-wait --delete 2>/dev/null || true + jmp admin delete exporter --namespace "${JS_NAMESPACE}" compat-exporter-wait --delete 2>/dev/null || true + + echo "=== Compat old-controller cleanup complete ===" >&2 +} + +wait_for_compat_exporter() { + # Brief delay to avoid catching pre-disconnect state + sleep 2 + kubectl -n "${JS_NAMESPACE}" wait --timeout 5m \ + --for=condition=Online --for=condition=Registered \ + exporters.jumpstarter.dev/compat-exporter +} + +# ============================================================================ +# Core compatibility tests: Old Controller + New Client/Exporter +# ============================================================================ + +@test "compat-old-ctrl: can create client with admin cli" { + run jmp admin create client -n "${JS_NAMESPACE}" compat-client --unsafe --save + assert_success +} + +@test "compat-old-ctrl: can create exporter with admin cli" { + run jmp admin create exporter -n "${JS_NAMESPACE}" compat-exporter --save \ + --label example.com/board=compat + assert_success + + go run github.com/mikefarah/yq/v4@latest -i ". * load(\"e2e/exporters/exporter.yaml\")" \ + /etc/jumpstarter/exporters/compat-exporter.yaml +} + +@test "compat-old-ctrl: new exporter registers with old controller" { + cat <&- & +while true; do + jmp run --exporter compat-exporter + sleep 2 +done +EOF + echo "$!" >> "$COMPAT_PIDS_FILE" + + wait_for_compat_exporter +} + +@test "compat-old-ctrl: exporter shows as Online and Registered" { + wait_for_compat_exporter + + run kubectl -n "${JS_NAMESPACE}" get exporters.jumpstarter.dev/compat-exporter \ + -o jsonpath='{.status.conditions[?(@.type=="Online")].status}' + assert_success + assert_output "True" + + run kubectl -n "${JS_NAMESPACE}" get exporters.jumpstarter.dev/compat-exporter \ + -o jsonpath='{.status.conditions[?(@.type=="Registered")].status}' + assert_success + assert_output "True" +} + +@test "compat-old-ctrl: new client can lease and connect through old controller" { + wait_for_compat_exporter + + run jmp shell --client compat-client \ + --selector example.com/board=compat j power on + assert_success +} + +@test "compat-old-ctrl: can operate on leases through old controller" { + wait_for_compat_exporter + + jmp config client use compat-client + + jmp create lease --selector example.com/board=compat --duration 1d + jmp get leases + jmp get exporters + + # Verify label selector filtering works + run jmp get leases --selector example.com/board=compat -o yaml + assert_success + assert_output --partial "example.com/board=compat" + + run jmp get leases --selector example.com/board=doesnotexist + assert_success + assert_output "No resources found." + + jmp delete leases --all +} + +@test "compat-old-ctrl: exporter stays Online after lease cycle" { + # After lease operations, the new exporter sends ReportStatus with + # new fields (status_version, previous_status, release_lease) that + # the old controller doesn't understand. The exporter must remain Online. + wait_for_compat_exporter + + run kubectl -n "${JS_NAMESPACE}" get exporters.jumpstarter.dev/compat-exporter \ + -o jsonpath='{.status.conditions[?(@.type=="Online")].status}' + assert_success + assert_output "True" + + run kubectl -n "${JS_NAMESPACE}" get exporters.jumpstarter.dev/compat-exporter \ + -o jsonpath='{.status.conditions[?(@.type=="Registered")].status}' + assert_success + assert_output "True" +} + +# ============================================================================ +# Client started before exporter +# ============================================================================ + +@test "compat-old-ctrl: client started before exporter connects" { + # Stop any running compat exporters for a clean test + pkill -9 -f "jmp run --exporter compat-exporter-wait" 2>/dev/null || true + sleep 3 + + # Create fresh resources for this test + jmp admin delete exporter --namespace "${JS_NAMESPACE}" compat-exporter-wait --delete 2>/dev/null || true + jmp admin delete client --namespace "${JS_NAMESPACE}" compat-client-wait --delete 2>/dev/null || true + + run jmp admin create client -n "${JS_NAMESPACE}" compat-client-wait --unsafe --save + assert_success + + run jmp admin create exporter -n "${JS_NAMESPACE}" compat-exporter-wait --save \ + --label example.com/board=compat-wait + assert_success + + go run github.com/mikefarah/yq/v4@latest -i ". * load(\"e2e/exporters/exporter.yaml\")" \ + /etc/jumpstarter/exporters/compat-exporter-wait.yaml + + # Start client shell BEFORE exporter is running (in background) + # The client should wait for a matching exporter to become available + jmp shell --client compat-client-wait \ + --selector example.com/board=compat-wait j power on & + local CLIENT_PID=$! + + # Wait a few seconds to ensure the client is actively waiting for lease fulfillment + sleep 5 + + # Verify the client is still waiting (hasn't exited yet) + if ! kill -0 $CLIENT_PID 2>/dev/null; then + wait $CLIENT_PID || true + fail "Client exited before exporter was started" + fi + + # Now start the exporter + cat <&- & +while true; do + jmp run --exporter compat-exporter-wait + sleep 2 +done +EOF + echo "$!" >> "$COMPAT_PIDS_FILE" + + # Wait for the client command to complete (with timeout) + local timeout=120 + local count=0 + while kill -0 $CLIENT_PID 2>/dev/null && [ $count -lt $timeout ]; do + sleep 1 + count=$((count + 1)) + done + + # Check if client process completed + if kill -0 $CLIENT_PID 2>/dev/null; then + kill -9 $CLIENT_PID 2>/dev/null || true + fail "Client shell timed out waiting for exporter (${timeout}s)" + fi + + # Verify client exited successfully + wait $CLIENT_PID + local exit_code=$? + [ $exit_code -eq 0 ] +} + +# ============================================================================ +# Cleanup +# ============================================================================ + +@test "compat-old-ctrl: cleanup resources" { + jmp admin delete client --namespace "${JS_NAMESPACE}" compat-client --delete 2>/dev/null || true + jmp admin delete exporter --namespace "${JS_NAMESPACE}" compat-exporter --delete 2>/dev/null || true + jmp admin delete client --namespace "${JS_NAMESPACE}" compat-client-wait --delete 2>/dev/null || true + jmp admin delete exporter --namespace "${JS_NAMESPACE}" compat-exporter-wait --delete 2>/dev/null || true +} diff --git a/e2e/test/compat_old_controller_test.go b/e2e/test/compat_old_controller_test.go new file mode 100644 index 000000000..3664ff53b --- /dev/null +++ b/e2e/test/compat_old_controller_test.go @@ -0,0 +1,192 @@ +/* +Copyright 2026. The Jumpstarter Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "os/exec" + "path/filepath" + "syscall" + "time" + + . "github.com/onsi/ginkgo/v2" //nolint:revive + . "github.com/onsi/gomega" //nolint:revive +) + +var _ = Describe("Compat: Old Controller E2E Tests", Label("compat", "old-controller"), Ordered, func() { + var ( + tracker *ProcessTracker + ns string + ) + + waitForCompatExporter := func() { + time.Sleep(exporterPostDelay) + MustKubectl("-n", ns, "wait", "--timeout", "5m", + "--for=condition=Online", "--for=condition=Registered", + "exporters.jumpstarter.dev/compat-exporter") + } + + BeforeAll(func() { + tracker = NewProcessTracker() + ns = Namespace() + }) + + AfterAll(func() { + if tracker != nil { + tracker.StopAll() + } + + _ = exec.Command("pkill", "-9", "-f", "jmp run --exporter compat-").Run() + + _, _ = Jmp("admin", "delete", "client", "--namespace", ns, "compat-client", "--delete") + _, _ = Jmp("admin", "delete", "exporter", "--namespace", ns, "compat-exporter", "--delete") + _, _ = Jmp("admin", "delete", "client", "--namespace", ns, "compat-client-wait", "--delete") + _, _ = Jmp("admin", "delete", "exporter", "--namespace", ns, "compat-exporter-wait", "--delete") + + if tracker != nil { + tracker.Cleanup() + } + }) + + // --- Core compatibility --- + Context("Core compatibility", func() { + It("can create client with admin cli", func() { + out, err := Jmp("admin", "create", "client", "-n", ns, "compat-client", + "--unsafe", "--save") + Expect(err).NotTo(HaveOccurred(), out) + }) + + It("can create exporter with admin cli", func() { + out, err := Jmp("admin", "create", "exporter", "-n", ns, "compat-exporter", + "--save", "--label", "example.com/board=compat") + Expect(err).NotTo(HaveOccurred(), out) + + overlayPath := filepath.Join(RepoRoot(), "e2e", "exporters", "exporter.yaml") + MergeExporterConfig("/etc/jumpstarter/exporters/compat-exporter.yaml", overlayPath) + }) + + It("new exporter registers with old controller", func() { + tracker.StartExporterLoop("compat-exporter") + waitForCompatExporter() + }) + + It("exporter shows as Online and Registered", func() { + waitForCompatExporter() + + out := MustKubectl("-n", ns, "get", "exporters.jumpstarter.dev/compat-exporter", + "-o", `jsonpath={.status.conditions[?(@.type=="Online")].status}`) + Expect(out).To(Equal("True")) + + out = MustKubectl("-n", ns, "get", "exporters.jumpstarter.dev/compat-exporter", + "-o", `jsonpath={.status.conditions[?(@.type=="Registered")].status}`) + Expect(out).To(Equal("True")) + }) + + It("new client can lease and connect through old controller", func() { + waitForCompatExporter() + out, err := Jmp("shell", "--client", "compat-client", + "--selector", "example.com/board=compat", "j", "power", "on") + Expect(err).NotTo(HaveOccurred(), out) + }) + + It("can operate on leases through old controller", func() { + waitForCompatExporter() + + MustJmp("config", "client", "use", "compat-client") + MustJmp("create", "lease", "--selector", "example.com/board=compat", "--duration", "1d") + MustJmp("get", "leases") + MustJmp("get", "exporters") + + out, err := Jmp("get", "leases", "--selector", "example.com/board=compat", "-o", "yaml") + Expect(err).NotTo(HaveOccurred(), out) + Expect(out).To(ContainSubstring("example.com/board=compat")) + + out, err = Jmp("get", "leases", "--selector", "example.com/board=doesnotexist") + Expect(err).NotTo(HaveOccurred(), out) + Expect(out).To(Equal("No resources found.")) + + MustJmp("delete", "leases", "--all") + }) + + It("exporter stays Online after lease cycle", func() { + waitForCompatExporter() + + out := MustKubectl("-n", ns, "get", "exporters.jumpstarter.dev/compat-exporter", + "-o", `jsonpath={.status.conditions[?(@.type=="Online")].status}`) + Expect(out).To(Equal("True")) + + out = MustKubectl("-n", ns, "get", "exporters.jumpstarter.dev/compat-exporter", + "-o", `jsonpath={.status.conditions[?(@.type=="Registered")].status}`) + Expect(out).To(Equal("True")) + }) + }) + + // --- Client started before exporter --- + Context("Client started before exporter", func() { + It("client started before exporter connects", func() { + _ = exec.Command("pkill", "-9", "-f", "jmp run --exporter compat-exporter-wait").Run() + time.Sleep(3 * time.Second) + + _, _ = Jmp("admin", "delete", "exporter", "--namespace", ns, "compat-exporter-wait", "--delete") + _, _ = Jmp("admin", "delete", "client", "--namespace", ns, "compat-client-wait", "--delete") + + out, err := Jmp("admin", "create", "client", "-n", ns, "compat-client-wait", + "--unsafe", "--save") + Expect(err).NotTo(HaveOccurred(), out) + + out, err = Jmp("admin", "create", "exporter", "-n", ns, "compat-exporter-wait", + "--save", "--label", "example.com/board=compat-wait") + Expect(err).NotTo(HaveOccurred(), out) + + overlayPath := filepath.Join(RepoRoot(), "e2e", "exporters", "exporter.yaml") + MergeExporterConfig("/etc/jumpstarter/exporters/compat-exporter-wait.yaml", overlayPath) + + // Start client BEFORE exporter + clientCmd := JmpCmd("shell", "--client", "compat-client-wait", + "--selector", "example.com/board=compat-wait", "j", "power", "on") + clientCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + err = clientCmd.Start() + Expect(err).NotTo(HaveOccurred()) + + time.Sleep(5 * time.Second) + Expect(clientCmd.ProcessState).To(BeNil(), "Client exited before exporter was started") + + // Now start the exporter + tracker.StartExporterLoop("compat-exporter-wait") + + done := make(chan error, 1) + go func() { done <- clientCmd.Wait() }() + + select { + case err := <-done: + Expect(err).NotTo(HaveOccurred(), "client shell failed") + case <-time.After(120 * time.Second): + _ = clientCmd.Process.Kill() + Fail("Client shell timed out waiting for exporter (120s)") + } + }) + }) + + // --- Cleanup --- + Context("Cleanup", func() { + It("cleans up resources", func() { + _, _ = Jmp("admin", "delete", "client", "--namespace", ns, "compat-client", "--delete") + _, _ = Jmp("admin", "delete", "exporter", "--namespace", ns, "compat-exporter", "--delete") + _, _ = Jmp("admin", "delete", "client", "--namespace", ns, "compat-client-wait", "--delete") + _, _ = Jmp("admin", "delete", "exporter", "--namespace", ns, "compat-exporter-wait", "--delete") + }) + }) +}) From d6beafdcbc1936deb52f70fbc84cffc07c738d6a Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Sat, 11 Apr 2026 09:17:06 +0200 Subject: [PATCH 13/54] fix: deploy old controller v0.8.1 via operator installer in compat tests v0.7.0 predated the operator installer and was only deployable via Helm. Replace it with v0.8.1 which ships an operator-installer.yaml release asset. Also bump old-client version from 0.7.1 to 0.8.1 so both compat scenarios test against the previous release. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e.yaml | 9 ++++----- Makefile | 4 ++-- e2e/compat/setup.sh | 14 +++++++------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 05e8f7125..96d234603 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -242,7 +242,6 @@ jobs: # ============================================================================ # Compatibility tests: cross-version interop between controller and client/exporter - # These jobs can be removed once 0.7.x controller support is no longer needed. # ============================================================================ e2e-compat-old-controller: @@ -262,11 +261,11 @@ jobs: with: go-version: "1.22" - - name: Setup compat environment (old controller v0.7.0) + - name: Setup compat environment (old controller v0.8.1) run: make e2e-compat-setup COMPAT_SCENARIO=old-controller env: CI: true - COMPAT_CONTROLLER_TAG: v0.7.0 + COMPAT_CONTROLLER_TAG: v0.8.1 - name: Run compat tests (old controller + new client/exporter) run: make e2e-compat-run COMPAT_TEST=old-controller @@ -305,11 +304,11 @@ jobs: - name: Load controller image run: docker load < /tmp/artifacts/controller-image.tar - - name: Setup compat environment (old client v0.7.1) + - name: Setup compat environment (old client v0.8.1) run: make e2e-compat-setup COMPAT_SCENARIO=old-client env: CI: true - COMPAT_CLIENT_VERSION: "0.7.1" + COMPAT_CLIENT_VERSION: "0.8.1" SKIP_BUILD: "true" PREBUILT_WHEELS_DIR: /tmp/python-wheels diff --git a/Makefile b/Makefile index 85cc645de..2fef4bb70 100644 --- a/Makefile +++ b/Makefile @@ -166,8 +166,8 @@ test-e2e: e2e-run # Compatibility E2E testing (cross-version tests, separate from main e2e) COMPAT_SCENARIO ?= old-controller COMPAT_TEST ?= old-controller -COMPAT_CONTROLLER_TAG ?= v0.7.0 -COMPAT_CLIENT_VERSION ?= 0.7.1 +COMPAT_CONTROLLER_TAG ?= v0.8.1 +COMPAT_CLIENT_VERSION ?= 0.8.1 .PHONY: e2e-compat-setup e2e-compat-setup: diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index 85306ac6c..f47dbed47 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -7,8 +7,8 @@ # # Environment variables: # COMPAT_SCENARIO - "old-controller" or "old-client" (required) -# COMPAT_CONTROLLER_TAG - Controller image tag for old-controller scenario (default: v0.7.0) -# COMPAT_CLIENT_VERSION - PyPI version for old-client scenario (default: 0.7.1) +# COMPAT_CONTROLLER_TAG - Controller release tag for old-controller scenario (default: v0.8.1) +# COMPAT_CLIENT_VERSION - PyPI version for old-client scenario (default: 0.8.1) set -euo pipefail @@ -26,8 +26,8 @@ export JS_NAMESPACE="${JS_NAMESPACE:-jumpstarter-lab}" # Scenario configuration COMPAT_SCENARIO="${COMPAT_SCENARIO:-old-controller}" -COMPAT_CONTROLLER_TAG="${COMPAT_CONTROLLER_TAG:-v0.7.0}" -COMPAT_CLIENT_VERSION="${COMPAT_CLIENT_VERSION:-0.7.1}" +COMPAT_CONTROLLER_TAG="${COMPAT_CONTROLLER_TAG:-v0.8.1}" +COMPAT_CLIENT_VERSION="${COMPAT_CLIENT_VERSION:-0.8.1}" # Color output RED='\033[0;31m' @@ -103,10 +103,10 @@ deploy_old_controller() { kubectl config use-context kind-jumpstarter - # Install old controller using operator installer from the release tag - local INSTALLER_URL="https://raw.githubusercontent.com/jumpstarter-dev/jumpstarter/${COMPAT_CONTROLLER_TAG}/controller/deploy/operator/dist/install.yaml" + # Install old controller using operator installer from the release assets + local INSTALLER_URL="https://github.com/jumpstarter-dev/jumpstarter/releases/download/${COMPAT_CONTROLLER_TAG}/operator-installer.yaml" log_info "Installing old controller via operator (version: ${COMPAT_CONTROLLER_TAG})..." - kubectl apply -f "${INSTALLER_URL}" || log_warn "Operator installer may not be available for ${COMPAT_CONTROLLER_TAG}, skipping" + kubectl apply -f "${INSTALLER_URL}" kubectl config set-context --current --namespace=jumpstarter-lab From 0abe23bcbf57f4d018e58facaa4510e86713a4fb Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Sat, 11 Apr 2026 09:27:15 +0200 Subject: [PATCH 14/54] fix: remove stale bats test file re-added by revert The revert commit brought back tests-old-controller.bats, but upstream converted all bats tests to ginkgo. The ginkgo equivalent (e2e/test/compat_old_controller_test.go) is the canonical test file. Co-Authored-By: Claude Opus 4.6 --- e2e/compat/tests-old-controller.bats | 232 --------------------------- 1 file changed, 232 deletions(-) delete mode 100644 e2e/compat/tests-old-controller.bats diff --git a/e2e/compat/tests-old-controller.bats b/e2e/compat/tests-old-controller.bats deleted file mode 100644 index 0fc46197e..000000000 --- a/e2e/compat/tests-old-controller.bats +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env bats -# Compatibility E2E tests: Old Controller (v0.7.x) + New Client/Exporter -# -# Tests that the new client and exporter code works correctly against -# an older controller version that doesn't support hooks protocol changes. -# Also includes the client-started-before-exporter scenario. - -JS_NAMESPACE="${JS_NAMESPACE:-jumpstarter-lab}" - -# File to track bash wrapper process PIDs across tests -COMPAT_PIDS_FILE="${BATS_RUN_TMPDIR:-/tmp}/compat_old_ctrl_pids.txt" - -setup_file() { - # Initialize the PIDs file at the start of all tests - echo "" > "$COMPAT_PIDS_FILE" -} - -setup() { - bats_load_library bats-support - bats_load_library bats-assert - - bats_require_minimum_version 1.5.0 -} - -teardown_file() { - echo "" >&2 - echo "========================================" >&2 - echo "COMPAT OLD-CONTROLLER TEARDOWN RUNNING" >&2 - echo "========================================" >&2 - - # Kill tracked PIDs - if [ -f "$COMPAT_PIDS_FILE" ]; then - while IFS= read -r pid; do - if [ -n "$pid" ]; then - if ps -p "$pid" > /dev/null 2>&1; then - echo " Killing PID $pid" >&2 - kill -9 "$pid" 2>/dev/null || true - fi - fi - done < "$COMPAT_PIDS_FILE" - fi - - # Kill any orphaned jmp processes for compat exporters - pkill -9 -f "jmp run --exporter compat-" 2>/dev/null || true - - # Clean up the PIDs file - rm -f "$COMPAT_PIDS_FILE" - - # Clean up CRDs (best effort) - jmp admin delete client --namespace "${JS_NAMESPACE}" compat-client --delete 2>/dev/null || true - jmp admin delete exporter --namespace "${JS_NAMESPACE}" compat-exporter --delete 2>/dev/null || true - jmp admin delete client --namespace "${JS_NAMESPACE}" compat-client-wait --delete 2>/dev/null || true - jmp admin delete exporter --namespace "${JS_NAMESPACE}" compat-exporter-wait --delete 2>/dev/null || true - - echo "=== Compat old-controller cleanup complete ===" >&2 -} - -wait_for_compat_exporter() { - # Brief delay to avoid catching pre-disconnect state - sleep 2 - kubectl -n "${JS_NAMESPACE}" wait --timeout 5m \ - --for=condition=Online --for=condition=Registered \ - exporters.jumpstarter.dev/compat-exporter -} - -# ============================================================================ -# Core compatibility tests: Old Controller + New Client/Exporter -# ============================================================================ - -@test "compat-old-ctrl: can create client with admin cli" { - run jmp admin create client -n "${JS_NAMESPACE}" compat-client --unsafe --save - assert_success -} - -@test "compat-old-ctrl: can create exporter with admin cli" { - run jmp admin create exporter -n "${JS_NAMESPACE}" compat-exporter --save \ - --label example.com/board=compat - assert_success - - go run github.com/mikefarah/yq/v4@latest -i ". * load(\"e2e/exporters/exporter.yaml\")" \ - /etc/jumpstarter/exporters/compat-exporter.yaml -} - -@test "compat-old-ctrl: new exporter registers with old controller" { - cat <&- & -while true; do - jmp run --exporter compat-exporter - sleep 2 -done -EOF - echo "$!" >> "$COMPAT_PIDS_FILE" - - wait_for_compat_exporter -} - -@test "compat-old-ctrl: exporter shows as Online and Registered" { - wait_for_compat_exporter - - run kubectl -n "${JS_NAMESPACE}" get exporters.jumpstarter.dev/compat-exporter \ - -o jsonpath='{.status.conditions[?(@.type=="Online")].status}' - assert_success - assert_output "True" - - run kubectl -n "${JS_NAMESPACE}" get exporters.jumpstarter.dev/compat-exporter \ - -o jsonpath='{.status.conditions[?(@.type=="Registered")].status}' - assert_success - assert_output "True" -} - -@test "compat-old-ctrl: new client can lease and connect through old controller" { - wait_for_compat_exporter - - run jmp shell --client compat-client \ - --selector example.com/board=compat j power on - assert_success -} - -@test "compat-old-ctrl: can operate on leases through old controller" { - wait_for_compat_exporter - - jmp config client use compat-client - - jmp create lease --selector example.com/board=compat --duration 1d - jmp get leases - jmp get exporters - - # Verify label selector filtering works - run jmp get leases --selector example.com/board=compat -o yaml - assert_success - assert_output --partial "example.com/board=compat" - - run jmp get leases --selector example.com/board=doesnotexist - assert_success - assert_output "No resources found." - - jmp delete leases --all -} - -@test "compat-old-ctrl: exporter stays Online after lease cycle" { - # After lease operations, the new exporter sends ReportStatus with - # new fields (status_version, previous_status, release_lease) that - # the old controller doesn't understand. The exporter must remain Online. - wait_for_compat_exporter - - run kubectl -n "${JS_NAMESPACE}" get exporters.jumpstarter.dev/compat-exporter \ - -o jsonpath='{.status.conditions[?(@.type=="Online")].status}' - assert_success - assert_output "True" - - run kubectl -n "${JS_NAMESPACE}" get exporters.jumpstarter.dev/compat-exporter \ - -o jsonpath='{.status.conditions[?(@.type=="Registered")].status}' - assert_success - assert_output "True" -} - -# ============================================================================ -# Client started before exporter -# ============================================================================ - -@test "compat-old-ctrl: client started before exporter connects" { - # Stop any running compat exporters for a clean test - pkill -9 -f "jmp run --exporter compat-exporter-wait" 2>/dev/null || true - sleep 3 - - # Create fresh resources for this test - jmp admin delete exporter --namespace "${JS_NAMESPACE}" compat-exporter-wait --delete 2>/dev/null || true - jmp admin delete client --namespace "${JS_NAMESPACE}" compat-client-wait --delete 2>/dev/null || true - - run jmp admin create client -n "${JS_NAMESPACE}" compat-client-wait --unsafe --save - assert_success - - run jmp admin create exporter -n "${JS_NAMESPACE}" compat-exporter-wait --save \ - --label example.com/board=compat-wait - assert_success - - go run github.com/mikefarah/yq/v4@latest -i ". * load(\"e2e/exporters/exporter.yaml\")" \ - /etc/jumpstarter/exporters/compat-exporter-wait.yaml - - # Start client shell BEFORE exporter is running (in background) - # The client should wait for a matching exporter to become available - jmp shell --client compat-client-wait \ - --selector example.com/board=compat-wait j power on & - local CLIENT_PID=$! - - # Wait a few seconds to ensure the client is actively waiting for lease fulfillment - sleep 5 - - # Verify the client is still waiting (hasn't exited yet) - if ! kill -0 $CLIENT_PID 2>/dev/null; then - wait $CLIENT_PID || true - fail "Client exited before exporter was started" - fi - - # Now start the exporter - cat <&- & -while true; do - jmp run --exporter compat-exporter-wait - sleep 2 -done -EOF - echo "$!" >> "$COMPAT_PIDS_FILE" - - # Wait for the client command to complete (with timeout) - local timeout=120 - local count=0 - while kill -0 $CLIENT_PID 2>/dev/null && [ $count -lt $timeout ]; do - sleep 1 - count=$((count + 1)) - done - - # Check if client process completed - if kill -0 $CLIENT_PID 2>/dev/null; then - kill -9 $CLIENT_PID 2>/dev/null || true - fail "Client shell timed out waiting for exporter (${timeout}s)" - fi - - # Verify client exited successfully - wait $CLIENT_PID - local exit_code=$? - [ $exit_code -eq 0 ] -} - -# ============================================================================ -# Cleanup -# ============================================================================ - -@test "compat-old-ctrl: cleanup resources" { - jmp admin delete client --namespace "${JS_NAMESPACE}" compat-client --delete 2>/dev/null || true - jmp admin delete exporter --namespace "${JS_NAMESPACE}" compat-exporter --delete 2>/dev/null || true - jmp admin delete client --namespace "${JS_NAMESPACE}" compat-client-wait --delete 2>/dev/null || true - jmp admin delete exporter --namespace "${JS_NAMESPACE}" compat-exporter-wait --delete 2>/dev/null || true -} From 075b1ee193b57984853cbabe325808b634c666b1 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Sat, 11 Apr 2026 09:34:41 +0200 Subject: [PATCH 15/54] fix: address CodeRabbit review findings - Create Jumpstarter CR after applying operator installer in compat setup, since the released installer only contains CRDs and operator deployment, not the CR instance - Align Makefile install/uninstall/build-installer/undeploy targets with the manifests output path (deploy/operator/config/ instead of config/) - Surface non-zero kubectl exit codes as errors in check_jumpstarter_installation instead of silently reporting installed=False - Add test for non-zero exit code path in check_jumpstarter_installation Co-Authored-By: Claude Opus 4.6 --- controller/Makefile | 10 +-- e2e/compat/setup.sh | 64 ++++++++++++++++++- .../jumpstarter_kubernetes/cluster/kubectl.py | 43 +++++++------ .../cluster/kubectl_test.py | 12 ++++ 4 files changed, 102 insertions(+), 27 deletions(-) diff --git a/controller/Makefile b/controller/Makefile index 33934b3f8..45a15971b 100644 --- a/controller/Makefile +++ b/controller/Makefile @@ -158,8 +158,8 @@ docker-buildx: ## Build and push docker image for the manager for cross-platform .PHONY: build-installer build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. mkdir -p dist - cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} - $(KUSTOMIZE) build config/default > dist/install.yaml + cd deploy/operator/config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + $(KUSTOMIZE) build deploy/operator/config/default > dist/install.yaml ##@ Deployment @@ -169,11 +169,11 @@ endif .PHONY: install install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. - $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f - + $(KUSTOMIZE) build deploy/operator/config/crd | $(KUBECTL) apply -f - .PHONY: uninstall uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - + $(KUSTOMIZE) build deploy/operator/config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - .PHONY: deploy deploy: cluster grpcurl ## Deploy controller using the operator. Set SKIP_BUILD=1 to skip image builds. Set CLUSTER_TYPE=k3s for k3s. @@ -205,7 +205,7 @@ deploy-exporters: .PHONY: undeploy undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - + $(KUSTOMIZE) build deploy/operator/config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - ##@ Dependencies diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index f47dbed47..1c049f2bb 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -108,13 +108,73 @@ deploy_old_controller() { log_info "Installing old controller via operator (version: ${COMPAT_CONTROLLER_TAG})..." kubectl apply -f "${INSTALLER_URL}" - kubectl config set-context --current --namespace=jumpstarter-lab + log_info "Waiting for operator to be ready..." + kubectl wait --namespace jumpstarter-operator-system \ + --for=condition=available deployment/jumpstarter-operator-controller-manager \ + --timeout=120s + + kubectl create namespace "${JS_NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - + + log_info "Creating Jumpstarter CR..." + kubectl apply -f - < /dev/null 2>&1; do + sleep 2 + retries=$((retries - 1)) + if [ ${retries} -eq 0 ]; then + log_error "Controller deployment not created after 180s" + exit 1 + fi + done + kubectl wait --namespace "${JS_NAMESPACE}" \ + --for=condition=available deployment/jumpstarter-controller \ + --timeout=180s # Wait for gRPC endpoints local GRPCURL="${REPO_ROOT}/controller/bin/grpcurl" log_info "Waiting for gRPC endpoints..." for ep in ${GRPC_ENDPOINT} ${GRPC_ROUTER_ENDPOINT}; do - local retries=60 + retries=60 log_info " Checking ${ep}..." while ! ${GRPCURL} -insecure "${ep}" list > /dev/null 2>&1; do sleep 2 diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 51157bae6..3812b5b8d 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -90,26 +90,29 @@ async def check_jumpstarter_installation( try: crd_cmd = [kubectl, "--context", context, "get", "crd", "-o", "json"] - returncode, stdout, _ = await run_command(crd_cmd) - - if returncode == 0: - json_start = stdout.find("{") - if json_start >= 0: - json_output = stdout[json_start:] - crds = json.loads(json_output) - else: - crds = json.loads(stdout) - jumpstarter_crds = [] - for item in crds.get("items", []): - name = item.get("metadata", {}).get("name", "") - if "jumpstarter.dev" in name: - jumpstarter_crds.append(name) - - if jumpstarter_crds: - result_data["has_crds"] = True - result_data["installed"] = True - result_data["namespace"] = namespace or "unknown" - result_data["status"] = "installed" + returncode, stdout, stderr = await run_command(crd_cmd) + + if returncode != 0: + result_data["error"] = f"Command failed: {stderr or stdout}" + return V1Alpha1JumpstarterInstance(**result_data) + + json_start = stdout.find("{") + if json_start >= 0: + json_output = stdout[json_start:] + crds = json.loads(json_output) + else: + crds = json.loads(stdout) + jumpstarter_crds = [] + for item in crds.get("items", []): + name = item.get("metadata", {}).get("name", "") + if "jumpstarter.dev" in name: + jumpstarter_crds.append(name) + + if jumpstarter_crds: + result_data["has_crds"] = True + result_data["installed"] = True + result_data["namespace"] = namespace or "unknown" + result_data["status"] = "installed" except json.JSONDecodeError as e: result_data["error"] = f"Failed to parse output: {e}" diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 439528c46..6fccde289 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -227,6 +227,18 @@ async def test_check_jumpstarter_installation_command_failure(self, mock_run_com assert result.error is not None assert "kubectl not found" in result.error + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_jumpstarter_installation_nonzero_exit(self, mock_run_command): + mock_run_command.return_value = (1, "", "forbidden") + + result = await check_jumpstarter_installation("test-context") + + assert result.installed is False + assert result.has_crds is False + assert result.error is not None + assert "forbidden" in result.error + @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") async def test_check_jumpstarter_installation_custom_namespace(self, mock_run_command): From 67b3edd64d731349318652beba0719e88a52f42f Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Sat, 11 Apr 2026 09:48:39 +0200 Subject: [PATCH 16/54] fix: fail fast when baseDomain is empty in e2e setup If the Jumpstarter CR is missing or baseDomain is not set, exit immediately instead of exporting invalid endpoints like grpc.:8082. Co-Authored-By: Claude Opus 4.6 --- e2e/setup-e2e.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/e2e/setup-e2e.sh b/e2e/setup-e2e.sh index 8df7e5ad6..2e6b8fc08 100755 --- a/e2e/setup-e2e.sh +++ b/e2e/setup-e2e.sh @@ -284,6 +284,10 @@ setup_test_environment() { # failures propagate under set -e (local VAR=$(...) masks exit codes). local BASEDOMAIN BASEDOMAIN=$(kubectl get jumpstarter -n "${JS_NAMESPACE}" jumpstarter -o jsonpath='{.spec.baseDomain}') + if [ -z "${BASEDOMAIN}" ]; then + log_error "Failed to get baseDomain from Jumpstarter CR in namespace ${JS_NAMESPACE}" + exit 1 + fi export ENDPOINT="grpc.${BASEDOMAIN}:8082" export LOGIN_ENDPOINT="login.${BASEDOMAIN}:8086" log_info "Controller endpoint: $ENDPOINT" From cbdc07a64cb2003e086a8ca692f139c923cdf71c Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Sat, 11 Apr 2026 09:54:38 +0200 Subject: [PATCH 17/54] fix: load operator image in e2e-compat-old-client CI job The old-client compat test deploys the new controller via deploy_new_controller(), which needs the operator image loaded into kind. Download and load the pre-built operator image and manifest, matching how the main e2e-tests job handles this. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e.yaml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 96d234603..846af22ea 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -273,7 +273,7 @@ jobs: CI: true e2e-compat-old-client: - needs: [changes, build-controller-image, build-python-wheels] + needs: [changes, build-controller-image, build-operator-image, build-python-wheels] if: needs.changes.outputs.should_run == 'true' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -295,14 +295,24 @@ jobs: name: controller-image-amd64 path: /tmp/artifacts + - name: Download operator image + uses: actions/download-artifact@v4 + with: + name: operator-image-amd64 + path: /tmp/artifacts + - name: Download python wheels uses: actions/download-artifact@v4 with: name: python-wheels path: /tmp/python-wheels - - name: Load controller image - run: docker load < /tmp/artifacts/controller-image.tar + - name: Load container images and operator manifest + run: | + docker load < /tmp/artifacts/controller-image.tar + docker load < /tmp/artifacts/operator-image.tar + mkdir -p controller/deploy/operator/dist + cp /tmp/artifacts/operator-install.yaml controller/deploy/operator/dist/install.yaml - name: Setup compat environment (old client v0.8.1) run: make e2e-compat-setup COMPAT_SCENARIO=old-client @@ -311,6 +321,7 @@ jobs: COMPAT_CLIENT_VERSION: "0.8.1" SKIP_BUILD: "true" PREBUILT_WHEELS_DIR: /tmp/python-wheels + OPERATOR_IMG: quay.io/jumpstarter-dev/jumpstarter-operator:latest - name: Run compat tests (new controller + old client/exporter) run: make e2e-compat-run COMPAT_TEST=old-client From 54f6c02e3ee65adeae066a58a7d85b0071b40239 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Sat, 11 Apr 2026 10:05:10 +0200 Subject: [PATCH 18/54] fix: use client version 0.7.4 for old-client compat test The jumpstarter base package was never published at 0.8.1 on PyPI, making the entire 0.8.1 client stack uninstallable. Fall back to 0.7.4 which is the latest version where all packages are available. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e.yaml | 4 ++-- Makefile | 2 +- e2e/compat/setup.sh | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 846af22ea..862a8fcbb 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -314,11 +314,11 @@ jobs: mkdir -p controller/deploy/operator/dist cp /tmp/artifacts/operator-install.yaml controller/deploy/operator/dist/install.yaml - - name: Setup compat environment (old client v0.8.1) + - name: Setup compat environment (old client v0.7.4) run: make e2e-compat-setup COMPAT_SCENARIO=old-client env: CI: true - COMPAT_CLIENT_VERSION: "0.8.1" + COMPAT_CLIENT_VERSION: "0.7.4" SKIP_BUILD: "true" PREBUILT_WHEELS_DIR: /tmp/python-wheels OPERATOR_IMG: quay.io/jumpstarter-dev/jumpstarter-operator:latest diff --git a/Makefile b/Makefile index 2fef4bb70..b8ac36af5 100644 --- a/Makefile +++ b/Makefile @@ -167,7 +167,7 @@ test-e2e: e2e-run COMPAT_SCENARIO ?= old-controller COMPAT_TEST ?= old-controller COMPAT_CONTROLLER_TAG ?= v0.8.1 -COMPAT_CLIENT_VERSION ?= 0.8.1 +COMPAT_CLIENT_VERSION ?= 0.7.4 .PHONY: e2e-compat-setup e2e-compat-setup: diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index 1c049f2bb..a5c74e948 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -27,7 +27,7 @@ export JS_NAMESPACE="${JS_NAMESPACE:-jumpstarter-lab}" # Scenario configuration COMPAT_SCENARIO="${COMPAT_SCENARIO:-old-controller}" COMPAT_CONTROLLER_TAG="${COMPAT_CONTROLLER_TAG:-v0.8.1}" -COMPAT_CLIENT_VERSION="${COMPAT_CLIENT_VERSION:-0.8.1}" +COMPAT_CLIENT_VERSION="${COMPAT_CLIENT_VERSION:-0.7.4}" # Color output RED='\033[0;31m' From 1b4ceb07300ea280783063bbb7ac9c788754e182 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Mon, 13 Apr 2026 18:53:17 +0200 Subject: [PATCH 19/54] fix: use correct kustomize image name and require CR instances for install check The build-installer target referenced 'controller' as the image name but the kustomization.yaml uses 'quay.io/jumpstarter-dev/jumpstarter-operator'. Also, check_jumpstarter_installation now queries for actual Jumpstarter CR instances instead of treating CRD presence alone as proof of installation. Co-Authored-By: Claude Opus 4.6 --- controller/Makefile | 2 +- .../jumpstarter_kubernetes/cluster/kubectl.py | 29 +++++++++++++-- .../cluster/kubectl_test.py | 35 ++++++++++++++++--- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/controller/Makefile b/controller/Makefile index 45a15971b..7382e78f4 100644 --- a/controller/Makefile +++ b/controller/Makefile @@ -158,7 +158,7 @@ docker-buildx: ## Build and push docker image for the manager for cross-platform .PHONY: build-installer build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. mkdir -p dist - cd deploy/operator/config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + cd deploy/operator/config/manager && $(KUSTOMIZE) edit set image quay.io/jumpstarter-dev/jumpstarter-operator=${IMG} $(KUSTOMIZE) build deploy/operator/config/default > dist/install.yaml ##@ Deployment diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 3812b5b8d..cdf4b9d94 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -72,6 +72,27 @@ async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[Dict[str, str]] raise KubeconfigError(f"Error listing kubectl contexts: {e}") from e +async def _check_cr_instances( + kubectl: str, context: str, namespace: Optional[str] +) -> dict: + """Query for Jumpstarter CR instances to confirm full installation.""" + cr_resource = "jumpstarters.operator.jumpstarter.dev" + try: + cr_cmd = [kubectl, "--context", context, "get", cr_resource, "-A", "-o", "json"] + cr_returncode, cr_stdout, _ = await run_command(cr_cmd) + if cr_returncode == 0: + cr_data = json.loads(cr_stdout) + if cr_data.get("items"): + return { + "installed": True, + "namespace": namespace or "unknown", + "status": "installed", + } + except (json.JSONDecodeError, RuntimeError): + pass + return {} + + async def check_jumpstarter_installation( context: str, namespace: Optional[str] = None, kubectl: str = "kubectl" ) -> V1Alpha1JumpstarterInstance: @@ -110,9 +131,11 @@ async def check_jumpstarter_installation( if jumpstarter_crds: result_data["has_crds"] = True - result_data["installed"] = True - result_data["namespace"] = namespace or "unknown" - result_data["status"] = "installed" + + if "jumpstarters.operator.jumpstarter.dev" in jumpstarter_crds: + result_data.update( + await _check_cr_instances(kubectl, context, namespace) + ) except json.JSONDecodeError as e: result_data["error"] = f"Failed to parse output: {e}" diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 6fccde289..d36ae6045 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -194,15 +194,34 @@ class TestCheckJumpstarterInstallation: @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") - async def test_check_jumpstarter_installation_crds_found(self, mock_run_command): + async def test_check_jumpstarter_installation_crds_only(self, mock_run_command): crds_response = {"items": [{"metadata": {"name": "exporters.jumpstarter.dev"}}]} mock_run_command.return_value = (0, json.dumps(crds_response), "") result = await check_jumpstarter_installation("test-context") - assert result.installed is True assert result.has_crds is True + assert result.installed is False + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_jumpstarter_installation_with_cr_instances(self, mock_run_command): + crds_response = {"items": [ + {"metadata": {"name": "exporters.jumpstarter.dev"}}, + {"metadata": {"name": "jumpstarters.operator.jumpstarter.dev"}}, + ]} + cr_response = {"items": [{"metadata": {"name": "jumpstarter", "namespace": "jumpstarter"}}]} + + mock_run_command.side_effect = [ + (0, json.dumps(crds_response), ""), + (0, json.dumps(cr_response), ""), + ] + + result = await check_jumpstarter_installation("test-context") + + assert result.has_crds is True + assert result.installed is True assert result.status == "installed" @pytest.mark.asyncio @@ -242,8 +261,16 @@ async def test_check_jumpstarter_installation_nonzero_exit(self, mock_run_comman @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") async def test_check_jumpstarter_installation_custom_namespace(self, mock_run_command): - crds_response = {"items": [{"metadata": {"name": "exporters.jumpstarter.dev"}}]} - mock_run_command.return_value = (0, json.dumps(crds_response), "") + crds_response = {"items": [ + {"metadata": {"name": "exporters.jumpstarter.dev"}}, + {"metadata": {"name": "jumpstarters.operator.jumpstarter.dev"}}, + ]} + cr_response = {"items": [{"metadata": {"name": "jumpstarter", "namespace": "custom-ns"}}]} + + mock_run_command.side_effect = [ + (0, json.dumps(crds_response), ""), + (0, json.dumps(cr_response), ""), + ] result = await check_jumpstarter_installation("test-context", namespace="custom-ns") From 4e27ff188d1f659f40e53944cba1474f744096a7 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 16 Apr 2026 15:02:55 +0200 Subject: [PATCH 20/54] test: add coverage for helm-removal changes in kubectl, operations, and get Cover the changed lines flagged by diff-cover after helm chart removal: - kubectl.py: _check_cr_instances function, JSON parsing paths, cluster info context-not-found and version-parse-failure branches - operations.py: force_recreate_cluster warning messages and install_jumpstarter_operator call path - get.py: cluster list command without name argument Generated-By: Forge/20260416_144215_369510_31d1ed1b Co-Authored-By: Claude Opus 4.6 --- .../jumpstarter_cli_admin/get_test.py | 38 ++++ .../cluster/kubectl_test.py | 201 +++++++++++++++++- .../cluster/operations_test.py | 66 ++++++ 3 files changed, 303 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py index a79ff7d0f..d2caade86 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py @@ -1370,3 +1370,41 @@ def test_get_clusters_error(list_clusters_mock: AsyncMock): result = runner.invoke(get, ["clusters"]) assert result.exit_code == 1 assert "error" in result.output.lower() + + +@patch("jumpstarter_cli_admin.get.list_clusters") +def test_get_cluster_without_name_lists_all(list_clusters_mock: AsyncMock): + from jumpstarter_kubernetes import V1Alpha1ClusterInfo, V1Alpha1ClusterList, V1Alpha1JumpstarterInstance + + runner = CliRunner() + cluster_list = V1Alpha1ClusterList( + items=[ + V1Alpha1ClusterInfo( + name="kind-test", + cluster="kind-kind-test", + server="https://127.0.0.1:6443", + user="kind-kind-test", + namespace="default", + is_current=True, + type="kind", + accessible=True, + version="1.28.0", + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + ), + ] + ) + list_clusters_mock.return_value = cluster_list + result = runner.invoke(get, ["cluster"]) + assert result.exit_code == 0 + assert "kind-test" in result.output + + +@patch("jumpstarter_cli_admin.get.list_clusters") +def test_get_clusters_command_calls_list_clusters(list_clusters_mock: AsyncMock): + from jumpstarter_kubernetes import V1Alpha1ClusterList + + runner = CliRunner() + list_clusters_mock.return_value = V1Alpha1ClusterList(items=[]) + result = runner.invoke(get, ["clusters"]) + assert result.exit_code == 0 + list_clusters_mock.assert_called_once() diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index d36ae6045..d76811e78 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -6,6 +6,7 @@ import pytest from jumpstarter_kubernetes.cluster.kubectl import ( + _check_cr_instances, check_jumpstarter_installation, check_kubernetes_access, get_cluster_info, @@ -189,6 +190,67 @@ async def test_get_kubectl_contexts_custom_kubectl(self, mock_run_command): mock_run_command.assert_called_once_with(["custom-kubectl", "config", "view", "-o", "json"]) +class TestCheckCrInstances: + """Test CR instance detection for Jumpstarter installation.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_cr_instances_found_with_namespace(self, mock_run_command): + cr_response = {"items": [{"metadata": {"name": "jumpstarter", "namespace": "custom-ns"}}]} + mock_run_command.return_value = (0, json.dumps(cr_response), "") + + result = await _check_cr_instances("kubectl", "test-context", "custom-ns") + + assert result == {"installed": True, "namespace": "custom-ns", "status": "installed"} + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_cr_instances_found_without_namespace(self, mock_run_command): + cr_response = {"items": [{"metadata": {"name": "jumpstarter"}}]} + mock_run_command.return_value = (0, json.dumps(cr_response), "") + + result = await _check_cr_instances("kubectl", "test-context", None) + + assert result == {"installed": True, "namespace": "unknown", "status": "installed"} + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_cr_instances_empty_items(self, mock_run_command): + cr_response = {"items": []} + mock_run_command.return_value = (0, json.dumps(cr_response), "") + + result = await _check_cr_instances("kubectl", "test-context", None) + + assert result == {} + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_cr_instances_nonzero_return_code(self, mock_run_command): + mock_run_command.return_value = (1, "", "forbidden") + + result = await _check_cr_instances("kubectl", "test-context", None) + + assert result == {} + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_cr_instances_json_decode_error(self, mock_run_command): + mock_run_command.return_value = (0, "not valid json", "") + + result = await _check_cr_instances("kubectl", "test-context", None) + + assert result == {} + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_cr_instances_runtime_error(self, mock_run_command): + mock_run_command.side_effect = RuntimeError("kubectl not found") + + result = await _check_cr_instances("kubectl", "test-context", None) + + assert result == {} + + class TestCheckJumpstarterInstallation: """Test Jumpstarter installation checking via CRD detection.""" @@ -277,6 +339,56 @@ async def test_check_jumpstarter_installation_custom_namespace(self, mock_run_co assert result.installed is True assert result.namespace == "custom-ns" + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_jumpstarter_installation_json_decode_error(self, mock_run_command): + mock_run_command.return_value = (0, "not valid json at all", "") + + result = await check_jumpstarter_installation("test-context") + + assert result.installed is False + assert result.error is not None + assert "Failed to parse output" in result.error + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_jumpstarter_installation_stdout_without_json_prefix(self, mock_run_command): + crds_json = json.dumps({"items": [{"metadata": {"name": "exporters.jumpstarter.dev"}}]}) + mock_run_command.return_value = (0, crds_json, "") + + result = await check_jumpstarter_installation("test-context") + + assert result.has_crds is True + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_jumpstarter_installation_stdout_with_warning_prefix(self, mock_run_command): + crds_json = json.dumps({"items": [{"metadata": {"name": "exporters.jumpstarter.dev"}}]}) + stdout_with_warning = f"Warning: some kubectl warning\n{crds_json}" + mock_run_command.return_value = (0, stdout_with_warning, "") + + result = await check_jumpstarter_installation("test-context") + + assert result.has_crds is True + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_jumpstarter_installation_cr_check_empty_items(self, mock_run_command): + crds_response = {"items": [ + {"metadata": {"name": "jumpstarters.operator.jumpstarter.dev"}}, + ]} + cr_response = {"items": []} + + mock_run_command.side_effect = [ + (0, json.dumps(crds_response), ""), + (0, json.dumps(cr_response), ""), + ] + + result = await check_jumpstarter_installation("test-context") + + assert result.has_crds is True + assert result.installed is False + class TestGetClusterInfo: """Test cluster info retrieval.""" @@ -327,16 +439,75 @@ async def test_get_cluster_info_inaccessible(self, mock_get_contexts): @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") async def test_get_cluster_info_invalid_json(self, mock_get_contexts): - # Mock get_kubectl_contexts to fail with JSON parse error error_msg = "Failed to parse kubectl config: Expecting value: line 1 column 1 (char 0)" mock_get_contexts.side_effect = JumpstarterKubernetesError(error_msg) result = await get_cluster_info("test-context") - assert result.accessible is False # Function failed + assert result.accessible is False assert "Failed to get cluster info" in result.error assert "Failed to parse kubectl config" in result.error + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + async def test_get_cluster_info_context_not_found(self, mock_get_contexts): + mock_get_contexts.return_value = [ + {"name": "other-context", "cluster": "other", "server": "https://other", "user": "u", "namespace": "default", "current": False} + ] + + result = await get_cluster_info("missing-context") + + assert result.name == "missing-context" + assert result.accessible is False + assert "not found" in result.error + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + @patch("jumpstarter_kubernetes.cluster.kubectl.check_jumpstarter_installation") + async def test_get_cluster_info_inaccessible_cluster(self, mock_check_jumpstarter, mock_run_command, mock_get_contexts): + mock_get_contexts.return_value = [ + {"name": "test-context", "cluster": "test-cluster", "server": "https://test.example.com", "user": "test-user", "namespace": "default", "current": False} + ] + mock_run_command.return_value = (1, "", "connection refused") + + result = await get_cluster_info("test-context") + + assert result.accessible is False + assert result.jumpstarter.installed is False + assert result.jumpstarter.error == "Cluster not accessible" + mock_check_jumpstarter.assert_not_called() + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + @patch("jumpstarter_kubernetes.cluster.kubectl.check_jumpstarter_installation") + async def test_get_cluster_info_version_parse_failure(self, mock_check_jumpstarter, mock_run_command, mock_get_contexts): + mock_get_contexts.return_value = [ + {"name": "test-context", "cluster": "test-cluster", "server": "https://test.example.com", "user": "test-user", "namespace": "default", "current": False} + ] + mock_run_command.return_value = (0, "not json", "") + mock_check_jumpstarter.return_value = V1Alpha1JumpstarterInstance(installed=False) + + result = await get_cluster_info("test-context") + + assert result.accessible is True + assert result.version == "unknown" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_get_cluster_info_version_command_runtime_error(self, mock_run_command, mock_get_contexts): + mock_get_contexts.return_value = [ + {"name": "test-context", "cluster": "test-cluster", "server": "https://test.example.com", "user": "test-user", "namespace": "default", "current": False} + ] + mock_run_command.side_effect = RuntimeError("command failed") + + result = await get_cluster_info("test-context") + + assert result.accessible is False + assert result.version is None + class TestListClusters: """Test cluster listing functionality.""" @@ -401,3 +572,29 @@ async def test_list_clusters_custom_parameters(self, mock_get_contexts): await list_clusters(kubectl="custom-kubectl", minikube="custom-minikube") mock_get_contexts.assert_called_once_with("custom-kubectl") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + @patch("jumpstarter_kubernetes.cluster.kubectl.get_cluster_info") + async def test_list_clusters_with_type_filter(self, mock_get_cluster_info, mock_get_contexts): + mock_get_contexts.return_value = [ + {"name": "kind-ctx", "cluster": "kind-cluster", "server": "https://kind", "user": "u", "current": True}, + {"name": "remote-ctx", "cluster": "remote-cluster", "server": "https://remote", "user": "u", "current": False}, + ] + + kind_info = V1Alpha1ClusterInfo( + name="kind-ctx", cluster="kind-cluster", server="https://kind", user="u", + namespace="default", is_current=True, type="kind", accessible=True, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + ) + remote_info = V1Alpha1ClusterInfo( + name="remote-ctx", cluster="remote-cluster", server="https://remote", user="u", + namespace="default", is_current=False, type="remote", accessible=False, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + ) + mock_get_cluster_info.side_effect = [kind_info, remote_info] + + result = await list_clusters(cluster_type_filter="kind") + + assert len(result.items) == 1 + assert result.items[0].name == "kind-ctx" diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py index 5e966fbdb..de9d68a53 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py @@ -162,3 +162,69 @@ async def test_create_cluster_and_install_unsupported_cluster_type(self): await create_cluster_and_install("remote", False, "test-cluster", "", "", "kind", "minikube") assert "Unsupported cluster_type: remote" in str(exc_info.value) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.create_kind_cluster_with_options") + @patch("jumpstarter_kubernetes.cluster.operations.configure_endpoints") + @patch("jumpstarter_kubernetes.cluster.operations.install_jumpstarter_operator") + async def test_create_cluster_and_install_force_recreate_confirmed( + self, mock_install, mock_configure, mock_create + ): + mock_configure.return_value = ("192.168.1.100", "test.domain", "grpc.test:8082", "router.test:8083") + + from jumpstarter_kubernetes.callbacks import SilentCallback + + callback = SilentCallback() + + await create_cluster_and_install( + "kind", True, "test-cluster", "", "", "kind", "minikube", + version="1.0.0", callback=callback, + ) + + mock_create.assert_called_once() + mock_install.assert_called_once() + + @pytest.mark.asyncio + async def test_create_cluster_and_install_force_recreate_cancelled(self): + from jumpstarter_kubernetes.callbacks import OutputCallback + from jumpstarter_kubernetes.exceptions import ClusterOperationError + + class RejectingCallback: + def progress(self, message): pass + def success(self, message): pass + def warning(self, message): pass + def error(self, message): pass + def confirm(self, prompt): return False + + with pytest.raises(ClusterOperationError, match="User cancelled"): + await create_cluster_and_install( + "kind", True, "test-cluster", "", "", "kind", "minikube", + version="1.0.0", callback=RejectingCallback(), + ) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.create_kind_cluster_with_options") + @patch("jumpstarter_kubernetes.cluster.operations.configure_endpoints") + @patch("jumpstarter_kubernetes.cluster.operations.install_jumpstarter_operator") + async def test_create_cluster_and_install_calls_operator_installer( + self, mock_install, mock_configure, mock_create + ): + mock_configure.return_value = ("192.168.1.100", "test.domain", "grpc.test:8082", "router.test:8083") + + await create_cluster_and_install( + "kind", False, "test-cluster", "", "", "kind", "minikube", + version="1.0.0", operator_installer="/path/to/installer", + ) + + mock_install.assert_called_once_with( + version="1.0.0", + namespace="jumpstarter-lab", + basedomain="test.domain", + grpc_endpoint="grpc.test:8082", + router_endpoint="router.test:8083", + mode="nodeport", + kubeconfig=None, + context=None, + operator_installer="/path/to/installer", + callback=ANY, + ) From 76b85fbc2a5639097605909f85b0165d3c6e6d91 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 16 Apr 2026 16:21:13 +0200 Subject: [PATCH 21/54] fix: resolve lint violations and improve test coverage for helm-removal changes Break long lines in kubectl_test.py to stay within 120 character limit and remove unused OutputCallback import in operations_test.py. Generated-By: Forge/20260416_161341_490145_52646d64 Co-Authored-By: Claude Opus 4.6 --- .../cluster/kubectl_test.py | 64 ++++++++++++++++--- .../cluster/operations_test.py | 1 - 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index d76811e78..e66603aea 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -452,7 +452,14 @@ async def test_get_cluster_info_invalid_json(self, mock_get_contexts): @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") async def test_get_cluster_info_context_not_found(self, mock_get_contexts): mock_get_contexts.return_value = [ - {"name": "other-context", "cluster": "other", "server": "https://other", "user": "u", "namespace": "default", "current": False} + { + "name": "other-context", + "cluster": "other", + "server": "https://other", + "user": "u", + "namespace": "default", + "current": False, + } ] result = await get_cluster_info("missing-context") @@ -465,9 +472,18 @@ async def test_get_cluster_info_context_not_found(self, mock_get_contexts): @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") @patch("jumpstarter_kubernetes.cluster.kubectl.check_jumpstarter_installation") - async def test_get_cluster_info_inaccessible_cluster(self, mock_check_jumpstarter, mock_run_command, mock_get_contexts): + async def test_get_cluster_info_inaccessible_cluster( + self, mock_check_jumpstarter, mock_run_command, mock_get_contexts + ): mock_get_contexts.return_value = [ - {"name": "test-context", "cluster": "test-cluster", "server": "https://test.example.com", "user": "test-user", "namespace": "default", "current": False} + { + "name": "test-context", + "cluster": "test-cluster", + "server": "https://test.example.com", + "user": "test-user", + "namespace": "default", + "current": False, + } ] mock_run_command.return_value = (1, "", "connection refused") @@ -482,9 +498,18 @@ async def test_get_cluster_info_inaccessible_cluster(self, mock_check_jumpstarte @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") @patch("jumpstarter_kubernetes.cluster.kubectl.check_jumpstarter_installation") - async def test_get_cluster_info_version_parse_failure(self, mock_check_jumpstarter, mock_run_command, mock_get_contexts): + async def test_get_cluster_info_version_parse_failure( + self, mock_check_jumpstarter, mock_run_command, mock_get_contexts + ): mock_get_contexts.return_value = [ - {"name": "test-context", "cluster": "test-cluster", "server": "https://test.example.com", "user": "test-user", "namespace": "default", "current": False} + { + "name": "test-context", + "cluster": "test-cluster", + "server": "https://test.example.com", + "user": "test-user", + "namespace": "default", + "current": False, + } ] mock_run_command.return_value = (0, "not json", "") mock_check_jumpstarter.return_value = V1Alpha1JumpstarterInstance(installed=False) @@ -497,9 +522,18 @@ async def test_get_cluster_info_version_parse_failure(self, mock_check_jumpstart @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") - async def test_get_cluster_info_version_command_runtime_error(self, mock_run_command, mock_get_contexts): + async def test_get_cluster_info_version_command_runtime_error( + self, mock_run_command, mock_get_contexts + ): mock_get_contexts.return_value = [ - {"name": "test-context", "cluster": "test-cluster", "server": "https://test.example.com", "user": "test-user", "namespace": "default", "current": False} + { + "name": "test-context", + "cluster": "test-cluster", + "server": "https://test.example.com", + "user": "test-user", + "namespace": "default", + "current": False, + } ] mock_run_command.side_effect = RuntimeError("command failed") @@ -578,8 +612,20 @@ async def test_list_clusters_custom_parameters(self, mock_get_contexts): @patch("jumpstarter_kubernetes.cluster.kubectl.get_cluster_info") async def test_list_clusters_with_type_filter(self, mock_get_cluster_info, mock_get_contexts): mock_get_contexts.return_value = [ - {"name": "kind-ctx", "cluster": "kind-cluster", "server": "https://kind", "user": "u", "current": True}, - {"name": "remote-ctx", "cluster": "remote-cluster", "server": "https://remote", "user": "u", "current": False}, + { + "name": "kind-ctx", + "cluster": "kind-cluster", + "server": "https://kind", + "user": "u", + "current": True, + }, + { + "name": "remote-ctx", + "cluster": "remote-cluster", + "server": "https://remote", + "user": "u", + "current": False, + }, ] kind_info = V1Alpha1ClusterInfo( diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py index de9d68a53..9edbd7d86 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py @@ -186,7 +186,6 @@ async def test_create_cluster_and_install_force_recreate_confirmed( @pytest.mark.asyncio async def test_create_cluster_and_install_force_recreate_cancelled(self): - from jumpstarter_kubernetes.callbacks import OutputCallback from jumpstarter_kubernetes.exceptions import ClusterOperationError class RejectingCallback: From 9d6352389b798c0e5cd8815ab74520dea3456b67 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 17 Apr 2026 16:10:35 +0200 Subject: [PATCH 22/54] fix: add coverage source config for proper diff-cover path resolution Without source = ["."], coverage.xml in isolated mode generates relative paths without a source root, causing diff-cover to fail matching coverage data against git diff paths. Co-Authored-By: Claude Opus 4.6 --- python/packages/jumpstarter-cli-admin/pyproject.toml | 3 +++ python/packages/jumpstarter-cli-common/pyproject.toml | 3 +++ python/packages/jumpstarter-kubernetes/pyproject.toml | 3 +++ 3 files changed, 9 insertions(+) diff --git a/python/packages/jumpstarter-cli-admin/pyproject.toml b/python/packages/jumpstarter-cli-admin/pyproject.toml index 886d97675..1e2f9f20f 100644 --- a/python/packages/jumpstarter-cli-admin/pyproject.toml +++ b/python/packages/jumpstarter-cli-admin/pyproject.toml @@ -23,6 +23,9 @@ dev = [ [project.scripts] jmp-admin = "jumpstarter_cli_admin:admin" +[tool.coverage.run] +source = ["."] + [tool.hatch.build.targets.wheel] packages = ["jumpstarter_cli_admin"] diff --git a/python/packages/jumpstarter-cli-common/pyproject.toml b/python/packages/jumpstarter-cli-common/pyproject.toml index e892545fa..4dd42ecaf 100644 --- a/python/packages/jumpstarter-cli-common/pyproject.toml +++ b/python/packages/jumpstarter-cli-common/pyproject.toml @@ -25,6 +25,9 @@ dev = [ "pytest-cov>=5.0.0", ] +[tool.coverage.run] +source = ["."] + [tool.hatch.build.targets.wheel] packages = ["jumpstarter_cli_common"] diff --git a/python/packages/jumpstarter-kubernetes/pyproject.toml b/python/packages/jumpstarter-kubernetes/pyproject.toml index db3a20d62..a433d5932 100644 --- a/python/packages/jumpstarter-kubernetes/pyproject.toml +++ b/python/packages/jumpstarter-kubernetes/pyproject.toml @@ -24,6 +24,9 @@ dev = [ "pytest-cov>=5.0.0", ] +[tool.coverage.run] +source = ["."] + [tool.hatch.metadata.hooks.vcs.urls] Homepage = "https://jumpstarter.dev" source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" From e73eea0d2c2f8021007c57a1173173f762d0babf Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 30 Apr 2026 17:09:36 +0200 Subject: [PATCH 23/54] fix: surface error information from _check_cr_instances instead of swallowing silently When _check_cr_instances encountered a JSONDecodeError or RuntimeError, it returned an empty dict, making real failures (RBAC denial, network timeout) indistinguishable from "no CR instances deployed." Now returns an error key so callers can propagate meaningful diagnostics to the user. Generated-By: Forge/20260430_165609_270222_49ec83fe --- .../jumpstarter_kubernetes/cluster/kubectl.py | 4 ++-- .../jumpstarter_kubernetes/cluster/kubectl_test.py | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index cdf4b9d94..b9827328e 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -88,8 +88,8 @@ async def _check_cr_instances( "namespace": namespace or "unknown", "status": "installed", } - except (json.JSONDecodeError, RuntimeError): - pass + except (json.JSONDecodeError, RuntimeError) as e: + return {"error": f"CR instance check failed: {e}"} return {} diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index e66603aea..1ceae8081 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -239,7 +239,8 @@ async def test_cr_instances_json_decode_error(self, mock_run_command): result = await _check_cr_instances("kubectl", "test-context", None) - assert result == {} + assert "error" in result + assert "CR instance check failed" in result["error"] @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") @@ -248,7 +249,9 @@ async def test_cr_instances_runtime_error(self, mock_run_command): result = await _check_cr_instances("kubectl", "test-context", None) - assert result == {} + assert "error" in result + assert "CR instance check failed" in result["error"] + assert "kubectl not found" in result["error"] class TestCheckJumpstarterInstallation: From b5ef0c67052fc3dc592c787358a0160bb4924abb Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 30 Apr 2026 17:11:13 +0200 Subject: [PATCH 24/54] fix: narrow exception handling in get_cluster_info to avoid masking programming bugs The outer except block in get_cluster_info caught all Exception types, silently masking TypeError, AttributeError, and other programming errors by returning a valid-looking V1Alpha1ClusterInfo with an error string. Narrowed to RuntimeError, json.JSONDecodeError, and JumpstarterKubernetesError so that actual bugs propagate. Generated-By: Forge/20260430_165609_270222_49ec83fe --- .../jumpstarter_kubernetes/cluster/kubectl.py | 3 ++- .../jumpstarter_kubernetes/cluster/kubectl_test.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index b9827328e..599efbef2 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -4,6 +4,7 @@ from typing import Dict, List, Optional from ..clusters import V1Alpha1ClusterInfo, V1Alpha1ClusterList, V1Alpha1JumpstarterInstance +from ..exceptions import JumpstarterKubernetesError from .common import run_command @@ -215,7 +216,7 @@ async def get_cluster_info( jumpstarter=jumpstarter_info, ) - except Exception as e: + except (RuntimeError, json.JSONDecodeError, JumpstarterKubernetesError) as e: return V1Alpha1ClusterInfo( name=context, cluster="unknown", diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 1ceae8081..3ce6e23c5 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -545,6 +545,14 @@ async def test_get_cluster_info_version_command_runtime_error( assert result.accessible is False assert result.version is None + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + async def test_get_cluster_info_propagates_programming_errors(self, mock_get_contexts): + mock_get_contexts.side_effect = TypeError("unexpected type") + + with pytest.raises(TypeError, match="unexpected type"): + await get_cluster_info("test-context") + class TestListClusters: """Test cluster listing functionality.""" From 6af54d5eb299fc8ee8c4139926fab97bd691bf51 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 30 Apr 2026 17:27:22 +0200 Subject: [PATCH 25/54] fix: F001 - surface error on nonzero return code in _check_cr_instances (human-directed) Capture stderr and return an error dict when the CR instance check command exits with a nonzero return code, instead of silently falling through to return an empty dict. Generated-By: Forge/20260430_165609_270222_49ec83fe Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter_kubernetes/cluster/kubectl.py | 4 +++- .../jumpstarter_kubernetes/cluster/kubectl_test.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 599efbef2..62f2a3c30 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -80,7 +80,7 @@ async def _check_cr_instances( cr_resource = "jumpstarters.operator.jumpstarter.dev" try: cr_cmd = [kubectl, "--context", context, "get", cr_resource, "-A", "-o", "json"] - cr_returncode, cr_stdout, _ = await run_command(cr_cmd) + cr_returncode, cr_stdout, cr_stderr = await run_command(cr_cmd) if cr_returncode == 0: cr_data = json.loads(cr_stdout) if cr_data.get("items"): @@ -89,6 +89,8 @@ async def _check_cr_instances( "namespace": namespace or "unknown", "status": "installed", } + elif cr_returncode != 0: + return {"error": f"CR instance check failed (exit {cr_returncode}): {cr_stderr or cr_stdout}"} except (json.JSONDecodeError, RuntimeError) as e: return {"error": f"CR instance check failed: {e}"} return {} diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 3ce6e23c5..1ee035587 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -230,7 +230,9 @@ async def test_cr_instances_nonzero_return_code(self, mock_run_command): result = await _check_cr_instances("kubectl", "test-context", None) - assert result == {} + assert "error" in result + assert "exit 1" in result["error"] + assert "forbidden" in result["error"] @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") From 102a78fb2f96bf4d45f2a34892dd38280494faeb Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 30 Apr 2026 17:28:13 +0200 Subject: [PATCH 26/54] fix: F002 - narrow list_clusters exception clause to avoid masking programming bugs (human-directed) Change except Exception to except (RuntimeError, json.JSONDecodeError, JumpstarterKubernetesError) to match the pattern already used by get_cluster_info, allowing TypeError and similar programming errors to propagate uncaught. Generated-By: Forge/20260430_165609_270222_49ec83fe Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter_kubernetes/cluster/kubectl.py | 3 +-- .../jumpstarter_kubernetes/cluster/kubectl_test.py | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 62f2a3c30..9a7abf8bb 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -255,8 +255,7 @@ async def list_clusters( return V1Alpha1ClusterList(items=cluster_infos) - except Exception as e: - # Return empty list with error in the first cluster + except (RuntimeError, json.JSONDecodeError, JumpstarterKubernetesError) as e: error_cluster = V1Alpha1ClusterInfo( name="error", cluster="error", diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 1ee035587..6742af333 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -657,3 +657,11 @@ async def test_list_clusters_with_type_filter(self, mock_get_cluster_info, mock_ assert len(result.items) == 1 assert result.items[0].name == "kind-ctx" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + async def test_list_clusters_propagates_programming_errors(self, mock_get_contexts): + mock_get_contexts.side_effect = TypeError("unexpected type") + + with pytest.raises(TypeError, match="unexpected type"): + await list_clusters() From 4041f40309b8be69ebbfb7265a2132ceda25623c Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 15:06:49 +0200 Subject: [PATCH 27/54] test: exercise custom minikube path in test_list_clusters_custom_parameters The test was returning an empty list from mock_get_contexts, causing list_clusters to exit early without ever calling get_cluster_info with the custom minikube parameter. Now returns at least one context, mocks get_cluster_info, and asserts it receives minikube="custom-minikube". Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../cluster/kubectl_test.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 6742af333..bb63bcee0 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -613,12 +613,28 @@ async def test_list_clusters_context_error(self, mock_get_contexts): @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") - async def test_list_clusters_custom_parameters(self, mock_get_contexts): - mock_get_contexts.return_value = [] + @patch("jumpstarter_kubernetes.cluster.kubectl.get_cluster_info") + async def test_list_clusters_custom_parameters(self, mock_get_cluster_info, mock_get_contexts): + mock_get_contexts.return_value = [ + { + "name": "ctx", + "cluster": "cluster", + "server": "https://server", + "user": "u", + "current": True, + }, + ] + mock_get_cluster_info.return_value = V1Alpha1ClusterInfo( + name="ctx", cluster="cluster", server="https://server", user="u", + namespace="default", is_current=True, type="kind", accessible=True, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + ) - await list_clusters(kubectl="custom-kubectl", minikube="custom-minikube") + result = await list_clusters(kubectl="custom-kubectl", minikube="custom-minikube") + assert len(result.items) == 1 mock_get_contexts.assert_called_once_with("custom-kubectl") + mock_get_cluster_info.assert_called_once_with("ctx", "custom-kubectl", "custom-minikube") @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") From 9cf659ee2d9c335d5063416c8e7a384f732dd622 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 15:07:11 +0200 Subject: [PATCH 28/54] fix: propagate kubectl errors in compat setup baseDomain fetch Remove 2>/dev/null and || true from baseDomain kubectl query in setup_test_environment. Errors from kubectl (RBAC, connectivity) are now visible instead of silently masked, matching the pattern used in e2e/setup-e2e.sh. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- e2e/compat/setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index a5c74e948..f5b98f5b4 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -239,7 +239,7 @@ setup_test_environment() { # Get the controller endpoint from Jumpstarter CR export ENDPOINT local BASEDOMAIN - BASEDOMAIN=$(kubectl get jumpstarter -n "${JS_NAMESPACE}" jumpstarter -o jsonpath='{.spec.baseDomain}' 2>/dev/null) || true + BASEDOMAIN=$(kubectl get jumpstarter -n "${JS_NAMESPACE}" jumpstarter -o jsonpath='{.spec.baseDomain}') if [ -z "${BASEDOMAIN}" ]; then log_error "Failed to get baseDomain from Jumpstarter CR in namespace ${JS_NAMESPACE}. Is the controller deployed with a Jumpstarter CR?" exit 1 From 0478adf34dfae56944571cdd51c68a1ba19aa6ba Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 15:07:59 +0200 Subject: [PATCH 29/54] fix: return explicit installed False from _check_cr_instances on empty items Return {"installed": False} instead of {} when no CR instances are found. This makes the function's contract unambiguous -- callers can distinguish "no instances found" from "check was never performed." Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 2 +- .../jumpstarter_kubernetes/cluster/kubectl_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 9a7abf8bb..78c49dc9d 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -93,7 +93,7 @@ async def _check_cr_instances( return {"error": f"CR instance check failed (exit {cr_returncode}): {cr_stderr or cr_stdout}"} except (json.JSONDecodeError, RuntimeError) as e: return {"error": f"CR instance check failed: {e}"} - return {} + return {"installed": False} async def check_jumpstarter_installation( diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index bb63bcee0..8214e17fe 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -221,7 +221,7 @@ async def test_cr_instances_empty_items(self, mock_run_command): result = await _check_cr_instances("kubectl", "test-context", None) - assert result == {} + assert result == {"installed": False} @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") From 915912ae0d8520a7265d0483d3c186601bacb106 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 15:08:26 +0200 Subject: [PATCH 30/54] fix: scope BASEDOMAIN and endpoint vars as local in deploy_old_controller BASEDOMAIN was set without local in deploy_old_controller, causing it to leak into global scope. Also scoped GRPC_ENDPOINT and GRPC_ROUTER_ENDPOINT as local for consistency since they are only used within the function. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- e2e/compat/setup.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index f5b98f5b4..46e5f1d35 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -97,9 +97,9 @@ deploy_old_controller() { # Compute networking variables local IP IP=$(get_external_ip) - BASEDOMAIN="jumpstarter.${IP}.nip.io" - GRPC_ENDPOINT="grpc.${BASEDOMAIN}:8082" - GRPC_ROUTER_ENDPOINT="router.${BASEDOMAIN}:8083" + local BASEDOMAIN="jumpstarter.${IP}.nip.io" + local GRPC_ENDPOINT="grpc.${BASEDOMAIN}:8082" + local GRPC_ROUTER_ENDPOINT="router.${BASEDOMAIN}:8083" kubectl config use-context kind-jumpstarter From cc5b2638e5ec87df05639cac342112368d2b6a62 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 15:09:09 +0200 Subject: [PATCH 31/54] test: cover CR instance check error path in check_jumpstarter_installation Add test for the scenario where CRD query succeeds with the operator CRD present, but the CR instance query returns non-zero (e.g. RBAC denial). Verifies has_crds is True, installed is False, and error contains the failure details. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../cluster/kubectl_test.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 8214e17fe..b447a8a5c 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -394,6 +394,25 @@ async def test_check_jumpstarter_installation_cr_check_empty_items(self, mock_ru assert result.has_crds is True assert result.installed is False + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_jumpstarter_installation_cr_check_error(self, mock_run_command): + crds_response = {"items": [ + {"metadata": {"name": "jumpstarters.operator.jumpstarter.dev"}}, + ]} + + mock_run_command.side_effect = [ + (0, json.dumps(crds_response), ""), + (1, "", "forbidden: User cannot list resource"), + ] + + result = await check_jumpstarter_installation("test-context") + + assert result.has_crds is True + assert result.installed is False + assert result.error is not None + assert "forbidden" in result.error + class TestGetClusterInfo: """Test cluster info retrieval.""" From 0e2390f9d9e29816c0c6506fe90d1df74ad5c73e Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 15:49:33 +0200 Subject: [PATCH 32/54] test: add mock-based tests for controller version resolution Adds controller_test.py with 10 tests covering: - Correct Quay.io API URL is requested - Compatible version selection (same major.minor) - Fallback to latest when no compatible version - Handling of None client version - Invalid semver tags are skipped - Malformed tag entries are skipped - Error cases: unexpected response, no valid versions, invalid client version, fetch failure Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../jumpstarter_kubernetes/controller_test.py | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller_test.py diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller_test.py new file mode 100644 index 000000000..cd378388b --- /dev/null +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller_test.py @@ -0,0 +1,218 @@ +"""Tests for controller version resolution.""" + +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from jumpstarter_kubernetes.controller import get_latest_compatible_controller_version +from jumpstarter_kubernetes.exceptions import JumpstarterKubernetesError + + +class TestGetLatestCompatibleControllerVersion: + """Test controller version resolution from Quay.io API.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession") + async def test_requests_correct_url(self, mock_session_class): + tags_response = {"tags": [{"name": "v0.5.0"}]} + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value=tags_response) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = lambda url, **kwargs: mock_response + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session_class.return_value = mock_session + + captured_url = None + original_get = mock_session.get + + def capture_get(url, **kwargs): + nonlocal captured_url + captured_url = url + return original_get(url, **kwargs) + + mock_session.get = capture_get + + await get_latest_compatible_controller_version("v0.5.0") + + assert captured_url == "https://quay.io/api/v1/repository/jumpstarter-dev/jumpstarter-operator/tag/" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession") + async def test_returns_compatible_version(self, mock_session_class): + tags_response = { + "tags": [ + {"name": "v0.5.0"}, + {"name": "v0.5.1"}, + {"name": "v0.5.2"}, + {"name": "v0.6.0"}, + ] + } + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value=tags_response) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = lambda url, **kwargs: mock_response + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session_class.return_value = mock_session + + result = await get_latest_compatible_controller_version("v0.5.0") + + assert result == "v0.5.2" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession") + async def test_falls_back_to_latest_when_no_compatible(self, mock_session_class): + tags_response = { + "tags": [ + {"name": "v0.6.0"}, + {"name": "v0.7.0"}, + ] + } + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value=tags_response) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = lambda url, **kwargs: mock_response + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session_class.return_value = mock_session + + result = await get_latest_compatible_controller_version("v0.5.0") + + assert result == "v0.7.0" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession") + async def test_returns_latest_when_no_client_version(self, mock_session_class): + tags_response = { + "tags": [ + {"name": "v0.5.0"}, + {"name": "v0.6.0"}, + ] + } + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value=tags_response) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = lambda url, **kwargs: mock_response + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session_class.return_value = mock_session + + result = await get_latest_compatible_controller_version(None) + + assert result == "v0.6.0" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession") + async def test_skips_invalid_semver_tags(self, mock_session_class): + tags_response = { + "tags": [ + {"name": "latest"}, + {"name": "sha-abc123"}, + {"name": "v0.5.0"}, + ] + } + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value=tags_response) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = lambda url, **kwargs: mock_response + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session_class.return_value = mock_session + + result = await get_latest_compatible_controller_version("v0.5.0") + + assert result == "v0.5.0" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession") + async def test_raises_on_unexpected_response_format(self, mock_session_class): + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value="not a dict") + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = lambda url, **kwargs: mock_response + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session_class.return_value = mock_session + + with pytest.raises(JumpstarterKubernetesError, match="Unexpected response"): + await get_latest_compatible_controller_version("v0.5.0") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession") + async def test_raises_on_no_valid_versions(self, mock_session_class): + tags_response = {"tags": [{"name": "latest"}]} + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value=tags_response) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = lambda url, **kwargs: mock_response + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session_class.return_value = mock_session + + with pytest.raises(JumpstarterKubernetesError, match="No valid controller versions"): + await get_latest_compatible_controller_version("v0.5.0") + + @pytest.mark.asyncio + async def test_raises_on_invalid_client_version(self): + with pytest.raises(JumpstarterKubernetesError, match="Invalid client version"): + await get_latest_compatible_controller_version("not-a-version") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession") + async def test_raises_on_fetch_failure(self, mock_session_class): + mock_session = AsyncMock() + mock_session.get = AsyncMock(side_effect=Exception("connection refused")) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session_class.return_value = mock_session + + with pytest.raises(JumpstarterKubernetesError, match="Failed to fetch controller versions"): + await get_latest_compatible_controller_version("v0.5.0") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession") + async def test_skips_malformed_tag_entries(self, mock_session_class): + tags_response = { + "tags": [ + {"no_name_key": "oops"}, + "not a dict", + {"name": "v0.5.0"}, + ] + } + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value=tags_response) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = lambda url, **kwargs: mock_response + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session_class.return_value = mock_session + + result = await get_latest_compatible_controller_version("v0.5.0") + + assert result == "v0.5.0" From c1471f606129a3db0e03f2c7033a8e85407fc51f Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 15:50:27 +0200 Subject: [PATCH 33/54] fix: correct get_kubectl_contexts return type to List[Dict[str, Any]] The "current" field in the returned dict is a bool, not a str. Update the return type annotation from List[Dict[str, str]] to List[Dict[str, Any]] to reflect the actual return value. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 4 ++-- .../cluster/kubectl_test.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 78c49dc9d..6cf5795d4 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -1,7 +1,7 @@ """Kubectl operations for cluster management.""" import json -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from ..clusters import V1Alpha1ClusterInfo, V1Alpha1ClusterList, V1Alpha1JumpstarterInstance from ..exceptions import JumpstarterKubernetesError @@ -22,7 +22,7 @@ async def check_kubernetes_access(context: Optional[str] = None, kubectl: str = return False -async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[Dict[str, str]]: +async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[Dict[str, Any]]: """Get all kubectl contexts.""" contexts = [] diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index b447a8a5c..40b4becc9 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -179,6 +179,26 @@ async def test_get_kubectl_contexts_invalid_json(self, mock_run_command): with pytest.raises(KubeconfigError, match="Failed to parse kubectl config"): await get_kubectl_contexts() + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_get_kubectl_contexts_current_field_is_bool(self, mock_run_command): + kubectl_config = { + "current-context": "ctx-a", + "contexts": [ + {"name": "ctx-a", "context": {"cluster": "c", "user": "u"}}, + {"name": "ctx-b", "context": {"cluster": "c", "user": "u"}}, + ], + "clusters": [{"name": "c", "cluster": {"server": "https://s"}}], + } + mock_run_command.return_value = (0, json.dumps(kubectl_config), "") + + result = await get_kubectl_contexts() + + assert isinstance(result[0]["current"], bool) + assert result[0]["current"] is True + assert isinstance(result[1]["current"], bool) + assert result[1]["current"] is False + @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") async def test_get_kubectl_contexts_custom_kubectl(self, mock_run_command): From 86cc4b7294ce8a998be030f8ad8504b808c776ea Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 15:51:26 +0200 Subject: [PATCH 34/54] fix: use Optional[OutputCallback] for nullable callback parameters The callback parameter defaults to None but was typed as OutputCallback (a Protocol), which is not correct since None is not an OutputCallback. Use Optional[OutputCallback] to match the actual default value. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../jumpstarter_kubernetes/cluster/operations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py index 40b50f157..3e9a72930 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py @@ -51,7 +51,7 @@ def validate_cluster_type_selection( async def delete_cluster_by_name( # noqa: C901 - cluster_name: str, cluster_type: Optional[str] = None, force: bool = False, callback: OutputCallback = None + cluster_name: str, cluster_type: Optional[str] = None, force: bool = False, callback: Optional[OutputCallback] = None ) -> None: """Delete a cluster by name, with auto-detection if type not specified.""" if callback is None: @@ -125,7 +125,7 @@ async def create_cluster_and_install( # noqa: C901 basedomain: Optional[str] = None, grpc_endpoint: Optional[str] = None, router_endpoint: Optional[str] = None, - callback: OutputCallback = None, + callback: Optional[OutputCallback] = None, k3s_ssh_host: Optional[str] = None, operator_installer: Optional[str] = None, ) -> None: @@ -217,7 +217,7 @@ async def create_cluster_only( kind: str, minikube: str, custom_certs: Optional[str] = None, - callback: OutputCallback = None, + callback: Optional[OutputCallback] = None, ) -> None: """Create a cluster without installing Jumpstarter.""" await create_cluster_and_install( From ab3c371c34448011b62d1fe869e702b31c478524 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 15:51:57 +0200 Subject: [PATCH 35/54] fix: enable branch coverage in coverage configuration Add branch = true to [tool.coverage.run] in jumpstarter-kubernetes, jumpstarter-cli-admin, and jumpstarter-cli-common pyproject.toml files to meet the evaluability principle requiring branch coverage when adding coverage configuration. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- python/packages/jumpstarter-cli-admin/pyproject.toml | 1 + python/packages/jumpstarter-cli-common/pyproject.toml | 1 + python/packages/jumpstarter-kubernetes/pyproject.toml | 1 + 3 files changed, 3 insertions(+) diff --git a/python/packages/jumpstarter-cli-admin/pyproject.toml b/python/packages/jumpstarter-cli-admin/pyproject.toml index 1e2f9f20f..84bdbd76b 100644 --- a/python/packages/jumpstarter-cli-admin/pyproject.toml +++ b/python/packages/jumpstarter-cli-admin/pyproject.toml @@ -25,6 +25,7 @@ jmp-admin = "jumpstarter_cli_admin:admin" [tool.coverage.run] source = ["."] +branch = true [tool.hatch.build.targets.wheel] packages = ["jumpstarter_cli_admin"] diff --git a/python/packages/jumpstarter-cli-common/pyproject.toml b/python/packages/jumpstarter-cli-common/pyproject.toml index 4dd42ecaf..4b6512e92 100644 --- a/python/packages/jumpstarter-cli-common/pyproject.toml +++ b/python/packages/jumpstarter-cli-common/pyproject.toml @@ -27,6 +27,7 @@ dev = [ [tool.coverage.run] source = ["."] +branch = true [tool.hatch.build.targets.wheel] packages = ["jumpstarter_cli_common"] diff --git a/python/packages/jumpstarter-kubernetes/pyproject.toml b/python/packages/jumpstarter-kubernetes/pyproject.toml index a433d5932..177f3a7c8 100644 --- a/python/packages/jumpstarter-kubernetes/pyproject.toml +++ b/python/packages/jumpstarter-kubernetes/pyproject.toml @@ -26,6 +26,7 @@ dev = [ [tool.coverage.run] source = ["."] +branch = true [tool.hatch.metadata.hooks.vcs.urls] Homepage = "https://jumpstarter.dev" From 00a7e1192b9963226ac8383295fef9590b913b35 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 16:11:59 +0200 Subject: [PATCH 36/54] fix: add CrInstanceResult TypedDict and include installed in all error paths All return paths from _check_cr_instances now consistently include the "installed" key. The error path previously returned only {"error": ...}, relying on the caller's pre-initialized default. The tautological elif is replaced with else. A CrInstanceResult TypedDict documents the contract. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 20 ++++++++++++++----- .../cluster/kubectl_test.py | 3 +++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 6cf5795d4..f42f5af5d 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -1,7 +1,7 @@ """Kubectl operations for cluster management.""" import json -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, TypedDict from ..clusters import V1Alpha1ClusterInfo, V1Alpha1ClusterList, V1Alpha1JumpstarterInstance from ..exceptions import JumpstarterKubernetesError @@ -73,9 +73,16 @@ async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[Dict[str, Any]] raise KubeconfigError(f"Error listing kubectl contexts: {e}") from e +class CrInstanceResult(TypedDict, total=False): + installed: bool + namespace: str + status: str + error: str + + async def _check_cr_instances( kubectl: str, context: str, namespace: Optional[str] -) -> dict: +) -> CrInstanceResult: """Query for Jumpstarter CR instances to confirm full installation.""" cr_resource = "jumpstarters.operator.jumpstarter.dev" try: @@ -89,10 +96,13 @@ async def _check_cr_instances( "namespace": namespace or "unknown", "status": "installed", } - elif cr_returncode != 0: - return {"error": f"CR instance check failed (exit {cr_returncode}): {cr_stderr or cr_stdout}"} + else: + return { + "installed": False, + "error": f"CR instance check failed (exit {cr_returncode}): {cr_stderr or cr_stdout}", + } except (json.JSONDecodeError, RuntimeError) as e: - return {"error": f"CR instance check failed: {e}"} + return {"installed": False, "error": f"CR instance check failed: {e}"} return {"installed": False} diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 40b4becc9..2efabd04b 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -253,6 +253,7 @@ async def test_cr_instances_nonzero_return_code(self, mock_run_command): assert "error" in result assert "exit 1" in result["error"] assert "forbidden" in result["error"] + assert result["installed"] is False @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") @@ -263,6 +264,7 @@ async def test_cr_instances_json_decode_error(self, mock_run_command): assert "error" in result assert "CR instance check failed" in result["error"] + assert result["installed"] is False @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") @@ -274,6 +276,7 @@ async def test_cr_instances_runtime_error(self, mock_run_command): assert "error" in result assert "CR instance check failed" in result["error"] assert "kubectl not found" in result["error"] + assert result["installed"] is False class TestCheckJumpstarterInstallation: From 1e3d5a0f8cecf3753c6735cfc550d6d64a369353 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 16:12:55 +0200 Subject: [PATCH 37/54] fix: define KubectlContext TypedDict for get_kubectl_contexts return type Replace the weakened Dict[str, Any] return annotation with a TypedDict that captures the known shape (name, cluster, server, user, namespace as str; current as bool). This strengthens the implicit contract for callers that access these keys by string literal. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 13 +++++++++++-- .../cluster/kubectl_test.py | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index f42f5af5d..9aae02d0f 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -1,13 +1,22 @@ """Kubectl operations for cluster management.""" import json -from typing import Any, Dict, List, Optional, TypedDict +from typing import List, Optional, TypedDict from ..clusters import V1Alpha1ClusterInfo, V1Alpha1ClusterList, V1Alpha1JumpstarterInstance from ..exceptions import JumpstarterKubernetesError from .common import run_command +class KubectlContext(TypedDict): + name: str + cluster: str + server: str + user: str + namespace: str + current: bool + + async def check_kubernetes_access(context: Optional[str] = None, kubectl: str = "kubectl") -> bool: """Check if Kubernetes cluster is accessible.""" try: @@ -22,7 +31,7 @@ async def check_kubernetes_access(context: Optional[str] = None, kubectl: str = return False -async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[Dict[str, Any]]: +async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[KubectlContext]: """Get all kubectl contexts.""" contexts = [] diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 2efabd04b..f90c42823 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -6,6 +6,7 @@ import pytest from jumpstarter_kubernetes.cluster.kubectl import ( + KubectlContext, _check_cr_instances, check_jumpstarter_installation, check_kubernetes_access, @@ -209,6 +210,21 @@ async def test_get_kubectl_contexts_custom_kubectl(self, mock_run_command): mock_run_command.assert_called_once_with(["custom-kubectl", "config", "view", "-o", "json"]) + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_get_kubectl_contexts_has_all_typed_keys(self, mock_run_command): + kubectl_config = { + "current-context": "ctx", + "contexts": [{"name": "ctx", "context": {"cluster": "c", "user": "u"}}], + "clusters": [{"name": "c", "cluster": {"server": "https://s"}}], + } + mock_run_command.return_value = (0, json.dumps(kubectl_config), "") + + result = await get_kubectl_contexts() + + expected_keys = set(KubectlContext.__annotations__.keys()) + assert set(result[0].keys()) == expected_keys + class TestCheckCrInstances: """Test CR instance detection for Jumpstarter installation.""" From 600e526ac296155680e7571f09a4ea5f0a85fe83 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 16:13:46 +0200 Subject: [PATCH 38/54] fix: extract actual namespace from CR metadata in _check_cr_instances The function queries CRs across all namespaces (-A) but previously returned the caller-supplied namespace parameter, which was often None (yielding "unknown"). Now extracts the namespace from the first CR item's metadata, falling back to the parameter and then to "unknown". Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 3 ++- .../cluster/kubectl_test.py | 22 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 9aae02d0f..951c27a66 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -100,9 +100,10 @@ async def _check_cr_instances( if cr_returncode == 0: cr_data = json.loads(cr_stdout) if cr_data.get("items"): + cr_namespace = cr_data["items"][0].get("metadata", {}).get("namespace") return { "installed": True, - "namespace": namespace or "unknown", + "namespace": cr_namespace or namespace or "unknown", "status": "installed", } else: diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index f90c42823..6cd68343d 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -241,7 +241,27 @@ async def test_cr_instances_found_with_namespace(self, mock_run_command): @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") - async def test_cr_instances_found_without_namespace(self, mock_run_command): + async def test_cr_instances_extracts_namespace_from_metadata(self, mock_run_command): + cr_response = {"items": [{"metadata": {"name": "jumpstarter", "namespace": "from-cr"}}]} + mock_run_command.return_value = (0, json.dumps(cr_response), "") + + result = await _check_cr_instances("kubectl", "test-context", None) + + assert result == {"installed": True, "namespace": "from-cr", "status": "installed"} + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_cr_instances_falls_back_to_parameter_namespace(self, mock_run_command): + cr_response = {"items": [{"metadata": {"name": "jumpstarter"}}]} + mock_run_command.return_value = (0, json.dumps(cr_response), "") + + result = await _check_cr_instances("kubectl", "test-context", "param-ns") + + assert result == {"installed": True, "namespace": "param-ns", "status": "installed"} + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_cr_instances_unknown_when_no_namespace_available(self, mock_run_command): cr_response = {"items": [{"metadata": {"name": "jumpstarter"}}]} mock_run_command.return_value = (0, json.dumps(cr_response), "") From 4f8112eba6ae3af620af989050580f7481fb778a Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 16:14:25 +0200 Subject: [PATCH 39/54] fix: remove unreachable json.JSONDecodeError from outer except clauses In get_cluster_info and list_clusters, json.JSONDecodeError is caught locally within inner try blocks or wrapped as KubeconfigError by get_kubectl_contexts, so it never reaches the outer except clauses. Remove the dead exception type to avoid misleading readers. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 951c27a66..bb833ece8 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -238,7 +238,7 @@ async def get_cluster_info( jumpstarter=jumpstarter_info, ) - except (RuntimeError, json.JSONDecodeError, JumpstarterKubernetesError) as e: + except (RuntimeError, JumpstarterKubernetesError) as e: return V1Alpha1ClusterInfo( name=context, cluster="unknown", @@ -275,7 +275,7 @@ async def list_clusters( return V1Alpha1ClusterList(items=cluster_infos) - except (RuntimeError, json.JSONDecodeError, JumpstarterKubernetesError) as e: + except (RuntimeError, JumpstarterKubernetesError) as e: error_cluster = V1Alpha1ClusterInfo( name="error", cluster="error", From 65e4ca37dcca43b49beab87473de01e93199a1bc Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 16:15:25 +0200 Subject: [PATCH 40/54] fix: remove unused kind parameter from list_clusters The kind parameter was accepted but never referenced in the function body. Remove it from the function signature and update callers in the CLI admin package to stop passing it. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../jumpstarter-cli-admin/jumpstarter_cli_admin/get.py | 4 ++-- .../jumpstarter_kubernetes/cluster/kubectl.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index 0f0904355..271f70a0e 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -139,7 +139,7 @@ async def get_cluster( model_print(cluster_info, output) else: # List all clusters if no name provided - cluster_list = await list_clusters(type, kubectl, kind, minikube) + cluster_list = await list_clusters(type, kubectl, minikube) model_print(cluster_list, output) except click.ClickException: raise @@ -159,7 +159,7 @@ async def get_cluster( async def get_clusters(type: str, kubectl: str, kind: str, minikube: str, output: OutputType): """List all Kubernetes clusters with Jumpstarter status""" try: - cluster_list = await list_clusters(type, kubectl, kind, minikube) + cluster_list = await list_clusters(type, kubectl, minikube) # Use model_print for all output formats model_print(cluster_list, output) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index bb833ece8..7f0fcfe57 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -256,7 +256,6 @@ async def get_cluster_info( async def list_clusters( cluster_type_filter: str = "all", kubectl: str = "kubectl", - kind: str = "kind", minikube: str = "minikube", ) -> V1Alpha1ClusterList: """List all Kubernetes clusters with Jumpstarter status.""" From b0d47dc1ab15edacb187616d90c6938b1ef4b129 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 16:16:09 +0200 Subject: [PATCH 41/54] test: verify install_jumpstarter_operator arguments in default path The test_create_cluster_and_install_success test previously only asserted assert_called_once() without verifying the arguments passed to install_jumpstarter_operator. Add full keyword argument assertions matching the pattern used in test_create_cluster_and_install_calls_operator_installer. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../cluster/operations_test.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py index 9edbd7d86..9aba7ed03 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py @@ -141,7 +141,18 @@ async def test_create_cluster_and_install_success( mock_create.assert_called_once() mock_configure.assert_called_once() - mock_install.assert_called_once() + mock_install.assert_called_once_with( + version="1.0.0", + namespace="jumpstarter-lab", + basedomain="test.domain", + grpc_endpoint="grpc.test:8082", + router_endpoint="router.test:8083", + mode="nodeport", + kubeconfig=None, + context=None, + operator_installer=None, + callback=ANY, + ) @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.operations.create_kind_cluster_with_options") From 84d17515044a4cb0fbf84d5081ac5499ad3bedac Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 16:16:28 +0200 Subject: [PATCH 42/54] fix: correct COMPAT_CLIENT_VERSION default in header comment The comment claimed the default was 0.8.1 but the actual default on line 30 is 0.7.4. Update the comment to match the code. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- e2e/compat/setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index 46e5f1d35..d3ce8137d 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -8,7 +8,7 @@ # Environment variables: # COMPAT_SCENARIO - "old-controller" or "old-client" (required) # COMPAT_CONTROLLER_TAG - Controller release tag for old-controller scenario (default: v0.8.1) -# COMPAT_CLIENT_VERSION - PyPI version for old-client scenario (default: 0.8.1) +# COMPAT_CLIENT_VERSION - PyPI version for old-client scenario (default: 0.7.4) set -euo pipefail From 49e876a6ecc657b9f2217ca9e3a52b8313a0d02f Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 16:34:48 +0200 Subject: [PATCH 43/54] fix: add status to empty CR result and narrow exception handler in get_kubectl_contexts Add "status": "no-cr-instance" to the empty-items return path in _check_cr_instances to distinguish from error states. Narrow the outer except clause in get_kubectl_contexts from broad Exception to (RuntimeError, JumpstarterKubernetesError) for consistency with get_cluster_info and list_clusters. Generated-By: Forge/20260512_145216_1337542_9a078ae2 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 4 ++-- .../jumpstarter_kubernetes/cluster/kubectl_test.py | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 7f0fcfe57..668860d17 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -77,7 +77,7 @@ async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[KubectlContext] except json.JSONDecodeError as e: from ..exceptions import KubeconfigError raise KubeconfigError(f"Failed to parse kubectl config: {e}") from e - except Exception as e: + except (RuntimeError, JumpstarterKubernetesError) as e: from ..exceptions import KubeconfigError raise KubeconfigError(f"Error listing kubectl contexts: {e}") from e @@ -113,7 +113,7 @@ async def _check_cr_instances( } except (json.JSONDecodeError, RuntimeError) as e: return {"installed": False, "error": f"CR instance check failed: {e}"} - return {"installed": False} + return {"installed": False, "status": "no-cr-instance"} async def check_jumpstarter_installation( diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 6cd68343d..9366aac19 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -210,6 +210,14 @@ async def test_get_kubectl_contexts_custom_kubectl(self, mock_run_command): mock_run_command.assert_called_once_with(["custom-kubectl", "config", "view", "-o", "json"]) + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_get_kubectl_contexts_propagates_programming_errors(self, mock_run_command): + mock_run_command.return_value = (0, '{"contexts": [], "clusters": []}', "") + with patch("jumpstarter_kubernetes.cluster.kubectl.json.loads", side_effect=TypeError("unexpected type")): + with pytest.raises(TypeError, match="unexpected type"): + await get_kubectl_contexts() + @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") async def test_get_kubectl_contexts_has_all_typed_keys(self, mock_run_command): @@ -277,7 +285,7 @@ async def test_cr_instances_empty_items(self, mock_run_command): result = await _check_cr_instances("kubectl", "test-context", None) - assert result == {"installed": False} + assert result == {"installed": False, "status": "no-cr-instance"} @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") From ea9217f0a1ac1b4f3db284bcd039e101224da4f1 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 17:20:31 +0200 Subject: [PATCH 44/54] refactor: use typed result variants for _check_cr_instances return value Replace single CrInstanceResult TypedDict(total=False) with three distinct types (CrInstanceSuccess, CrInstanceError, CrInstanceNotFound) using Literal discriminants for static type narrowing. Replace dict.update() in check_jumpstarter_installation with explicit conditional field assignment. Make the no-instance return path explicit with an else clause. Add test for missing items key in JSON response. Generated-By: Forge/20260512_170224_1547662_b3e5aa08 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 78 ++++++++++++------- .../cluster/kubectl_test.py | 22 +++++- 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 668860d17..f41d68dc0 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -1,7 +1,7 @@ """Kubectl operations for cluster management.""" import json -from typing import List, Optional, TypedDict +from typing import List, Literal, Optional, TypedDict, Union from ..clusters import V1Alpha1ClusterInfo, V1Alpha1ClusterList, V1Alpha1JumpstarterInstance from ..exceptions import JumpstarterKubernetesError @@ -82,13 +82,24 @@ async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[KubectlContext] raise KubeconfigError(f"Error listing kubectl contexts: {e}") from e -class CrInstanceResult(TypedDict, total=False): - installed: bool +class CrInstanceSuccess(TypedDict): + installed: Literal[True] namespace: str status: str + + +class CrInstanceError(TypedDict): + installed: Literal[False] error: str +class CrInstanceNotFound(TypedDict): + installed: Literal[False] + + +CrInstanceResult = Union[CrInstanceSuccess, CrInstanceError, CrInstanceNotFound] + + async def _check_cr_instances( kubectl: str, context: str, namespace: Optional[str] ) -> CrInstanceResult: @@ -101,19 +112,36 @@ async def _check_cr_instances( cr_data = json.loads(cr_stdout) if cr_data.get("items"): cr_namespace = cr_data["items"][0].get("metadata", {}).get("namespace") - return { - "installed": True, - "namespace": cr_namespace or namespace or "unknown", - "status": "installed", - } + return CrInstanceSuccess( + installed=True, + namespace=cr_namespace or namespace or "unknown", + status="installed", + ) + else: + return CrInstanceNotFound(installed=False) else: - return { - "installed": False, - "error": f"CR instance check failed (exit {cr_returncode}): {cr_stderr or cr_stdout}", - } + return CrInstanceError( + installed=False, + error=f"CR instance check failed (exit {cr_returncode}): {cr_stderr or cr_stdout}", + ) except (json.JSONDecodeError, RuntimeError) as e: - return {"installed": False, "error": f"CR instance check failed: {e}"} - return {"installed": False, "status": "no-cr-instance"} + return CrInstanceError(installed=False, error=f"CR instance check failed: {e}") + + +def _parse_json_with_prefix(stdout: str) -> dict: + json_start = stdout.find("{") + if json_start >= 0: + return json.loads(stdout[json_start:]) + return json.loads(stdout) + + +def _apply_cr_result(result_data: dict, cr_result: CrInstanceResult) -> None: + if cr_result.get("installed"): + result_data["installed"] = True + result_data["namespace"] = cr_result.get("namespace") + result_data["status"] = cr_result.get("status") + elif "error" in cr_result: + result_data["error"] = cr_result["error"] async def check_jumpstarter_installation( @@ -140,25 +168,19 @@ async def check_jumpstarter_installation( result_data["error"] = f"Command failed: {stderr or stdout}" return V1Alpha1JumpstarterInstance(**result_data) - json_start = stdout.find("{") - if json_start >= 0: - json_output = stdout[json_start:] - crds = json.loads(json_output) - else: - crds = json.loads(stdout) - jumpstarter_crds = [] - for item in crds.get("items", []): - name = item.get("metadata", {}).get("name", "") - if "jumpstarter.dev" in name: - jumpstarter_crds.append(name) + crds = _parse_json_with_prefix(stdout) + jumpstarter_crds = [ + item.get("metadata", {}).get("name", "") + for item in crds.get("items", []) + if "jumpstarter.dev" in item.get("metadata", {}).get("name", "") + ] if jumpstarter_crds: result_data["has_crds"] = True if "jumpstarters.operator.jumpstarter.dev" in jumpstarter_crds: - result_data.update( - await _check_cr_instances(kubectl, context, namespace) - ) + cr_result = await _check_cr_instances(kubectl, context, namespace) + _apply_cr_result(result_data, cr_result) except json.JSONDecodeError as e: result_data["error"] = f"Failed to parse output: {e}" diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index 9366aac19..f41b8df27 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -6,6 +6,9 @@ import pytest from jumpstarter_kubernetes.cluster.kubectl import ( + CrInstanceError, + CrInstanceNotFound, + CrInstanceSuccess, KubectlContext, _check_cr_instances, check_jumpstarter_installation, @@ -246,6 +249,7 @@ async def test_cr_instances_found_with_namespace(self, mock_run_command): result = await _check_cr_instances("kubectl", "test-context", "custom-ns") assert result == {"installed": True, "namespace": "custom-ns", "status": "installed"} + assert set(result.keys()) == set(CrInstanceSuccess.__annotations__.keys()) @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") @@ -285,7 +289,19 @@ async def test_cr_instances_empty_items(self, mock_run_command): result = await _check_cr_instances("kubectl", "test-context", None) - assert result == {"installed": False, "status": "no-cr-instance"} + assert result == {"installed": False} + assert set(result.keys()) == set(CrInstanceNotFound.__annotations__.keys()) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_cr_instances_missing_items_key(self, mock_run_command): + cr_response = {"kind": "JumpstarterList"} + mock_run_command.return_value = (0, json.dumps(cr_response), "") + + result = await _check_cr_instances("kubectl", "test-context", None) + + assert result == {"installed": False} + assert set(result.keys()) == set(CrInstanceNotFound.__annotations__.keys()) @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") @@ -294,6 +310,7 @@ async def test_cr_instances_nonzero_return_code(self, mock_run_command): result = await _check_cr_instances("kubectl", "test-context", None) + assert set(result.keys()) == set(CrInstanceError.__annotations__.keys()) assert "error" in result assert "exit 1" in result["error"] assert "forbidden" in result["error"] @@ -306,6 +323,7 @@ async def test_cr_instances_json_decode_error(self, mock_run_command): result = await _check_cr_instances("kubectl", "test-context", None) + assert set(result.keys()) == set(CrInstanceError.__annotations__.keys()) assert "error" in result assert "CR instance check failed" in result["error"] assert result["installed"] is False @@ -317,6 +335,7 @@ async def test_cr_instances_runtime_error(self, mock_run_command): result = await _check_cr_instances("kubectl", "test-context", None) + assert set(result.keys()) == set(CrInstanceError.__annotations__.keys()) assert "error" in result assert "CR instance check failed" in result["error"] assert "kubectl not found" in result["error"] @@ -460,6 +479,7 @@ async def test_check_jumpstarter_installation_cr_check_empty_items(self, mock_ru assert result.has_crds is True assert result.installed is False + assert result.error is None @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") From 5d1d96117057ed43878ad45326cac250da49dd6e Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 17:27:20 +0200 Subject: [PATCH 45/54] fix: use type narrowing in _apply_cr_result instead of .get() Replace .get() calls with direct key access on CrInstanceResult Union to enable static type narrowing via the Literal discriminant. This ensures type checkers can verify field access is valid for each variant and that malformed dicts with installed=True but missing required fields raise KeyError rather than silently returning None. Add direct unit tests for _apply_cr_result covering all three CrInstanceResult variants plus malformed input rejection. Generated-By: Forge/20260512_170224_1547662_b3e5aa08 --- .../jumpstarter_kubernetes/cluster/kubectl.py | 6 +-- .../cluster/kubectl_test.py | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index f41d68dc0..5912a2be6 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -136,10 +136,10 @@ def _parse_json_with_prefix(stdout: str) -> dict: def _apply_cr_result(result_data: dict, cr_result: CrInstanceResult) -> None: - if cr_result.get("installed"): + if cr_result["installed"] is True: result_data["installed"] = True - result_data["namespace"] = cr_result.get("namespace") - result_data["status"] = cr_result.get("status") + result_data["namespace"] = cr_result["namespace"] + result_data["status"] = cr_result["status"] elif "error" in cr_result: result_data["error"] = cr_result["error"] diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index f41b8df27..f7d9c5ce8 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -10,6 +10,7 @@ CrInstanceNotFound, CrInstanceSuccess, KubectlContext, + _apply_cr_result, _check_cr_instances, check_jumpstarter_installation, check_kubernetes_access, @@ -342,6 +343,47 @@ async def test_cr_instances_runtime_error(self, mock_run_command): assert result["installed"] is False +class TestApplyCrResult: + """Test _apply_cr_result uses type-narrowed key access.""" + + def test_apply_cr_result_success_uses_direct_key_access(self): + result_data = {"installed": False, "namespace": None, "status": None, "error": None} + cr_result = CrInstanceSuccess(installed=True, namespace="test-ns", status="installed") + + _apply_cr_result(result_data, cr_result) + + assert result_data["installed"] is True + assert result_data["namespace"] == "test-ns" + assert result_data["status"] == "installed" + + def test_apply_cr_result_rejects_malformed_success_missing_namespace(self): + result_data = {"installed": False, "namespace": None, "status": None, "error": None} + malformed = {"installed": True} + + with pytest.raises(KeyError): + _apply_cr_result(result_data, malformed) + + def test_apply_cr_result_error_sets_error_field(self): + result_data = {"installed": False, "namespace": None, "status": None, "error": None} + cr_result = CrInstanceError(installed=False, error="something went wrong") + + _apply_cr_result(result_data, cr_result) + + assert result_data["error"] == "something went wrong" + assert result_data["installed"] is False + + def test_apply_cr_result_not_found_leaves_data_unchanged(self): + result_data = {"installed": False, "namespace": None, "status": None, "error": None} + cr_result = CrInstanceNotFound(installed=False) + + _apply_cr_result(result_data, cr_result) + + assert result_data["installed"] is False + assert result_data["namespace"] is None + assert result_data["status"] is None + assert result_data["error"] is None + + class TestCheckJumpstarterInstallation: """Test Jumpstarter installation checking via CRD detection.""" From f71d16db771878443f4ee4820d9b9a0b022aeae1 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 18:05:57 +0200 Subject: [PATCH 46/54] fix: resolve ruff lint violations Break long function signature in delete_cluster_by_name to stay within 120-char line limit and remove unused json import from controller_test. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter_kubernetes/cluster/operations.py | 5 ++++- .../jumpstarter_kubernetes/controller_test.py | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py index 3e9a72930..246aae924 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py @@ -51,7 +51,10 @@ def validate_cluster_type_selection( async def delete_cluster_by_name( # noqa: C901 - cluster_name: str, cluster_type: Optional[str] = None, force: bool = False, callback: Optional[OutputCallback] = None + cluster_name: str, + cluster_type: Optional[str] = None, + force: bool = False, + callback: Optional[OutputCallback] = None, ) -> None: """Delete a cluster by name, with auto-detection if type not specified.""" if callback is None: diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller_test.py index cd378388b..336dfe51a 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller_test.py @@ -1,6 +1,5 @@ """Tests for controller version resolution.""" -import json from unittest.mock import AsyncMock, patch import pytest From 38819b40095f633f465adacf2d6f6d4d09a5f60a Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 18:50:25 +0200 Subject: [PATCH 47/54] fix: use shared WaitForExporter in compat tests The inline waitForCompatExporter only ran kubectl wait, which can return before both conditions are fully propagated. The shared WaitForExporter also polls until exporterStatus reaches Available, preventing the race that caused flaky CI failures. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/test/compat_old_client_test.go | 5 +---- e2e/test/compat_old_controller_test.go | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/e2e/test/compat_old_client_test.go b/e2e/test/compat_old_client_test.go index ca112dd91..d5246f5f7 100644 --- a/e2e/test/compat_old_client_test.go +++ b/e2e/test/compat_old_client_test.go @@ -34,10 +34,7 @@ var _ = Describe("Compat: Old Client E2E Tests", Label("compat", "old-client"), ) waitForCompatExporter := func() { - time.Sleep(exporterPostDelay) - MustKubectl("-n", ns, "wait", "--timeout", "5m", - "--for=condition=Online", "--for=condition=Registered", - "exporters.jumpstarter.dev/compat-old-exporter") + WaitForExporter("compat-old-exporter") } BeforeAll(func() { diff --git a/e2e/test/compat_old_controller_test.go b/e2e/test/compat_old_controller_test.go index 3664ff53b..7d6173286 100644 --- a/e2e/test/compat_old_controller_test.go +++ b/e2e/test/compat_old_controller_test.go @@ -33,10 +33,7 @@ var _ = Describe("Compat: Old Controller E2E Tests", Label("compat", "old-contro ) waitForCompatExporter := func() { - time.Sleep(exporterPostDelay) - MustKubectl("-n", ns, "wait", "--timeout", "5m", - "--for=condition=Online", "--for=condition=Registered", - "exporters.jumpstarter.dev/compat-exporter") + WaitForExporter("compat-exporter") } BeforeAll(func() { From 04ca25d1d2344f0cefbf2f5dd94c67218bdfc2e8 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 20:41:15 +0200 Subject: [PATCH 48/54] fix: remove unused --kind CLI option from get cluster commands The kind parameter was removed from list_clusters and get_cluster_info but the --kind click option was left behind as dead code. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter-cli-admin/jumpstarter_cli_admin/get.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index 271f70a0e..3cee8663e 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -119,12 +119,11 @@ async def get_lease( "--type", type=click.Choice(["kind", "minikube", "remote", "all"]), default="all", help="Filter clusters by type" ) @click.option("--kubectl", type=str, help="Path or name of kubectl executable", default="kubectl") -@click.option("--kind", type=str, help="Path or name of kind executable", default="kind") @click.option("--minikube", type=str, help="Path or name of minikube executable", default="minikube") @opt_output_all @blocking async def get_cluster( - name: Optional[str], type: str, kubectl: str, kind: str, minikube: str, output: OutputType + name: Optional[str], type: str, kubectl: str, minikube: str, output: OutputType ): """Get information about a specific cluster or list all clusters""" try: @@ -152,11 +151,10 @@ async def get_cluster( "--type", type=click.Choice(["kind", "minikube", "remote", "all"]), default="all", help="Filter clusters by type" ) @click.option("--kubectl", type=str, help="Path or name of kubectl executable", default="kubectl") -@click.option("--kind", type=str, help="Path or name of kind executable", default="kind") @click.option("--minikube", type=str, help="Path or name of minikube executable", default="minikube") @opt_output_all @blocking -async def get_clusters(type: str, kubectl: str, kind: str, minikube: str, output: OutputType): +async def get_clusters(type: str, kubectl: str, minikube: str, output: OutputType): """List all Kubernetes clusters with Jumpstarter status""" try: cluster_list = await list_clusters(type, kubectl, minikube) From e0032b2080c6f470af21b6c915f65cba849db7e9 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 20:43:57 +0200 Subject: [PATCH 49/54] fix: use KubectlContext type annotation consistently Annotate context_info in get_cluster_info and export KubectlContext from the cluster package so external consumers can reference the type. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter_kubernetes/cluster/__init__.py | 2 ++ .../jumpstarter_kubernetes/cluster/kubectl.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py index dbbfb0def..7e95eda92 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py @@ -47,6 +47,7 @@ # Kubectl operations from .kubectl import ( + KubectlContext, check_jumpstarter_installation, check_kubernetes_access, get_cluster_info, @@ -82,6 +83,7 @@ __all__ = [ # Types "ClusterType", + "KubectlContext", # Common utilities "extract_host_from_ssh", "validate_cluster_name", diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 5912a2be6..90dfc5a80 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -198,7 +198,7 @@ async def get_cluster_info( """Get comprehensive cluster information.""" try: contexts = await get_kubectl_contexts(kubectl) - context_info = None + context_info: KubectlContext | None = None for ctx in contexts: if ctx["name"] == context: From f2b8b8f634861ef44e97af44412863b003148719 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 21:00:42 +0200 Subject: [PATCH 50/54] fix: delegate controller Makefile targets to operator Makefile Refactor build-installer, install, uninstall, and undeploy targets in the controller Makefile to delegate to the operator Makefile via $(MAKE) -C deploy/operator instead of directly referencing deploy/operator/config/* paths. This addresses the reviewer's concern that the controller Makefile should not directly manipulate operator config paths, and follows the existing delegation pattern already used by build-operator and manifests targets. Generated-By: Forge/20260512_204804_1774718_7f9a01ee --- controller/Makefile | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/controller/Makefile b/controller/Makefile index 7382e78f4..2c131146a 100644 --- a/controller/Makefile +++ b/controller/Makefile @@ -156,10 +156,8 @@ docker-buildx: ## Build and push docker image for the manager for cross-platform rm Dockerfile.cross .PHONY: build-installer -build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. - mkdir -p dist - cd deploy/operator/config/manager && $(KUSTOMIZE) edit set image quay.io/jumpstarter-dev/jumpstarter-operator=${IMG} - $(KUSTOMIZE) build deploy/operator/config/default > dist/install.yaml +build-installer: manifests generate ## Generate a consolidated YAML with CRDs and deployment. + $(MAKE) -C deploy/operator build-installer IMG=$(IMG) ##@ Deployment @@ -168,12 +166,12 @@ ifndef ignore-not-found endif .PHONY: install -install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. - $(KUSTOMIZE) build deploy/operator/config/crd | $(KUBECTL) apply -f - +install: manifests ## Install CRDs into the K8s cluster specified in ~/.kube/config. + $(MAKE) -C deploy/operator install .PHONY: uninstall -uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - $(KUSTOMIZE) build deploy/operator/config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - +uninstall: manifests ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. + $(MAKE) -C deploy/operator uninstall ignore-not-found=$(ignore-not-found) .PHONY: deploy deploy: cluster grpcurl ## Deploy controller using the operator. Set SKIP_BUILD=1 to skip image builds. Set CLUSTER_TYPE=k3s for k3s. @@ -204,8 +202,8 @@ deploy-exporters: ./hack/demoenv/prepare_exporters.sh .PHONY: undeploy -undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - $(KUSTOMIZE) build deploy/operator/config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - +undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. + $(MAKE) -C deploy/operator undeploy ignore-not-found=$(ignore-not-found) ##@ Dependencies From 54c2211b7d2631fbf7b56bb11dec402ec4a1952c Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 21:29:00 +0200 Subject: [PATCH 51/54] Revert "fix: delegate controller Makefile targets to operator Makefile" This reverts commit f2b8b8f634861ef44e97af44412863b003148719. --- controller/Makefile | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/controller/Makefile b/controller/Makefile index 2c131146a..7382e78f4 100644 --- a/controller/Makefile +++ b/controller/Makefile @@ -156,8 +156,10 @@ docker-buildx: ## Build and push docker image for the manager for cross-platform rm Dockerfile.cross .PHONY: build-installer -build-installer: manifests generate ## Generate a consolidated YAML with CRDs and deployment. - $(MAKE) -C deploy/operator build-installer IMG=$(IMG) +build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. + mkdir -p dist + cd deploy/operator/config/manager && $(KUSTOMIZE) edit set image quay.io/jumpstarter-dev/jumpstarter-operator=${IMG} + $(KUSTOMIZE) build deploy/operator/config/default > dist/install.yaml ##@ Deployment @@ -166,12 +168,12 @@ ifndef ignore-not-found endif .PHONY: install -install: manifests ## Install CRDs into the K8s cluster specified in ~/.kube/config. - $(MAKE) -C deploy/operator install +install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. + $(KUSTOMIZE) build deploy/operator/config/crd | $(KUBECTL) apply -f - .PHONY: uninstall -uninstall: manifests ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - $(MAKE) -C deploy/operator uninstall ignore-not-found=$(ignore-not-found) +uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. + $(KUSTOMIZE) build deploy/operator/config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - .PHONY: deploy deploy: cluster grpcurl ## Deploy controller using the operator. Set SKIP_BUILD=1 to skip image builds. Set CLUSTER_TYPE=k3s for k3s. @@ -202,8 +204,8 @@ deploy-exporters: ./hack/demoenv/prepare_exporters.sh .PHONY: undeploy -undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - $(MAKE) -C deploy/operator undeploy ignore-not-found=$(ignore-not-found) +undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. + $(KUSTOMIZE) build deploy/operator/config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - ##@ Dependencies From b4cea710478844087a063089bd238be4bb9da934 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 12 May 2026 21:29:50 +0200 Subject: [PATCH 52/54] fix: restore controller Makefile's own config paths Revert the path changes in build-installer, install, uninstall, and undeploy targets back to the controller's own kubebuilder config/ layout (config/manager, config/default, config/crd) instead of reaching into deploy/operator/config/. The manifests target still outputs to deploy/operator/config/ since helm paths are being removed and CRDs need to go to the operator. Co-Authored-By: Claude Opus 4.6 (1M context) --- controller/Makefile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/controller/Makefile b/controller/Makefile index 7382e78f4..33934b3f8 100644 --- a/controller/Makefile +++ b/controller/Makefile @@ -158,8 +158,8 @@ docker-buildx: ## Build and push docker image for the manager for cross-platform .PHONY: build-installer build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. mkdir -p dist - cd deploy/operator/config/manager && $(KUSTOMIZE) edit set image quay.io/jumpstarter-dev/jumpstarter-operator=${IMG} - $(KUSTOMIZE) build deploy/operator/config/default > dist/install.yaml + cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + $(KUSTOMIZE) build config/default > dist/install.yaml ##@ Deployment @@ -169,11 +169,11 @@ endif .PHONY: install install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. - $(KUSTOMIZE) build deploy/operator/config/crd | $(KUBECTL) apply -f - + $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f - .PHONY: uninstall uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - $(KUSTOMIZE) build deploy/operator/config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - + $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - .PHONY: deploy deploy: cluster grpcurl ## Deploy controller using the operator. Set SKIP_BUILD=1 to skip image builds. Set CLUSTER_TYPE=k3s for k3s. @@ -205,7 +205,7 @@ deploy-exporters: .PHONY: undeploy undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - $(KUSTOMIZE) build deploy/operator/config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - + $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - ##@ Dependencies From 233f042a5728b0aaa89605dfe3deed58cff3f78b Mon Sep 17 00:00:00 2001 From: raballew Date: Wed, 13 May 2026 09:29:44 +0200 Subject: [PATCH 53/54] Update e2e/compat/setup.sh Co-authored-by: Miguel Angel Ajo Pelayo --- e2e/compat/setup.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index d3ce8137d..0a15ef7cf 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -133,8 +133,6 @@ spec: autoProvisioning: enabled: true controller: - image: quay.io/jumpstarter-dev/jumpstarter-controller - imagePullPolicy: IfNotPresent replicas: 1 grpc: endpoints: From dc3f02ebdd59ee95f087e4dada44af0c2bfdaec6 Mon Sep 17 00:00:00 2001 From: raballew Date: Wed, 13 May 2026 09:29:57 +0200 Subject: [PATCH 54/54] Update e2e/compat/setup.sh Co-authored-by: Miguel Angel Ajo Pelayo --- e2e/compat/setup.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/e2e/compat/setup.sh b/e2e/compat/setup.sh index 0a15ef7cf..1c0245caa 100755 --- a/e2e/compat/setup.sh +++ b/e2e/compat/setup.sh @@ -141,8 +141,6 @@ spec: enabled: true port: 30010 routers: - image: quay.io/jumpstarter-dev/jumpstarter-controller - imagePullPolicy: IfNotPresent replicas: 1 grpc: endpoints: