From cb2fac45385267cc447b0b997a292eb17d16a715 Mon Sep 17 00:00:00 2001 From: Robin Duhan <52245137+RobinDuhan@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:26:12 +0530 Subject: [PATCH] INST-19278 - Added helm test cases using bash --- .github/workflows/helm.test.yml | 118 ++++++++ build/test/aws.sh | 133 +++++++++ build/test/azure.sh | 82 ++++++ build/test/env | 46 +++ build/test/gcp.sh | 80 ++++++ build/test/helper.sh | 477 ++++++++++++++++++++++++++++++++ 6 files changed, 936 insertions(+) create mode 100644 .github/workflows/helm.test.yml create mode 100644 build/test/aws.sh create mode 100644 build/test/azure.sh create mode 100644 build/test/env create mode 100644 build/test/gcp.sh create mode 100644 build/test/helper.sh diff --git a/.github/workflows/helm.test.yml b/.github/workflows/helm.test.yml new file mode 100644 index 0000000..c94c597 --- /dev/null +++ b/.github/workflows/helm.test.yml @@ -0,0 +1,118 @@ +name: Helm E2E Tests + +on: + workflow_dispatch: + inputs: + download_url: + description: 'Binary download URL (architecture suffix appended automatically)' + required: true + default: 'https://releases.jfrog.io/artifactory/run/jfrog-credentials-provider/0.1.0-beta.6/jfrog-credential-provider-linux' + type: string + provider: + description: 'Cloud provider to test' + required: true + default: 'all' + type: choice + options: + - all + - aws + - azure + - gcp + push: + branches: + - feature/INST-19278 + +permissions: + id-token: write + contents: read + +jobs: + test-aws: + name: AWS E2E Tests + # if: inputs.provider == 'all' || inputs.provider == 'aws' + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Configure aws credentials + uses: aws-actions/configure-aws-credentials@v4.1.0 + with: + role-to-assume: arn:aws:iam::095132750011:role/github-actions-kubelet-ci-role + role-session-name: kubelettestcisession + aws-region: ap-northeast-3 + + - name: Install tools + run: | + command -v helm >/dev/null || { curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash; } + command -v yq >/dev/null || { sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq; } + + - name: Run AWS E2E tests + env: + AWS_NODE_ROLE_ARN: ${{ secrets.AWS_NODE_ROLE_ARN }} + AWS_SUBNET_IDS: ${{ secrets.AWS_SUBNET_IDS }} + run: | + source build/test/env + export DOWNLOAD_URL="${{ inputs.download_url }}" + bash build/test/aws.sh + + test-azure: + name: Azure E2E Tests + # if: inputs.provider == 'all' || inputs.provider == 'azure' + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Login to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_APP_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_APP_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_APP_SUBSCRIPTION_ID }} + + - name: Install tools + run: | + command -v helm >/dev/null || { curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash; } + command -v yq >/dev/null || { sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq; } + + - name: Run Azure E2E tests + env: + AZURE_APP_CLIENT_ID: ${{ secrets.AZURE_HELM_TEST_APP_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_APP_TENANT_ID }} + # user assigned identity that has access to the app registration + AZURE_NODEPOOL_CLIENT_ID: ${{ secrets.AZURE_HELM_TEST_NODEPOOL_CLIENT_ID }} + run: | + source build/test/env + export DOWNLOAD_URL="${{ inputs.download_url }}" + export JFROG_OIDC_PROVIDER_NAME="${AZURE_JFROG_OIDC_PROVIDER_NAME}" + bash build/test/azure.sh + + test-gcp: + name: GCP E2E Tests + if: inputs.provider == 'all' || inputs.provider == 'gcp' + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }} + + - name: Set up gcloud CLI + uses: google-github-actions/setup-gcloud@v2 + + - name: Install tools + run: | + command -v helm >/dev/null || { curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash; } + command -v yq >/dev/null || { sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq; } + + - name: Run GCP E2E tests + env: + GCP_SERVICE_ACCOUNT_EMAIL: ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }} + run: | + source build/test/env + export DOWNLOAD_URL="${{ inputs.download_url }}" + export JFROG_OIDC_PROVIDER_NAME="${GCP_JFROG_OIDC_PROVIDER_NAME}" + bash build/test/gcp.sh + diff --git a/build/test/aws.sh b/build/test/aws.sh new file mode 100644 index 0000000..909e72a --- /dev/null +++ b/build/test/aws.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# aws.sh - E2E tests for AWS (assume_role + cognito_oidc) +# Expects the following env vars to be set by the caller (GitHub Actions workflow): +# EKS_CLUSTER_NAME, AWS_REGION, AWS_SUBNET_IDS, AWS_NODE_ROLE_ARN +# ARTIFACTORY_URL, MATCH_IMAGES, TEST_IMAGE, HELM_CHART_VERSION +# AWS_ROLE_NAME (for assume_role) +# AWS_COGNITO_SECRET_NAME, AWS_COGNITO_USER_POOL_NAME, +# AWS_COGNITO_RESOURCE_SERVER_NAME, AWS_COGNITO_USER_POOL_RESOURCE_SCOPE, +# JFROG_OIDC_PROVIDER_NAME (for cognito_oidc) +# DOWNLOAD_URL for custom binary url + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/helper.sh" + +RUN_ID="${GITHUB_RUN_ID:-$(date +%s)}" + +# --------------------------------------------------------------------------- +# assume_role test +# --------------------------------------------------------------------------- +test_aws_assume_role() { + local ng_name="jfrog-ar-${RUN_ID}" + local release_name="jfrog-cp-assume-role" + local namespace="jfrog-assume-role" + local node_label_value="aws-assume-role" + local values_file="/tmp/values-aws-assume-role.yaml" + + log_step "TEST: AWS assume_role" + + cleanup_assume_role() { + log_step "Cleanup: AWS assume_role" + cleanup_helm_test "${release_name}" "${namespace}" || true + delete_node_group_aws "${EKS_CLUSTER_NAME}" "${ng_name}" || true + } + trap cleanup_assume_role EXIT + + create_node_group_aws \ + "${EKS_CLUSTER_NAME}" \ + "${ng_name}" \ + "jfrog-test=${node_label_value},credentialsProviderEnabled=true" \ + "t4g.medium" \ + "${AWS_SUBNET_IDS}" \ + "AL2023_ARM_64_STANDARD" \ + "${AWS_NODE_ROLE_ARN}" + + generate_values "${REPO_ROOT}/examples/aws-values.yaml" "${values_file}" \ + ".providerConfig[0].artifactoryUrl = \"${ARTIFACTORY_URL}\"" \ + ".providerConfig[0].matchImages[0] = \"${MATCH_IMAGES}\"" \ + ".providerConfig[0].aws.aws_role_name = \"${AWS_ROLE_NAME}\"" \ + ".downloadUrl = \"${DOWNLOAD_URL}\"" + + run_helm_test \ + "${release_name}" \ + "${namespace}" \ + "${values_file}" \ + "${TEST_IMAGE}" \ + "jfrog-test" \ + "${node_label_value}" \ + "false" \ + + log_info "TEST PASSED: AWS assume_role" + + cleanup_assume_role + trap - EXIT +} + +# --------------------------------------------------------------------------- +# cognito_oidc test +# --------------------------------------------------------------------------- +test_aws_projected_sa() { + local ng_name="jfrog-co-${RUN_ID}" + local release_name="jfrog-cp-projected-sa" + local namespace="jfrog-projected-sa" + local node_label_value="aws-projecte-token" + local values_file="/tmp/values-aws-projected-sa.yaml" + + log_step "TEST: AWS projected_sa" + + cleanup_projected_sa() { + log_step "Cleanup: AWS projected_sa" + cleanup_helm_test "${release_name}" "${namespace}" || true + delete_node_group_aws "${EKS_CLUSTER_NAME}" "${ng_name}" || true + } + trap cleanup_cognito EXIT + + create_node_group_aws \ + "${EKS_CLUSTER_NAME}" \ + "${ng_name}" \ + "jfrog-test=${node_label_value},credentialsProviderEnabled=true" \ + "t4g.small" \ + "${AWS_SUBNET_IDS}" \ + "${AWS_NODE_ROLE_ARN}" + + generate_values "${REPO_ROOT}/examples/aws-projected-sa-values.yaml" "${values_file}" \ + ".providerConfig[0].artifactoryUrl = \"${ARTIFACTORY_URL}\"" \ + ".providerConfig[0].matchImages[0] = \"${MATCH_IMAGES}\"" \ + ".providerConfig[0].aws.enabled = true" \ + ".providerConfig[0].aws.aws_auth_method = \"assume_role\"" \ + ".providerConfig[0].aws.aws_role_name = \"${AWS_ROLE_NAME}\"" \ + ".providerConfig[0].tokenAttributes.enabled = true" \ + ".downloadUrl = \"${DOWNLOAD_URL}\"" + + run_helm_test \ + "${release_name}" \ + "${namespace}" \ + "${values_file}" \ + "${TEST_IMAGE}" \ + "jfrog-test" \ + "${node_label_value}" \ + "true" \ + + log_info "TEST PASSED: AWS cognito_oidc" + + cleanup_cognito + trap - EXIT +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + log_step "Starting AWS E2E tests (run: ${RUN_ID})" + + connect_cluster_aws "${EKS_CLUSTER_NAME}" "${AWS_REGION}" + + test_aws_assume_role + # test_aws_cognito_oidc + + log_step "All AWS E2E tests PASSED" +} + +main "$@" diff --git a/build/test/azure.sh b/build/test/azure.sh new file mode 100644 index 0000000..00a1e27 --- /dev/null +++ b/build/test/azure.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# azure.sh - E2E tests for Azure (azure_oidc) +# Expects the following env vars to be set by the caller (GitHub Actions workflow): +# AKS_CLUSTER_NAME, AKS_RESOURCE_GROUP +# ARTIFACTORY_URL, MATCH_IMAGES, TEST_IMAGE +# AZURE_APP_CLIENT_ID, AZURE_TENANT_ID, AZURE_NODEPOOL_CLIENT_ID, +# JFROG_OIDC_PROVIDER_NAME + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/helper.sh" + +RUN_ID="${GITHUB_RUN_ID:-$(date +%s)}" + +# --------------------------------------------------------------------------- +# azure_oidc test +# --------------------------------------------------------------------------- +test_azure_oidc() { + local ng_name="jfrogazoidc${RUN_ID}" + # Azure node pool names must be <= 12 chars alphanumeric; truncate RUN_ID + ng_name="jfaz${RUN_ID: -8}" + local release_name="jfrog-cp-azure-oidc" + local namespace="jfrog-azure-oidc" + local node_label_value="azure-oidc" + local values_file="/tmp/values-azure-oidc.yaml" + + log_step "TEST: Azure OIDC" + + cleanup_azure() { + log_step "Cleanup: Azure OIDC" + cleanup_helm_test "${release_name}" "${namespace}" || true + delete_node_group_azure "${AKS_CLUSTER_NAME}" "${AKS_RESOURCE_GROUP}" "${ng_name}" || true + } + trap cleanup_azure EXIT + + create_node_group_azure \ + "${AKS_CLUSTER_NAME}" \ + "${AKS_RESOURCE_GROUP}" \ + "${ng_name}" \ + "jfrog-test=${node_label_value},credentialsProviderEnabled=true" \ + "${AZURE_NODE_VM_SIZE:-Standard_D2pds_v5}" \ + "${AZURE_NODE_COUNT:-1}" \ + "${AZURE_NODEPOOL_CLIENT_ID}" + + generate_values "${REPO_ROOT}/examples/azure-values.yaml" "${values_file}" \ + ".providerConfig[0].artifactoryUrl = \"${ARTIFACTORY_URL}\"" \ + ".providerConfig[0].matchImages[0] = \"${MATCH_IMAGES}\"" \ + ".providerConfig[0].azure.azure_app_client_id = \"${AZURE_APP_CLIENT_ID}\"" \ + ".providerConfig[0].azure.azure_tenant_id = \"${AZURE_TENANT_ID}\"" \ + ".providerConfig[0].azure.azure_nodepool_client_id = \"${AZURE_NODEPOOL_CLIENT_ID}\"" \ + ".providerConfig[0].azure.jfrog_oidc_provider_name = \"${JFROG_OIDC_PROVIDER_NAME}\"" \ + + run_helm_test \ + "${release_name}" \ + "${namespace}" \ + "${values_file}" \ + "${TEST_IMAGE}" \ + "jfrog-test" \ + "${node_label_value}" \ + "false" \ + + log_info "TEST PASSED: Azure OIDC" + + cleanup_azure + trap - EXIT +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + log_step "Starting Azure E2E tests (run: ${RUN_ID})" + + connect_cluster_azure "${AKS_CLUSTER_NAME}" "${AKS_RESOURCE_GROUP}" + + test_azure_oidc + + log_step "All Azure E2E tests PASSED" +} + +main "$@" diff --git a/build/test/env b/build/test/env new file mode 100644 index 0000000..6ba2801 --- /dev/null +++ b/build/test/env @@ -0,0 +1,46 @@ +# build/test/env - Non-sensitive test configuration +# Source this file before running test scripts. +# +# Required GitHub Actions secrets (not stored here): +# AWS_ROLE_TO_ASSUME - IAM role ARN for GitHub Actions OIDC auth to AWS +# AWS_NODE_ROLE_ARN - IAM role ARN for EKS node groups +# AWS_SUBNET_IDS - Space-separated subnet IDs for EKS node groups +# AZURE_APP_CLIENT_ID - Azure AD app registration client ID +# AZURE_TENANT_ID - Azure AD tenant ID +# AZURE_SUBSCRIPTION_ID - Azure subscription ID +# AZURE_NODEPOOL_CLIENT_ID - Managed identity client ID for AKS node pool +# GCP_WORKLOAD_IDENTITY_PROVIDER - Workload Identity Federation provider for GitHub Actions OIDC auth to GCP +# GCP_SERVICE_ACCOUNT_EMAIL - GCP service account email for node pools and GH Actions auth + +# --------------------------------------------------------------------------- +# Shared +# --------------------------------------------------------------------------- +export ARTIFACTORY_URL="partnership.jfrog.io" +export MATCH_IMAGES="partnership*.jfrog.io" +export TEST_IMAGE="partnership-docker-remote-test.jfrog.io/busybox:latest" + +# --------------------------------------------------------------------------- +# AWS +# --------------------------------------------------------------------------- +export EKS_CLUSTER_NAME="aws-operator-jfrog" +export AWS_REGION="ap-northeast-3" +export AWS_ROLE_NAME="OperatorSelfManagedWorkerNodeRole" + +# --------------------------------------------------------------------------- +# Azure +# --------------------------------------------------------------------------- +export AKS_CLUSTER_NAME="robind-test-v1" +export AKS_RESOURCE_GROUP="infra-robin-test" +export AZURE_JFROG_OIDC_PROVIDER_NAME="azure-aks-oidc-provider" +export AZURE_APP_AUDIENCE="api://AzureADTokenExchange" +export AZURE_IDENTITY_NAME="kep-identity" + +# --------------------------------------------------------------------------- +# GCP +# --------------------------------------------------------------------------- +export GKE_CLUSTER_NAME="jfrog-inst-credentials-cluster" +export GCP_PROJECT="jfrog-dev" +export GCP_ZONE="asia-south1-a" +export GCP_MACHINE_TYPE="e2-medium" +export GCP_OIDC_AUDIENCE="jfrog-dev" +export GCP_JFROG_OIDC_PROVIDER_NAME="gcp-gke-oidc-provider" diff --git a/build/test/gcp.sh b/build/test/gcp.sh new file mode 100644 index 0000000..d99b1c3 --- /dev/null +++ b/build/test/gcp.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# gcp.sh - E2E tests for GCP (gcp_oidc) +# Expects the following env vars to be set by the caller (GitHub Actions workflow): +# GKE_CLUSTER_NAME, GCP_PROJECT, GCP_ZONE +# ARTIFACTORY_URL, MATCH_IMAGES, TEST_IMAGE, HELM_CHART_VERSION +# GCP_SERVICE_ACCOUNT_EMAIL, GCP_OIDC_AUDIENCE, JFROG_OIDC_PROVIDER_NAME + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/helper.sh" + +RUN_ID="${GITHUB_RUN_ID:-$(date +%s)}" + +# --------------------------------------------------------------------------- +# gcp_oidc test +# --------------------------------------------------------------------------- +test_gcp_oidc() { + local ng_name="jfrog-gcp-oidc-${RUN_ID}" + local release_name="jfrog-cp-gcp-oidc" + local namespace="jfrog-gcp-oidc" + local node_label_value="gcp-oidc" + local values_file="/tmp/values-gcp-oidc.yaml" + + log_step "TEST: GCP OIDC" + + cleanup_gcp() { + log_step "Cleanup: GCP OIDC" + cleanup_helm_test "${release_name}" "${namespace}" || true + delete_node_group_gcp "${GKE_CLUSTER_NAME}" "${GCP_PROJECT}" "${GCP_ZONE}" "${ng_name}" || true + } + trap cleanup_gcp EXIT + + create_node_group_gcp \ + "${GKE_CLUSTER_NAME}" \ + "${GCP_PROJECT}" \ + "${GCP_ZONE}" \ + "${ng_name}" \ + "jfrog-test=${node_label_value},credentialsProviderEnabled=true" \ + "${GCP_MACHINE_TYPE:-e2-medium}" \ + "1" \ + "${GCP_SERVICE_ACCOUNT_EMAIL}" + + generate_values "${REPO_ROOT}/examples/gcp-values.yaml" "${values_file}" \ + ".providerConfig[0].artifactoryUrl = \"${ARTIFACTORY_URL}\"" \ + ".providerConfig[0].matchImages[0] = \"${MATCH_IMAGES}\"" \ + ".providerConfig[0].gcp.google_service_account_email = \"${GCP_SERVICE_ACCOUNT_EMAIL}\"" \ + ".providerConfig[0].gcp.jfrog_oidc_audience = \"${GCP_OIDC_AUDIENCE}\"" \ + ".providerConfig[0].gcp.jfrog_oidc_provider_name = \"${JFROG_OIDC_PROVIDER_NAME}\"" \ + ".downloadUrl = \"${DOWNLOAD_URL}\"" + + run_helm_test \ + "${release_name}" \ + "${namespace}" \ + "${values_file}" \ + "${TEST_IMAGE}" \ + "jfrog-test" \ + "${node_label_value}" \ + "false" \ + + log_info "TEST PASSED: GCP OIDC" + + cleanup_gcp + trap - EXIT +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + log_step "Starting GCP E2E tests (run: ${RUN_ID})" + + connect_cluster_gcp "${GKE_CLUSTER_NAME}" "${GCP_PROJECT}" "${GCP_ZONE}" + + test_gcp_oidc + + log_step "All GCP E2E tests PASSED" +} + +main "$@" diff --git a/build/test/helper.sh b/build/test/helper.sh new file mode 100644 index 0000000..8207033 --- /dev/null +++ b/build/test/helper.sh @@ -0,0 +1,477 @@ +#!/bin/bash +# helper.sh - Shared library for JFrog Credential Provider E2E tests +# Sourced by aws.sh, azure.sh, gcp.sh + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Globals +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +LOG_FILE="/tmp/jfrog-test-${GITHUB_RUN_ID:-local}.log" +POD_WAIT_TIMEOUT="${POD_WAIT_TIMEOUT:-300}" +DAEMONSET_WAIT_TIMEOUT="${DAEMONSET_WAIT_TIMEOUT:-300}" +NODE_GROUP_WAIT_TIMEOUT="${NODE_GROUP_WAIT_TIMEOUT:-600}" + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +_log() { + local level="$1"; shift + local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [${level}] $*" + echo "${msg}" | tee -a "${LOG_FILE}" +} + +log_info() { _log "INFO" "$@"; } +log_warn() { _log "WARN" "$@"; } +log_error() { _log "ERROR" "$@"; } +log_step() { _log "STEP" "===== $* ====="; } + +# --------------------------------------------------------------------------- +# Cluster connection +# --------------------------------------------------------------------------- +connect_cluster_aws() { + local cluster_name="$1" + local region="$2" + + log_step "Connecting to EKS cluster ${cluster_name} in ${region}" + if ! aws eks update-kubeconfig --name "${cluster_name}" --region "${region}"; then + log_error "Failed to connect to EKS cluster ${cluster_name}" + return 1 + fi + log_info "Connected to EKS cluster ${cluster_name}" +} + +connect_cluster_azure() { + local cluster_name="$1" + local resource_group="$2" + + log_step "Connecting to AKS cluster ${cluster_name} in resource group ${resource_group}" + if ! az aks get-credentials --name "${cluster_name}" --resource-group "${resource_group}" --overwrite-existing; then + log_error "Failed to connect to AKS cluster ${cluster_name}" + return 1 + fi + log_info "Connected to AKS cluster ${cluster_name}" +} + +connect_cluster_gcp() { + local cluster_name="$1" + local project="$2" + local zone="$3" + + log_step "Connecting to GKE cluster ${cluster_name} in project ${project}, zone ${zone}" + if ! gcloud container clusters get-credentials "${cluster_name}" --project "${project}" --zone "${zone}"; then + log_error "Failed to connect to GKE cluster ${cluster_name}" + return 1 + fi + log_info "Connected to GKE cluster ${cluster_name}" +} + +# --------------------------------------------------------------------------- +# Node group creation +# --------------------------------------------------------------------------- + +# create_node_group_aws CLUSTER_NAME NG_NAME LABELS INSTANCE_TYPE SUBNET_IDS NODE_ROLE_ARN [MIN] [MAX] [DESIRED] +# LABELS is a comma-separated key=value string, e.g. "jfrog-test=aws-assume-role,env=ci" +create_node_group_aws() { + local cluster_name="$1" + local ng_name="$2" + local labels="$3" + local instance_type="$4" + local subnet_ids="$5" + local ami_type="$6" + local node_role_arn="$7" + local min_size="${8:-1}" + local max_size="${9:-1}" + local desired_size="${10:-1}" + + log_step "Creating AWS node group ${ng_name} on cluster ${cluster_name}" + log_info "Labels: ${labels} | Instance: ${instance_type} | Size: ${min_size}/${max_size}/${desired_size}" + + if ! aws eks create-nodegroup \ + --cluster-name "${cluster_name}" \ + --nodegroup-name "${ng_name}" \ + --node-role "${node_role_arn}" \ + --labels "${labels}" \ + --instance-types "${instance_type}" \ + --subnets ${subnet_ids} \ + --ami-type "${ami_type}" \ + --scaling-config "minSize=${min_size},maxSize=${max_size},desiredSize=${desired_size}"; then + log_error "Failed to create AWS node group ${ng_name}" + return 1 + fi + + log_info "Waiting for node group ${ng_name} to become ACTIVE (timeout: ${NODE_GROUP_WAIT_TIMEOUT}s)..." + if ! aws eks wait nodegroup-active \ + --cluster-name "${cluster_name}" \ + --nodegroup-name "${ng_name}"; then + log_error "Node group ${ng_name} did not reach ACTIVE state" + return 1 + fi + log_info "Node group ${ng_name} is ACTIVE" +} + +# create_node_group_azure CLUSTER_NAME RESOURCE_GROUP NG_NAME LABELS VM_SIZE [NODE_COUNT] [IDENTITY_CLIENT_ID] +# LABELS is a comma-separated key=value string, e.g. "jfrog-test=azure-oidc" +create_node_group_azure() { + local cluster_name="$1" + local resource_group="$2" + local ng_name="$3" + local labels="$4" + local vm_size="$5" + local node_count="${6:-1}" + local identity_client_id="${7:-}" + + log_step "Creating Azure node pool ${ng_name} on cluster ${cluster_name}" + log_info "Labels: ${labels} | VM size: ${vm_size} | Count: ${node_count}" + + # Convert comma-separated "k=v,k2=v2" to space-separated "k=v k2=v2" for az CLI + local label_args="${labels//,/ }" + + if ! az aks nodepool add \ + --cluster-name "${cluster_name}" \ + --resource-group "${resource_group}" \ + --name "${ng_name}" \ + --labels ${label_args} \ + --node-count "${node_count}" \ + --node-vm-size "${vm_size}"; then + log_error "Failed to create Azure node pool ${ng_name}" + return 1 + fi + log_info "Azure node pool ${ng_name} is ready" + + if [[ -n "${identity_client_id}" ]]; then + log_info "Assigning managed identity ${identity_client_id} to node pool VMSS" + + local mc_rg + mc_rg=$(az aks show \ + --name "${cluster_name}" \ + --resource-group "${resource_group}" \ + --query "nodeResourceGroup" -o tsv) + + local vmss_name + vmss_name=$(az vmss list \ + -g "${mc_rg}" \ + --query "[?tags.\"aks-managed-poolName\"=='${ng_name}'].name" -o tsv) + + if [[ -z "${vmss_name}" ]]; then + log_error "Could not find VMSS for node pool ${ng_name} in ${mc_rg}" + return 1 + fi + + local identity_id + identity_id=$(az identity list \ + --query "[?clientId=='${identity_client_id}'].id" -o tsv) + + if [[ -z "${identity_id}" ]]; then + log_error "Could not find managed identity with clientId ${identity_client_id}" + return 1 + fi + + if ! az vmss identity assign \ + -g "${mc_rg}" \ + -n "${vmss_name}" \ + --identities "${identity_id}"; then + log_error "Failed to assign identity to Azure node pool ${ng_name}" + return 1 + fi + log_info "Identity assigned to Azure node pool ${ng_name}" + fi +} + +# create_node_group_gcp CLUSTER_NAME PROJECT ZONE NG_NAME LABELS MACHINE_TYPE [NUM_NODES] +# LABELS is a comma-separated key=value string, e.g. "jfrog-test=gcp-oidc" +create_node_group_gcp() { + local cluster_name="$1" + local project="$2" + local zone="$3" + local ng_name="$4" + local labels="$5" + local machine_type="$6" + local num_nodes="${7:-1}" + local service_account_email="$8" + + log_step "Creating GCP node pool ${ng_name} on cluster ${cluster_name}" + log_info "Labels: ${labels} | Machine type: ${machine_type} | Nodes: ${num_nodes}" + + if ! gcloud container node-pools create "${ng_name}" \ + --cluster "${cluster_name}" \ + --project "${project}" \ + --zone "${zone}" \ + --machine-type "${machine_type}" \ + --num-nodes "${num_nodes}" \ + --node-labels "${labels}" \ + --pod-ipv4-range "pod-ranges-extra" \ + --service-account "${service_account_email}"; then + log_error "Failed to create GCP node pool ${ng_name}" + return 1 + fi + log_info "GCP node pool ${ng_name} is ready" +} + +# --------------------------------------------------------------------------- +# Node group deletion +# --------------------------------------------------------------------------- +delete_node_group_aws() { + local cluster_name="$1" + local ng_name="$2" + + log_step "Deleting AWS node group ${ng_name} from cluster ${cluster_name}" + if ! aws eks delete-nodegroup --cluster-name "${cluster_name}" --nodegroup-name "${ng_name}"; then + log_warn "Failed to delete AWS node group ${ng_name} (may not exist)" + return 0 + fi + log_info "Waiting for node group ${ng_name} to be deleted..." + aws eks wait nodegroup-deleted --cluster-name "${cluster_name}" --nodegroup-name "${ng_name}" || true + log_info "AWS node group ${ng_name} deleted" +} + +delete_node_group_azure() { + local cluster_name="$1" + local resource_group="$2" + local ng_name="$3" + + log_step "Deleting Azure node pool ${ng_name} from cluster ${cluster_name}" + if ! az aks nodepool delete \ + --cluster-name "${cluster_name}" \ + --resource-group "${resource_group}" \ + --name "${ng_name}" \ + --no-wait; then + log_warn "Failed to delete Azure node pool ${ng_name} (may not exist)" + return 0 + fi + log_info "Azure node pool ${ng_name} deleted" +} + +delete_node_group_gcp() { + local cluster_name="$1" + local project="$2" + local zone="$3" + local ng_name="$4" + + log_step "Deleting GCP node pool ${ng_name} from cluster ${cluster_name}" + if ! gcloud container node-pools delete "${ng_name}" \ + --cluster "${cluster_name}" \ + --project "${project}" \ + --zone "${zone}" \ + --quiet; then + log_warn "Failed to delete GCP node pool ${ng_name} (may not exist)" + return 0 + fi + log_info "GCP node pool ${ng_name} deleted" +} + +# --------------------------------------------------------------------------- +# Values generation +# --------------------------------------------------------------------------- + +# generate_values TEMPLATE_FILE OUTPUT_FILE [YQ_EXPR...] +# Copies the template then applies each yq expression in order. +# Example: +# generate_values examples/aws-values.yaml /tmp/out.yaml \ +# '.providerConfig[0].artifactoryUrl = "my.jfrog.io"' \ +# '.nodeSelector."jfrog-test" = "aws-assume-role"' +generate_values() { + local template_file="$1" + local output_file="$2" + shift 2 + + log_info "Generating values file from ${template_file} -> ${output_file}" + cp "${template_file}" "${output_file}" + + for expr in "$@"; do + log_info " yq: ${expr}" + yq -i "${expr}" "${output_file}" + done + + log_info "Generated values file: ${output_file}" +} + +# --------------------------------------------------------------------------- +# Helm lifecycle +# --------------------------------------------------------------------------- + +# helm_install RELEASE_NAME NAMESPACE VALUES_FILE CHART_VERSION +helm_install() { + local release_name="$1" + local namespace="$2" + local values_file="$3" + + log_step "Helm installing ${release_name} in namespace ${namespace})" + + + if ! helm install "${release_name}" "${REPO_ROOT}/helm" \ + --namespace "${namespace}" \ + --create-namespace \ + -f "${values_file}" \ + --wait \ + --timeout "${DAEMONSET_WAIT_TIMEOUT}s"; then + log_error "Helm install failed for ${release_name}" + log_info "Dumping pod status in namespace ${namespace}:" + kubectl get pods -n "${namespace}" -o wide 2>&1 | tee -a "${LOG_FILE}" || true + log_info "Dumping events in namespace ${namespace}:" + kubectl get events -n "${namespace}" --sort-by='.lastTimestamp' 2>&1 | tee -a "${LOG_FILE}" || true + return 1 + fi + + log_info "Helm install succeeded for ${release_name}" + log_info "Pods in namespace ${namespace}:" + kubectl get pods -n "${namespace}" -o wide 2>&1 | tee -a "${LOG_FILE}" +} + +# helm_uninstall RELEASE_NAME NAMESPACE +helm_uninstall() { + local release_name="$1" + local namespace="$2" + + log_step "Helm uninstalling ${release_name} from namespace ${namespace}" + if ! helm uninstall "${release_name}" --namespace "${namespace}"; then + log_warn "Helm uninstall failed for ${release_name} (may not exist)" + return 0 + fi + log_info "Helm uninstall succeeded for ${release_name}" +} + +# --------------------------------------------------------------------------- +# Pod verification +# --------------------------------------------------------------------------- + +# deploy_test_pod POD_NAME NAMESPACE IMAGE NODE_SELECTOR_KEY NODE_SELECTOR_VALUE +# Creates a minimal pod that pulls from the target Artifactory to validate credentials. +deploy_test_pod() { + local pod_name="$1" + local namespace="$2" + local image="$3" + local node_selector_key="$4" + local node_selector_value="$5" + local projected_token_enabled="$6" + + if [[ "${projected_token_enabled}" == "true" ]]; then + localservice_account_name="projected-sa" + kubectl create serviceaccount ${service_account_name} -n ${namespace} + kubectl annotate serviceaccount ${service_account_name} -n ${namespace} "eks.amazonaws.com/role-arn=${node_role_arn}" + else + service_account_name="default" + fi + + log_step "Deploying test pod ${pod_name} in ${namespace} (image: ${image})" + + kubectl apply -n "${namespace}" -f - </dev/null || echo "Unknown") + + case "${phase}" in + Running) + log_info "Pod ${pod_name} is Running" + return 0 + ;; + Succeeded) + log_info "Pod ${pod_name} Succeeded (completed)" + return 0 + ;; + Failed) + log_error "Pod ${pod_name} has Failed" + kubectl describe pod "${pod_name}" -n "${namespace}" 2>&1 | tee -a "${LOG_FILE}" || true + return 1 + ;; + *) + log_info "Pod ${pod_name} phase: ${phase} (${elapsed}s/${timeout}s)" + ;; + esac + + sleep "${interval}" + elapsed=$((elapsed + interval)) + done + + log_error "Timed out waiting for pod ${pod_name} to reach Running state" + kubectl describe pod "${pod_name}" -n "${namespace}" 2>&1 | tee -a "${LOG_FILE}" || true + kubectl get events -n "${namespace}" --field-selector "involvedObject.name=${pod_name}" --sort-by='.lastTimestamp' 2>&1 | tee -a "${LOG_FILE}" || true + return 1 +} + +# cleanup_test_pod POD_NAME NAMESPACE +cleanup_test_pod() { + local pod_name="$1" + local namespace="$2" + + log_info "Cleaning up test pod ${pod_name} from namespace ${namespace}" + kubectl delete pod "${pod_name}" -n "${namespace}" --ignore-not-found --grace-period=5 || true + log_info "Test pod ${pod_name} cleaned up" +} + +# --------------------------------------------------------------------------- +# Full test lifecycle helper +# --------------------------------------------------------------------------- + +# run_helm_test RELEASE_NAME NAMESPACE VALUES_FILE CHART_VERSION TEST_IMAGE NODE_SELECTOR_KEY NODE_SELECTOR_VALUE +# Runs a complete helm install -> deploy test pod -> verify -> cleanup cycle. +run_helm_test() { + local release_name="$1" + local namespace="$2" + local values_file="$3" + local test_image="$4" + local node_selector_key="$5" + local node_selector_value="$6" + local projected_token_enabled="$7" + local pod_name="test-${release_name}" + + log_step "Starting helm test for ${release_name}" + + helm_install "${release_name}" "${namespace}" "${values_file}" + + deploy_test_pod "${pod_name}" "${namespace}" "${test_image}" "${node_selector_key}" "${node_selector_value}" "${projected_token_enabled}" + wait_for_pod "${pod_name}" "${namespace}" + + log_info "Helm test PASSED for ${release_name}" +} + +# cleanup_helm_test RELEASE_NAME NAMESPACE +# Cleans up a helm release and its test pod. +cleanup_helm_test() { + local release_name="$1" + local namespace="$2" + local pod_name="test-${release_name}" + + log_step "Cleaning up helm test for ${release_name}" + cleanup_test_pod "${pod_name}" "${namespace}" + helm_uninstall "${release_name}" "${namespace}" + kubectl delete namespace "${namespace}" --ignore-not-found || true + log_info "Cleanup complete for ${release_name}" +} + +log_info "helper.sh loaded (log file: ${LOG_FILE})"