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/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/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 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)) + } +}