From d5c6b78679671781f6914f7bae956616ec40b0e9 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:25:51 -0800 Subject: [PATCH 1/2] feat: add client caching to reduce OAuth token requests Previously, every certificate request reconciliation created a new Command API client, which meant a new OAuth token was fetched for each request. For customers with OAuth provider quotas, this caused rate limiting issues. This change introduces a ClientCache that: - Caches Command API clients by configuration hash - Reuses cached clients across reconciliations for the same issuer - Allows the underlying oauth2 library's token caching to work as intended - Is thread-safe for concurrent reconciliations The cache key is a SHA-256 hash of all configuration fields that affect the client connection (hostname, API path, credentials, scopes, etc.), ensuring different issuers get different clients while the same issuer reuses its client. Fixes: OAuth token re-authentication on every request Co-Authored-By: Claude Opus 4.5 --- cmd/main.go | 13 +- internal/command/client_cache.go | 183 ++++++++++++++++++++++++ internal/command/client_cache_test.go | 198 ++++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 4 deletions(-) create mode 100644 internal/command/client_cache.go create mode 100644 internal/command/client_cache_test.go diff --git a/cmd/main.go b/cmd/main.go index 1611f84..387b682 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -196,18 +196,23 @@ func main() { os.Exit(1) } - if defaultHealthCheckInterval < time.Duration(30) * time.Second { + if defaultHealthCheckInterval < time.Duration(30)*time.Second { setupLog.Error(errors.New(fmt.Sprintf("interval %s is invalid, must be greater than or equal to '30s'", healthCheckInterval)), "invalid health check interval") os.Exit(1) } + // Create a shared client cache to avoid re-authenticating (fetching new OAuth tokens) + // for every certificate request. Clients are cached by configuration hash. + clientCache := command.NewClientCache() + setupLog.Info("initialized Command client cache for OAuth token reuse") + if err = (&controller.IssuerReconciler{ Client: mgr.GetClient(), Kind: "Issuer", ClusterResourceNamespace: clusterResourceNamespace, SecretAccessGrantedAtClusterLevel: secretAccessGrantedAtClusterLevel, Scheme: mgr.GetScheme(), - HealthCheckerBuilder: command.NewHealthChecker, + HealthCheckerBuilder: clientCache.GetOrCreateHealthChecker, DefaultHealthCheckInterval: defaultHealthCheckInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Issuer") @@ -219,7 +224,7 @@ func main() { Kind: "ClusterIssuer", ClusterResourceNamespace: clusterResourceNamespace, SecretAccessGrantedAtClusterLevel: secretAccessGrantedAtClusterLevel, - HealthCheckerBuilder: command.NewHealthChecker, + HealthCheckerBuilder: clientCache.GetOrCreateHealthChecker, DefaultHealthCheckInterval: defaultHealthCheckInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ClusterIssuer") @@ -229,7 +234,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), ClusterResourceNamespace: clusterResourceNamespace, - SignerBuilder: command.NewSignerBuilder, + SignerBuilder: clientCache.GetOrCreateSigner, CheckApprovedCondition: !disableApprovedCheck, SecretAccessGrantedAtClusterLevel: secretAccessGrantedAtClusterLevel, Clock: clock.RealClock{}, diff --git a/internal/command/client_cache.go b/internal/command/client_cache.go new file mode 100644 index 0000000..d00a1f6 --- /dev/null +++ b/internal/command/client_cache.go @@ -0,0 +1,183 @@ +/* +Copyright © 2025 Keyfactor + +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 command + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sync" + + commandsdk "github.com/Keyfactor/keyfactor-go-client-sdk/v25" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// ClientCache provides thread-safe caching of Command API clients to avoid +// re-authenticating (and fetching new OAuth tokens) for every request. +// Clients are cached by a hash of their configuration, so different issuers +// with different configs get different clients, but the same issuer reuses +// its client across reconciliations. +type ClientCache struct { + mu sync.RWMutex + clients map[string]*cachedClient +} + +type cachedClient struct { + signer *signer +} + +// NewClientCache creates a new ClientCache instance. +func NewClientCache() *ClientCache { + return &ClientCache{ + clients: make(map[string]*cachedClient), + } +} + +// configHash generates a unique hash for a Config to use as a cache key. +// This ensures that different configurations get different clients. +func configHash(config *Config) string { + h := sha256.New() + + // Include all fields that affect the client connection + h.Write([]byte(config.Hostname)) + h.Write([]byte(config.APIPath)) + h.Write(config.CaCertsBytes) + + if config.BasicAuth != nil { + h.Write([]byte("basic")) + h.Write([]byte(config.BasicAuth.Username)) + h.Write([]byte(config.BasicAuth.Password)) + } + + if config.OAuth != nil { + h.Write([]byte("oauth")) + h.Write([]byte(config.OAuth.TokenURL)) + h.Write([]byte(config.OAuth.ClientID)) + h.Write([]byte(config.OAuth.ClientSecret)) + h.Write([]byte(config.OAuth.Audience)) + for _, scope := range config.OAuth.Scopes { + h.Write([]byte(scope)) + } + } + + // Include ambient credential config + h.Write([]byte(config.AmbientCredentialAudience)) + for _, scope := range config.AmbientCredentialScopes { + h.Write([]byte(scope)) + } + + return hex.EncodeToString(h.Sum(nil)) +} + +// GetOrCreateSigner returns a cached signer for the given config, or creates +// a new one if none exists. This ensures OAuth tokens are reused across +// requests to the same Command instance. +func (c *ClientCache) GetOrCreateSigner(ctx context.Context, config *Config) (Signer, error) { + key := configHash(config) + logger := log.FromContext(ctx) + + // Fast path: check if we have a cached client + c.mu.RLock() + if cached, ok := c.clients[key]; ok { + c.mu.RUnlock() + logger.V(1).Info("Reusing cached Command client", "cacheKey", key[:12]) + return cached.signer, nil + } + c.mu.RUnlock() + + // Slow path: create a new client + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check after acquiring write lock + if cached, ok := c.clients[key]; ok { + logger.V(1).Info("Reusing cached Command client (after lock)", "cacheKey", key[:12]) + return cached.signer, nil + } + + logger.Info("Creating new Command client (will be cached for future requests)", "cacheKey", key[:12]) + + s, err := newInternalSigner(ctx, config, commandsdk.NewAPIClient) + if err != nil { + return nil, fmt.Errorf("failed to create signer: %w", err) + } + + c.clients[key] = &cachedClient{signer: s} + return s, nil +} + +// GetOrCreateHealthChecker returns a cached health checker for the given config. +// Since the signer type implements both Signer and HealthChecker interfaces, +// this shares the same cache as GetOrCreateSigner. +func (c *ClientCache) GetOrCreateHealthChecker(ctx context.Context, config *Config) (HealthChecker, error) { + key := configHash(config) + logger := log.FromContext(ctx) + + // Fast path: check if we have a cached client + c.mu.RLock() + if cached, ok := c.clients[key]; ok { + c.mu.RUnlock() + logger.V(1).Info("Reusing cached Command client for health check", "cacheKey", key[:12]) + return cached.signer, nil + } + c.mu.RUnlock() + + // Slow path: create a new client + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check after acquiring write lock + if cached, ok := c.clients[key]; ok { + logger.V(1).Info("Reusing cached Command client for health check (after lock)", "cacheKey", key[:12]) + return cached.signer, nil + } + + logger.Info("Creating new Command client for health check (will be cached)", "cacheKey", key[:12]) + + s, err := newInternalSigner(ctx, config, commandsdk.NewAPIClient) + if err != nil { + return nil, fmt.Errorf("failed to create health checker: %w", err) + } + + c.clients[key] = &cachedClient{signer: s} + return s, nil +} + +// Invalidate removes a cached client for the given config. +// This should be called when an issuer's credentials are updated. +func (c *ClientCache) Invalidate(config *Config) { + key := configHash(config) + c.mu.Lock() + defer c.mu.Unlock() + delete(c.clients, key) +} + +// InvalidateAll removes all cached clients. +// This can be used during shutdown or when a global credential refresh is needed. +func (c *ClientCache) InvalidateAll() { + c.mu.Lock() + defer c.mu.Unlock() + c.clients = make(map[string]*cachedClient) +} + +// Size returns the number of cached clients. +func (c *ClientCache) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.clients) +} diff --git a/internal/command/client_cache_test.go b/internal/command/client_cache_test.go new file mode 100644 index 0000000..162f7e3 --- /dev/null +++ b/internal/command/client_cache_test.go @@ -0,0 +1,198 @@ +/* +Copyright © 2025 Keyfactor + +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 command + +import ( + "testing" +) + +func TestConfigHash(t *testing.T) { + tests := []struct { + name string + config1 *Config + config2 *Config + wantSame bool + }{ + { + name: "identical configs produce same hash", + config1: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + }, + }, + config2: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + }, + }, + wantSame: true, + }, + { + name: "different hostnames produce different hash", + config1: &Config{ + Hostname: "test1.example.com", + APIPath: "KeyfactorAPI", + }, + config2: &Config{ + Hostname: "test2.example.com", + APIPath: "KeyfactorAPI", + }, + wantSame: false, + }, + { + name: "different OAuth credentials produce different hash", + config1: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id-1", + ClientSecret: "client-secret", + }, + }, + config2: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id-2", + ClientSecret: "client-secret", + }, + }, + wantSame: false, + }, + { + name: "basic auth vs oauth produce different hash", + config1: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + BasicAuth: &BasicAuth{ + Username: "user", + Password: "pass", + }, + }, + config2: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + }, + }, + wantSame: false, + }, + { + name: "different scopes produce different hash", + config1: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + Scopes: []string{"scope1"}, + }, + }, + config2: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + Scopes: []string{"scope1", "scope2"}, + }, + }, + wantSame: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash1 := configHash(tt.config1) + hash2 := configHash(tt.config2) + + if tt.wantSame && hash1 != hash2 { + t.Errorf("expected same hash, got different: %s vs %s", hash1, hash2) + } + if !tt.wantSame && hash1 == hash2 { + t.Errorf("expected different hash, got same: %s", hash1) + } + }) + } +} + +func TestClientCache_BasicOperations(t *testing.T) { + cache := NewClientCache() + + // Initial size should be 0 + if cache.Size() != 0 { + t.Errorf("expected empty cache, got size %d", cache.Size()) + } + + // After invalidating a non-existent config, size should still be 0 + cache.Invalidate(&Config{Hostname: "test.example.com"}) + if cache.Size() != 0 { + t.Errorf("expected empty cache after invalidating non-existent, got size %d", cache.Size()) + } + + // InvalidateAll on empty cache should work + cache.InvalidateAll() + if cache.Size() != 0 { + t.Errorf("expected empty cache after InvalidateAll, got size %d", cache.Size()) + } +} + +func TestConfigHash_Deterministic(t *testing.T) { + config := &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + Scopes: []string{"scope1", "scope2"}, + Audience: "audience", + }, + CaCertsBytes: []byte("ca-cert-data"), + AmbientCredentialAudience: "ambient-audience", + AmbientCredentialScopes: []string{"ambient-scope"}, + } + + // Hash should be deterministic + hash1 := configHash(config) + hash2 := configHash(config) + hash3 := configHash(config) + + if hash1 != hash2 || hash2 != hash3 { + t.Errorf("hash is not deterministic: %s, %s, %s", hash1, hash2, hash3) + } + + // Hash should be a valid hex string of expected length (SHA-256 = 64 hex chars) + if len(hash1) != 64 { + t.Errorf("expected hash length 64, got %d", len(hash1)) + } +} From 1afc0412282136f4ccbb7c35e6383e453a8777b1 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:10:09 -0800 Subject: [PATCH 2/2] chore(scripts): update scripting usability --- Makefile | 7 ++-- e2e/.gitignore | 1 + e2e/README.md | 97 +++++++++++++++++++++++++++++++++++++++++------- e2e/run_tests.sh | 60 +++++++++++++++++++----------- 4 files changed, 127 insertions(+), 38 deletions(-) diff --git a/Makefile b/Makefile index f30d196..7893a54 100644 --- a/Makefile +++ b/Makefile @@ -64,9 +64,10 @@ vet: ## Run go vet against code. test: manifests generate fmt vet envtest ## Run tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out -# Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. -.PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. -test-e2e: +# Run e2e tests against the current kubeconfig context (set USE_MINIKUBE=true to use minikube instead) +# Configure e2e/.env with Command instance credentials before running +.PHONY: test-e2e +test-e2e: ## Run e2e tests against a Kubernetes cluster cd e2e && source .env && ./run_tests.sh .PHONY: lint diff --git a/e2e/.gitignore b/e2e/.gitignore index 0caca0b..85a5cd1 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -1,2 +1,3 @@ +.env certs/* !**/.gitkeep \ No newline at end of file diff --git a/e2e/README.md b/e2e/README.md index 48b81b4..5554ead 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -13,19 +13,26 @@ The test suite does the following: This is currently configured as a Bash script, so it is necessary to run this on a UNIX-compatible machine. ## Requirements -- An available Command instance is running and configured as described in the [root README](../README.md#configuring-command) - - OAuth is used to communicate with Command + +**Local tools:** - Docker (>= 28.2.2) -- Minikube (>= v1.35.0) - kubectl (>= v1.32.2) - helm (>= v3.17.1) - cmctl (>= v2.1.1) +- Minikube (>= v1.35.0) - only required if using `USE_MINIKUBE=true` + +**Kubernetes cluster:** +- By default, tests run against your current kubeconfig context +- Set `USE_MINIKUBE=true` to use minikube instead -On the Command side: -- An enrollment pattern is created called "Test Enrollment Pattern" that is has CSR Enrollment, CSR Generation, and PFX Enrollment enabled -- A security role by the name of "InstanceOwner" exists and has the ability to perform Enrollment +**Command instance:** +- An available Command instance configured as described in the [root README](../README.md#configuring-command) +- OAuth credentials for API access +- An enrollment pattern (default: "Default Pattern") with CSR Enrollment enabled +- A security role (default: "InstanceOwner") with Enrollment permissions ## Configuring the environment variables + command-cert-manager-issuer interacts with an external Command instance. An environment variable file `.env` can be used to store the environment variables to be used to talk to the Command instance. A `.env.example` file is available as a template for your environment variables. @@ -35,24 +42,86 @@ A `.env.example` file is available as a template for your environment variables. cp .env.example .env ``` -Modify the fields as needed. +### Required variables + +| Variable | Description | +|----------|-------------| +| `HOSTNAME` | Command instance hostname | +| `API_PATH` | API path (default: `KeyfactorAPI`) | +| `OAUTH_TOKEN_URL` | OAuth token endpoint URL | +| `OAUTH_CLIENT_ID` | OAuth client ID | +| `OAUTH_CLIENT_SECRET` | OAuth client secret | +| `CERTIFICATE_TEMPLATE` | Certificate template short name | +| `CERTIFICATE_AUTHORITY_LOGICAL_NAME` | CA logical name in Command | + +### Optional variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `IMAGE_TAG` | Docker image version to test | `2.5.0` | +| `HELM_CHART_VERSION` | Helm chart version | `2.5.0` | +| `E2E_ENROLLMENT_PATTERN_NAME` | Enrollment pattern name | `Default Pattern` | +| `E2E_OWNER_ROLE_NAME` | Owner role name | `InstanceOwner` | +| `DISABLE_CA_CHECK` | Skip TLS CA verification | `false` | +| `USE_MINIKUBE` | Use minikube instead of current kubeconfig | `false` | +| `IMAGE_REGISTRY` | Registry to push local builds (when `IMAGE_TAG=local`) | - | ## Configuring the trusted certificate store + The issuer created in the end-to-end tests can leverage the `caSecretName` specification to determine a collection of CAs to trust in order to establish a trusted connection with the remote Keyfactor Command instance. The certificates defined in this secret will be pulled from the `certs` folder in this directory. -Please place the CA certificates for the Keyfactor Command instance you'd like to connect to (the intermediate and/or root CAs) under `certs` directory. +Place the CA certificates for the Keyfactor Command instance you'd like to connect to (the intermediate and/or root CAs) under `certs` directory. > NOTE: This check can be disabled by setting the env variable `DISABLE_CA_CHECK=true`. -## Running the script +## Running the tests + +### Using current kubeconfig context (default) ```bash -# enable the script to be executed -chmod +x ./run_tests.sh +# Configure your .env file first +source .env -# load the environment variables +# Run the tests +./run_tests.sh +``` + +Or from the project root: +```bash +make test-e2e +``` + +### Using minikube + +```bash +export USE_MINIKUBE=true source .env +./run_tests.sh +``` + +### Testing a specific version + +```bash +export IMAGE_TAG="2.4.0" +export HELM_CHART_VERSION="2.4.0" +source .env +./run_tests.sh +``` -# run the end-to-end tests +### Testing local changes + +```bash +# With minikube (image built directly into minikube's docker) +export IMAGE_TAG="local" +export HELM_CHART_VERSION="local" +export USE_MINIKUBE=true +source .env ./run_tests.sh -``` \ No newline at end of file + +# With a remote cluster (requires pushing to a registry) +export IMAGE_TAG="local" +export HELM_CHART_VERSION="local" +export IMAGE_REGISTRY="your-registry.com/your-repo" +source .env +./run_tests.sh +``` diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index 1d3ba30..6f5e5df 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -36,15 +36,17 @@ ## =========================================================================== -IMAGE_REPO="keyfactor" -IMAGE_NAME="command-cert-manager-issuer" -# IMAGE_TAG="2.2.0-rc.9" # Uncomment if you want to use an existing image from the repository -IMAGE_TAG="local" # Uncomment if you want to build the image locally +# Image configuration - can be overridden via environment variables +# Set IMAGE_TAG=local to build locally, or use a published version (default: 2.5.0) +IMAGE_REPO="${IMAGE_REPO:-keyfactor}" +IMAGE_NAME="${IMAGE_NAME:-command-cert-manager-issuer}" +IMAGE_TAG="${IMAGE_TAG:-2.5.0}" FULL_IMAGE_NAME="${IMAGE_REPO}/${IMAGE_NAME}:${IMAGE_TAG}" +# Helm chart configuration - can be overridden via environment variables +# Set HELM_CHART_VERSION=local to use the local chart, or use a published version (default: 2.5.0) HELM_CHART_NAME="command-cert-manager-issuer" -# HELM_CHART_VERSION="2.1.0" # Uncomment if you want to use a specific version from the Helm repository -HELM_CHART_VERSION="local" # Uncomment if you want to use the local Helm chart +HELM_CHART_VERSION="${HELM_CHART_VERSION:-2.5.0}" IS_LOCAL_DEPLOYMENT=$([ "$IMAGE_TAG" = "local" ] && echo "true" || echo "false") IS_LOCAL_HELM=$([ "$HELM_CHART_VERSION" = "local" ] && echo "true" || echo "false") @@ -58,11 +60,11 @@ ISSUER_CR_NAME="issuer" ISSUER_CRD_FQTN="issuers.command-issuer.keyfactor.com" CLUSTER_ISSUER_CRD_FQTN="clusterissuers.command-issuer.keyfactor.com" -ENROLLMENT_PATTERN_ID=1 -ENROLLMENT_PATTERN_NAME="Test Enrollment Pattern" +ENROLLMENT_PATTERN_ID=${E2E_ENROLLMENT_PATTERN_ID:-1} +ENROLLMENT_PATTERN_NAME="${E2E_ENROLLMENT_PATTERN_NAME:-Default Pattern}" -OWNER_ROLE_ID=2 -OWNER_ROLE_NAME="InstanceOwner" +OWNER_ROLE_ID=${E2E_OWNER_ROLE_ID:-2} +OWNER_ROLE_NAME="${E2E_OWNER_ROLE_NAME:-InstanceOwner}" CHART_PATH="./deploy/charts/command-cert-manager-issuer" @@ -854,18 +856,20 @@ cd .. echo "⚙️ Local image deployment: ${IS_LOCAL_DEPLOYMENT}" echo "⚙️ Local Helm chart: ${IS_LOCAL_HELM}" -if ! minikube status &> /dev/null; then - echo "Error: Minikube is not running. Please start it with 'minikube start'" - exit 1 +# Use existing kubeconfig context (set USE_MINIKUBE=true to use minikube) +if [ "${USE_MINIKUBE:-false}" = "true" ]; then + if ! minikube status &> /dev/null; then + echo "Error: Minikube is not running. Please start it with 'minikube start'" + exit 1 + fi + kubectl config use-context minikube + echo "📡 Connecting to Minikube Docker environment..." + eval $(minikube docker-env) +else + echo "📡 Using current kubeconfig context..." fi - -kubectl config use-context minikube echo "Connected to Kubernetes context: $(kubectl config current-context)..." - -# 1. Connect to minikube Docker env -echo "📡 Connecting to Minikube Docker environment..." -eval $(minikube docker-env) -echo "🚀 Starting deployment to Minikube..." +echo "🚀 Starting deployment..." # 2. Deploy cert-manager Helm chart if not exists echo "🔐 Checking for cert-manager installation..." @@ -883,11 +887,25 @@ kubectl create namespace ${MANAGER_NAMESPACE} --dry-run=client -o yaml | kubectl # 4. Build the command-cert-manager-issuer Docker image # This step is only needed if the image tag is "local" -if "$IS_LOCAL_DEPLOYMENT" = "true"; then +if [ "$IS_LOCAL_DEPLOYMENT" = "true" ]; then + if [ "${USE_MINIKUBE:-false}" != "true" ]; then + echo "⚠️ WARNING: Local deployment without minikube requires pushing the image to a registry." + echo "⚠️ Set IMAGE_REGISTRY env var to push, or use a published IMAGE_TAG instead." + fi echo "🐳 Building ${FULL_IMAGE_NAME} Docker image..." docker build -t ${FULL_IMAGE_NAME} . echo "✅ Docker image built successfully" + # If IMAGE_REGISTRY is set, push the image + if [ -n "${IMAGE_REGISTRY:-}" ]; then + REMOTE_IMAGE="${IMAGE_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" + echo "📤 Tagging and pushing image to ${REMOTE_IMAGE}..." + docker tag ${FULL_IMAGE_NAME} ${REMOTE_IMAGE} + docker push ${REMOTE_IMAGE} + FULL_IMAGE_NAME="${REMOTE_IMAGE}" + echo "✅ Image pushed successfully" + fi + echo "📦 Listing Docker images..." docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}\t{{.Size}}" | head -5 fi