From 9a7f1299b3cc71714c4231013f149ae9251ad061 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Thu, 6 Nov 2025 12:08:14 -0500 Subject: [PATCH 01/43] chore: Add documentation for Google ambient credentials Signed-off-by: Matthew H. Irby --- docs/ambient-providers/google.md | 488 +++++++++++++++++++++++++++++++ docsource/content.md | 1 + 2 files changed, 489 insertions(+) create mode 100644 docs/ambient-providers/google.md diff --git a/docs/ambient-providers/google.md b/docs/ambient-providers/google.md new file mode 100644 index 0000000..e3d1850 --- /dev/null +++ b/docs/ambient-providers/google.md @@ -0,0 +1,488 @@ +# Ambient Credentials with Google Kubernetes Engine (GKE) + +> **IMPORTANT**: Support for adding Google as an identity provider in Command is only officially supported with Keyfactor Command 25.1.2+ and 25.2.1+. If you are on an older version of Command, please contact Keyfactor Customer Support for assistance on adding Google as an identity provider. + +This documentation covers the various ways to configure GKE workload identity for your workload to use ambient credentials with Keyfactor Command. Please refer to the official [Google documentation for workload identity federation](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) for the most up-to-date information regarding workload identity with GKE. For more information about what workload identity is and how it works in GKE, please refer [here](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity). + +## Authentication Options Overview + +GKE workloads can authenticate to external services like Keyfactor Command by obtaining ID tokens from the GKE metadata server. There are two approaches to configure this: + +1. **Workload Identity Federation for GKE with Service Account Impersonation** (Recommended) - Kubernetes ServiceAccounts are bound to Google Service Accounts, allowing fine-grained, per-workload identity management. The GKE metadata server uses the bound Google Service Account to generate ID tokens. +2. **Compute Engine Default Service Account** (Not recommended for production) - Workloads use a shared node-level service account; all workloads on the same node inherit these credentials with no isolation. + +This guide covers both approaches, but ***Workload Identity Federation for GKE with Service Account Impersonation is the recommended method*** for new deployments due to its improved security model and workload isolation. + +> **Important**: For the GKE metadata server to generate ID tokens, a Google Service Account must be available. In Option 1, you explicitly create and bind a GSA to your Kubernetes ServiceAccount. In Option 2, the Compute Engine default service account is used implicitly. + +> For more information on alternatives to Workload Identity Federation for GKE (and security compromises associated with these alternatives), please refer [to this list](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity). + +> For more information about service accounts in GKE, please refer to [this link](https://cloud.google.com/kubernetes-engine/docs/how-to/service-accounts). + +## Prerequisites + +Before configuring ambient credentials with GKE, ensure you have met the requirements [specified in Google's GKE guide](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) in addition to the following: + +- A GKE cluster (version 1.12 or later recommended; 1.24+ for all Workload Identity Federation features) +- `gcloud` CLI installed and authenticated +- `kubectl` configured to access your cluster +- Appropriate IAM permissions: + - `roles/container.admin` (for cluster configuration) + - `roles/iam.serviceAccountAdmin` (for service account management) + - `roles/iam.securityAdmin` (for IAM policy binding) +- Keyfactor Command 25.1.2+ or 25.2.1+ with Google OIDC provider configured ([how to configure](#configuring-google-as-identity-provider-in-keyfactor-command)) + +## GKE Identity Configuration Options + +### Option 1: Workload Identity Federation for GKE with Service Account Impersonation (Recommended) + +Workload Identity Federation for GKE with Service Account impersonation is the **most secure** method to grant your workloads the ability to obtain ID tokens for authentication. This approach: + +1. Creates a Google Service Account (GSA) specifically for your workload +2. Binds your Kubernetes ServiceAccount (KSA) to the GSA through IAM policy +3. Annotates the KSA to indicate which GSA to use +4. Allows the GKE metadata server to generate ID tokens using the GSA's identity + +#### Why Service Account Impersonation is Required + +The GKE metadata server endpoint (`metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity`) requires a Google Service Account to generate ID tokens. Without a GSA bound to your KSA: +- The metadata server has no identity to issue tokens for +- Token generation requests will fail with "service account not defined" errors +- Your workload cannot authenticate to external services + +The KSA annotation (`iam.gke.io/gcp-service-account`) tells the metadata server which GSA to use when generating tokens for pods using that KSA. + +#### Advantages +- **Better Security**: Fine-grained, per-workload identity without shared credentials +- **Workload Isolation**: Each workload can have its own dedicated GSA with specific permissions +- **Audit Trail**: Clear mapping between Kubernetes workloads and Google Service Accounts +- **Principle of Least Privilege**: Grant only the minimum required permissions to each workload + +#### Setup + +For the below steps, configure your environment variables. + +```bash +# Get project-level metadata +export PROJECT_ID=$(gcloud config get project) # use "gcloud projects list" to get a list of projects and "gcloud config set project " to set the project +export PROJECT_NUMBER=$(gcloud projects describe ${PROJECT_ID} \ + --format="value(projectNumber)") + +export CLUSTER_NAME="cluster-name-here" # The name of your GKE cluster +export REGION="cluster-region" # The region your GKE cluster is deployed to (i.e. us-east1) + +export DEPLOYMENT_NAME="command-issuer" # The Helm chart deployment name +export KSA_NAMESPACE="command-issuer-system" # The namespace your command-cert-manager-issuer is deployed to (change if different than defined in root README) +export KSA_NAME="command-issuer" # This is the Kubernetes ServiceAccount that is automatically created when command-cert-manager-issuer is deployed with Helm +export GSA_NAME="command-cert-manager-issuer-gsa" # Google Service Account that will be created to grant the KSA permissions to assume its identity + +export NODEPOOL_NAME="gke-wi-nodepool" # The nodepool that will have the GKE metadata server enabled on it +``` + +#### Step 1: Enable Workload Identity Federation on Your Cluster + +For **existing clusters**, enable Workload Identity Federation: + +```bash +# Enable Workload Identity Federation on the cluster +gcloud container clusters update ${CLUSTER_NAME} \ +--location=${REGION} \ +--workload-pool=${PROJECT_ID}.svc.id.goog +``` + +For **new clusters**, create with Workload Identity Federation enabled: + +```bash +# Create cluster with Workload Identity Federation +gcloud container clusters create ${CLUSTER_NAME} \ +--region=${REGION} \ +--workload-pool=${PROJECT_ID}.svc.id.goog +``` + +> **Note**: If your cluster was created after May 30, 2024 (Standard) or June 18, 2024 (Autopilot), Workload Identity is enabled by default. You can verify this with: +> ```bash +> gcloud container clusters describe ${CLUSTER_NAME} \ +> --location=${REGION} \ +> --format="value(workloadIdentityConfig.workloadPool)" +> ``` + +#### Step 2: Configure Node Pools (if needed) + +Check if your node pools have the GKE metadata server enabled: + +```bash +# Check the workload metadata configuration +gcloud container node-pools describe \ + --cluster=${CLUSTER_NAME} \ + --location=${REGION} \ + --format="value(config.workloadMetadataConfig.mode)" +``` + +If the output is `GKE_METADATA`, you can skip this step. If it's `GCE_METADATA` or empty, create a new node pool or update existing pools: + +```bash +# Option A: Create a new node pool with GKE_METADATA +gcloud container node-pools create ${NODEPOOL_NAME} \ + --cluster=${CLUSTER_NAME} \ + --location=${REGION} \ + --workload-metadata=GKE_METADATA + +# Option B: Update existing node pool (requires recreation of nodes) +gcloud container node-pools update \ + --cluster=${CLUSTER_NAME} \ + --location=${REGION} \ + --workload-metadata=GKE_METADATA +``` + +> **Note**: Clusters created after the dates mentioned in Step 1 have `GKE_METADATA` enabled by default on all node pools. + +#### Step 3: Create Google Service Account + +Create a Google Service Account that will be used to generate ID tokens: + +```bash +# Create the Google Service Account +gcloud iam service-accounts create ${GSA_NAME} \ + --display-name="command-cert-manager-issuer Service Account" \ + --project=${PROJECT_ID} +``` + +> **Important**: This GSA doesn't need any GCP API permissions unless your workload needs to access other Google Cloud services. For ID token generation alone, the service account just needs to exist. + +#### Step 4: Create Kubernetes Namespace and ServiceAccount + +```bash +# Get cluster credentials +gcloud container clusters get-credentials ${CLUSTER_NAME} \ + --region=${REGION} + +# Create namespace if it doesn't already exist +kubectl create namespace ${KSA_NAMESPACE} 2>/dev/null || true + +# Create Kubernetes ServiceAccount if it doesn't already exist +kubectl create serviceaccount ${KSA_NAME} \ + --namespace=${KSA_NAMESPACE} 2>/dev/null || true +``` + +#### Step 5: Create Workload Identity Binding + +Bind the Kubernetes ServiceAccount to the Google Service Account, allowing the KSA to impersonate the GSA: + +```bash +# Allow the KSA to impersonate the GSA +gcloud iam service-accounts add-iam-policy-binding ${GSA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com \ + --role roles/iam.workloadIdentityUser \ + --member "serviceAccount:${PROJECT_ID}.svc.id.goog[${KSA_NAMESPACE}/${KSA_NAME}]" +``` + +This grants the `roles/iam.workloadIdentityUser` role to the Kubernetes ServiceAccount, allowing it to act as the Google Service Account. + +#### Step 6: Annotate Kubernetes ServiceAccount + +Annotate the KSA to specify which GSA it should use: + +```bash +# Annotate the KSA with the GSA email +kubectl annotate serviceaccount ${KSA_NAME} \ + --namespace ${KSA_NAMESPACE} \ + iam.gke.io/gcp-service-account=${GSA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com +``` + +This annotation is **critical** - it tells the GKE metadata server which Google Service Account to use when generating ID tokens for pods using this KSA. + +#### Step 7: Update Workload to Use GKE Metadata Server Nodes (if needed) + +If you created a new node pool with `GKE_METADATA` enabled, update your deployment to schedule pods on those nodes: + +If `command-cert-manager-issuer` was deployed using Helm: + +```bash +helm upgrade ${DEPLOYMENT_NAME} deploy/charts/command-cert-manager-issuer \ + --namespace ${KSA_NAMESPACE} \ + --reuse-values \ + --set-string "nodeSelector.iam\.gke\.io/gke-metadata-server-enabled=true" +``` + +If deployed without Helm, edit the Deployment directly: + +```bash +kubectl edit deployment ${DEPLOYMENT_NAME} -n ${KSA_NAMESPACE} +``` + +Add the nodeSelector under `spec.template.spec`: + +```yaml +spec: + template: + spec: + nodeSelector: + iam.gke.io/gke-metadata-server-enabled: "true" +``` + +Then restart the deployment: + +```bash +kubectl rollout restart deployment ${DEPLOYMENT_NAME} -n ${KSA_NAMESPACE} +``` + +> **Note**: If all your node pools have `GKE_METADATA` enabled, you can skip the nodeSelector configuration. + +#### Step 8: Retrieve Identity Information for Keyfactor Command + +Get the OAuth Client ID (unique ID) of the Google Service Account: + +```bash +gcloud iam service-accounts describe ${GSA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com \ + --format="value(oauth2ClientId)" +``` + +This ID will be used to create a security claim in Keyfactor Command for your identity provider. + +--- + +### Option 2: Compute Engine Default Service Account (Not Recommended for Production) + +> **SECURITY WARNING**: All pods on the same node share the same service account, which violates the principle of least privilege. This approach is provided for reference only and is **strongly discouraged** for production use. + +When creating a GKE cluster without specifying a custom service account, nodes automatically use the Compute Engine [default service account](https://cloud.google.com/compute/docs/access/service-accounts#token) (`-compute@developer.gserviceaccount.com`). This service account can be used by the GKE metadata server to generate ID tokens. + +#### Security Concerns + +- By default, the Compute Engine service account has the Editor role, which is overly permissive +- All pods on the same node share this identity with no isolation +- No per-workload credential management +- Violates the principle of least privilege +- Increases blast radius in case of pod compromise +- Cannot distinguish between different workloads in audit logs + +**For production environments, use Option 1 instead.** + +For the below steps, configure your environment variables: + +```bash +# Get project-level metadata +export PROJECT_ID=$(gcloud config get project) # use "gcloud projects list" to get a list of projects and "gcloud config set project " to set the project +export PROJECT_NUMBER=$(gcloud projects describe ${PROJECT_ID} \ + --format="value(projectNumber)") + +export CLUSTER_NAME="cluster-name-here" # The name of your GKE cluster +export REGION="cluster-region" # The region your GKE cluster is deployed to (i.e. us-east1) +``` + +#### Step 1: Check Current Configuration + +Verify that your cluster is using the default node service account: + +```bash +# Check if Workload Identity Federation is enabled +gcloud container clusters describe ${CLUSTER_NAME} \ + --region=${REGION} \ + --format="value(workloadIdentityConfig.workloadPool)" + +# If empty, Workload Identity Federation is NOT enabled + +# Check node pool service account +gcloud container node-pools describe default-pool \ + --cluster=${CLUSTER_NAME} \ + --region=${REGION} \ + --format="value(config.serviceAccount)" + +# If "default", you're using the Compute Engine default service account +``` + +#### Step 2: Retrieve Identity Information + +Get the OAuth Client ID (unique ID) of the Compute Engine default service account: + +```bash +# Get the unique ID (sub claim) +gcloud iam service-accounts describe \ + ${PROJECT_NUMBER}-compute@developer.gserviceaccount.com \ + --format='value(oauth2ClientId)' +``` + +This ID will be used to create a security claim in Keyfactor Command for your identity provider. + +## Configuring Google as Identity Provider in Keyfactor Command + +After configuring your GKE workload identity, you need to set up Google as an identity provider in Keyfactor Command. + +### Step 1: Navigate to Identity Providers + +1. Log in to Keyfactor Command +2. Navigate to **Settings** > **Identity Providers** +3. Click **Add** + +### Step 2: Import Discovery Document + +Use Google's standard OIDC discovery endpoint: + +``` +https://accounts.google.com/.well-known/openid-configuration +``` + +This endpoint provides the necessary configuration for Google's identity provider, including the issuer URL, token endpoints, and supported claims. + +### Step 3: Configure Claim Mappings + +Configure the following claim mappings: + +- **Name Claim Type** (OAuth Subject): `sub` +- **Unique Claim Type** (OAuth Object ID): `azp` (or `sub`, depending on your token format) +- **Display Name**: Google GKE (or your preferred name) + +> **Note**: For programmatic API access, Command requires you to fill in Client ID and Client Secret fields, but these values are not actually used for workload identity authentication. You can use any placeholder values for these fields. + +### Step 4: Save and Test + +1. Click **Save** to create the identity provider +2. Test the configuration by retrieving a token from your workload +3. Verify the token is accepted by Keyfactor Command + +### Step 5: Map Identity to Security Roles + +After saving the identity provider: + +1. Navigate to **Security** > **Security Roles** +2. Select or create a security role for your workload +3. Add a security claim with the appropriate identifier: + - For **Option 1 (Workload Identity with SA impersonation)**: Use the OAuth Client ID of your Google Service Account (from Step 8 above) + - For **Option 2 (Compute Engine default SA)**: Use the OAuth Client ID of the Compute Engine default service account +4. Configure the appropriate permissions for certificate operations + +The security claim format in Command should be: +- **Claim Type**: OAuth Subject (or similar, depending on your token's `sub` claim) +- **Claim Value**: The numeric OAuth Client ID retrieved in the setup steps + +--- + +## Verification and Troubleshooting + +### Verify Workload Identity Configuration + +Test the complete setup with a temporary pod: + +```bash +# Deploy a test pod using your KSA +kubectl run -it --rm test-wi \ + --image=google/cloud-sdk:slim \ + --serviceaccount=${KSA_NAME} \ + --namespace=${KSA_NAMESPACE} \ + -- bash + +# Inside the pod, verify the service account annotation is working +curl -H "Metadata-Flavor: Google" \ + http://metadata/computeMetadata/v1/instance/service-accounts/default/email + +# This should return: @.iam.gserviceaccount.com + +# Get an ID token for your audience (e.g., your Command instance) +curl -H "Metadata-Flavor: Google" \ + "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://your-keyfactor-command-instance.com&format=full" + +# You should receive a JWT token +``` + +### Verify Token Claims + +Decode the token to verify it contains the expected claims: + +```bash +# Copy the token from the previous step and decode it at https://jwt.io +# Or use a CLI tool: +echo "" | cut -d. -f2 | base64 -d | jq . +``` + +Expected claims: +- `iss`: Should be `https://accounts.google.com` +- `sub`: Should be the OAuth Client ID of your GSA +- `email`: Should be `@.iam.gserviceaccount.com` +- `aud`: Should match your audience parameter + +### Common Issues + +> For any issues not covered below, check out the [root README's troubleshooting](../../README.md#troubleshooting) section. + +#### Issue: "metadata: GCE metadata 'instance/service-accounts/default/identity' not defined" + +**Cause**: The KSA annotation is missing or incorrect, or the workload identity binding is not configured + +**Solution**: +1. Verify the KSA annotation exists: + ```bash + kubectl get serviceaccount ${KSA_NAME} -n ${KSA_NAMESPACE} -o yaml | grep iam.gke.io/gcp-service-account + ``` +2. Verify the workload identity binding: + ```bash + gcloud iam service-accounts get-iam-policy ${GSA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com + ``` +3. Ensure pods are restarted after adding the annotation: + ```bash + kubectl rollout restart deployment ${DEPLOYMENT_NAME} -n ${KSA_NAMESPACE} + ``` + +#### Issue: "Permission denied" errors + +**Cause**: IAM permissions not correctly configured + +**Solution**: +- Verify the workload identity binding is correct: + ```bash + gcloud iam service-accounts get-iam-policy ${GSA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com + ``` +- Ensure the binding includes `roles/iam.workloadIdentityUser` for the correct KSA +- Check that the workload pool is correctly configured on the cluster + +#### Issue: "Invalid token" from Keyfactor Command + +**Cause**: Issuer URL mismatch or incorrect claim mapping + +**Solution**: +- Verify the issuer URL in Keyfactor matches the token's `iss` claim (`https://accounts.google.com`) +- Check that the security claim in Keyfactor Command matches the token's `sub` claim (should be the OAuth Client ID) +- Ensure the token audience matches what Keyfactor Command expects +- Verify the identity provider discovery document was imported correctly + +#### Issue: Pod cannot authenticate / Workload Identity not working + +**Cause**: Workload Identity not enabled on cluster or node pool metadata incorrect + +**Solution**: +```bash +# Verify Workload Identity is enabled on cluster +gcloud container clusters describe ${CLUSTER_NAME} \ + --location=${REGION} \ + --format="value(workloadIdentityConfig.workloadPool)" + +# Should output: .svc.id.goog + +# Check node pool metadata configuration +gcloud container node-pools describe \ + --cluster=${CLUSTER_NAME} \ + --location=${REGION} \ + --format="value(config.workloadMetadataConfig.mode)" + +# Should output: GKE_METADATA + +# If not correct, update the cluster: +gcloud container clusters update ${CLUSTER_NAME} \ + --location=${REGION} \ + --workload-pool=${PROJECT_ID}.svc.id.goog + +# And update/create node pool: +gcloud container node-pools create ${NODEPOOL_NAME} \ + --cluster=${CLUSTER_NAME} \ + --location=${REGION} \ + --workload-metadata=GKE_METADATA +``` + +--- + +## Additional Resources + +- [Official GKE Workload Identity Documentation](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) +- [Workload Identity Federation Concepts](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity) +- [Supported Products and Limitations](https://cloud.google.com/iam/docs/federated-identity-supported-services) +- [Keyfactor Command Identity Provider Documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/IdentityProviderOperations.htm) +- [Google Service Account Documentation](https://cloud.google.com/iam/docs/service-account-overview) +- [Best Practices for GKE Workload Identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity#best_practices) diff --git a/docsource/content.md b/docsource/content.md index 898600a..d77894c 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -132,6 +132,7 @@ These credentials must be configured using a Kubernetes Secret. By default, the Command Issuer also supports ambient authentication, where a token is fetched from an Authorization Server using a cloud provider's auth infrastructure and passed to Command directly. The following methods are supported: - [Managed Identity Using Azure Entra ID Workload Identity](./docs/ambient-providers/azure.md) (if running in [AKS](https://azure.microsoft.com/en-us/products/kubernetes-service)) +- [Workload Identity Using Google Kubernetes Engine](./docs/ambient-providers/google.md) (if running in [GKE](https://cloud.google.com/kubernetes-engine)) If you are running your Kubernetes workload in a cloud provider not listed above, you can use workload identity federation with [Azure AD](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation). From 35e48705ed752d8dde7c2c8eed9fdf0f19ceb5ca Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Thu, 6 Nov 2025 12:08:37 -0500 Subject: [PATCH 02/43] feat: add timeout to getting azure default credentials Signed-off-by: Matthew H. Irby --- internal/command/client.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/command/client.go b/internal/command/client.go index e6d1ca0..dbeb44b 100644 --- a/internal/command/client.go +++ b/internal/command/client.go @@ -20,6 +20,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" @@ -107,6 +108,10 @@ type azure struct { func (a *azure) GetAccessToken(ctx context.Context) (string, error) { log := log.FromContext(ctx) + // Try Azure with a short timeout + timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // To prevent clogging logs every time JWT is generated initializing := a.cred == nil @@ -122,7 +127,7 @@ func (a *azure) GetAccessToken(ctx context.Context) (string, error) { log.Info(fmt.Sprintf("generating Default Azure Credentials with scopes %s", strings.Join(a.scopes, " "))) // Request a token with the provided scopes - token, err := a.cred.GetToken(ctx, policy.TokenRequestOptions{ + token, err := a.cred.GetToken(timeoutCtx, policy.TokenRequestOptions{ Scopes: a.scopes, }) if err != nil { From 30047c6847a6e768de1bd473301e3d00b3339dc4 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Thu, 6 Nov 2025 17:13:24 +0000 Subject: [PATCH 03/43] Update generated docs --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2d3ce53..ac19f0e 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ These credentials must be configured using a Kubernetes Secret. By default, the Command Issuer also supports ambient authentication, where a token is fetched from an Authorization Server using a cloud provider's auth infrastructure and passed to Command directly. The following methods are supported: - [Managed Identity Using Azure Entra ID Workload Identity](./docs/ambient-providers/azure.md) (if running in [AKS](https://azure.microsoft.com/en-us/products/kubernetes-service)) +- [Workload Identity Using Google Kubernetes Engine](./docs/ambient-providers/google.md) (if running in [GKE](https://cloud.google.com/kubernetes-engine)) If you are running your Kubernetes workload in a cloud provider not listed above, you can use workload identity federation with [Azure AD](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation). From 978d0e82c49568f0cb95d3163a74bc418c7cd5d4 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Mon, 15 Dec 2025 10:22:41 -0500 Subject: [PATCH 04/43] chore: update docs and attach parent context to timeout Signed-off-by: Matthew H. Irby --- docs/ambient-providers/google.md | 45 ++------------------------------ internal/command/client.go | 2 +- 2 files changed, 3 insertions(+), 44 deletions(-) diff --git a/docs/ambient-providers/google.md b/docs/ambient-providers/google.md index e3d1850..da9feb5 100644 --- a/docs/ambient-providers/google.md +++ b/docs/ambient-providers/google.md @@ -64,7 +64,7 @@ For the below steps, configure your environment variables. ```bash # Get project-level metadata -export PROJECT_ID=$(gcloud config get project) # use "gcloud projects list" to get a list of projects and "gcloud config set project " to set the project +export PROJECT_ID=$(gcloud config get project) # use "gcloud projects list" to get a list of projects and "gcloud config set project " to set the project export PROJECT_NUMBER=$(gcloud projects describe ${PROJECT_ID} \ --format="value(projectNumber)") @@ -356,48 +356,7 @@ The security claim format in Command should be: --- -## Verification and Troubleshooting - -### Verify Workload Identity Configuration - -Test the complete setup with a temporary pod: - -```bash -# Deploy a test pod using your KSA -kubectl run -it --rm test-wi \ - --image=google/cloud-sdk:slim \ - --serviceaccount=${KSA_NAME} \ - --namespace=${KSA_NAMESPACE} \ - -- bash - -# Inside the pod, verify the service account annotation is working -curl -H "Metadata-Flavor: Google" \ - http://metadata/computeMetadata/v1/instance/service-accounts/default/email - -# This should return: @.iam.gserviceaccount.com - -# Get an ID token for your audience (e.g., your Command instance) -curl -H "Metadata-Flavor: Google" \ - "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://your-keyfactor-command-instance.com&format=full" - -# You should receive a JWT token -``` - -### Verify Token Claims - -Decode the token to verify it contains the expected claims: - -```bash -# Copy the token from the previous step and decode it at https://jwt.io -# Or use a CLI tool: -echo "" | cut -d. -f2 | base64 -d | jq . -``` - -Expected claims: -- `iss`: Should be `https://accounts.google.com` -- `sub`: Should be the OAuth Client ID of your GSA -- `email`: Should be `@.iam.gserviceaccount.com` -- `aud`: Should match your audience parameter +## Troubleshooting ### Common Issues diff --git a/internal/command/client.go b/internal/command/client.go index dbeb44b..36616e3 100644 --- a/internal/command/client.go +++ b/internal/command/client.go @@ -109,7 +109,7 @@ func (a *azure) GetAccessToken(ctx context.Context) (string, error) { log := log.FromContext(ctx) // Try Azure with a short timeout - timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() // To prevent clogging logs every time JWT is generated From 3ad8cfec96640254302f9fe3c2a00eb65aeb6801 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Thu, 15 Jan 2026 15:07:02 -0500 Subject: [PATCH 05/43] chore(ci): update GH Actions to leverage latest starter workflow Signed-off-by: Matthew H. Irby --- .github/workflows/keyfactor-bootstrap-workflow.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml index f72b649..21fb50b 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -46,13 +46,12 @@ jobs: run: go test -v ./... call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@3.2.0 + uses: keyfactor/actions/.github/workflows/starter.yml@v4 needs: test secrets: token: ${{ secrets.V2BUILDTOKEN}} gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} - APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}} scan_token: ${{ secrets.SAST_TOKEN }} docker-user: ${{ secrets.DOCKER_USER }} docker-token: ${{ secrets.DOCKER_PWD }} From 5435700dc48c272d8c52d5b8870d0f9ddee6a198 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Thu, 15 Jan 2026 15:07:47 -0500 Subject: [PATCH 06/43] feat(e2e): update e2e tests to use the caSecretName spec field Signed-off-by: Matthew H. Irby --- e2e/.env.example | 4 ++- e2e/.gitignore | 2 ++ e2e/README.md | 51 +++++++++++++++++++++++++++++++-- e2e/certs/.gitkeep | 0 e2e/run_tests.sh | 71 +++++++++++++++++++++++++++++++++++++++------- 5 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 e2e/.gitignore create mode 100644 e2e/certs/.gitkeep diff --git a/e2e/.env.example b/e2e/.env.example index 874e5fa..c0028a3 100644 --- a/e2e/.env.example +++ b/e2e/.env.example @@ -7,4 +7,6 @@ export CERTIFICATE_AUTHORITY_LOGICAL_NAME="Sub-CA" export OAUTH_TOKEN_URL="https://example.com/oauth2/token" export OAUTH_CLIENT_ID="changeme" -export OAUTH_CLIENT_SECRET='changeme' \ No newline at end of file +export OAUTH_CLIENT_SECRET='changeme' + +export DISABLE_CA_CHECK="false" # Set to true to disable CA check in tests \ No newline at end of file diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..0caca0b --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,2 @@ +certs/* +!**/.gitkeep \ No newline at end of file diff --git a/e2e/README.md b/e2e/README.md index f4fbe62..0348d38 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,7 +1,54 @@ # End-to-End Test Suite -This is a test suite intended to make it easy to run tests on the command-cert-manager-issuer project. This suite can test the local changes of the command issuer, and it is able to test existing Docker images. +This is a test suite intended to make it easy to run end-to-end tests on the command-cert-manager-issuer project. This suite can test the local changes of the Command issuer, and it is able to test existing Docker images. + +The test suite does the following: +- Deploys command-cert-manager-issuer to a Kubernetes cluster with the desired version +- Creates an issuer (Issuer and ClusterIssuer) +- Creates a Certificate custom resource +- Waits for cert-manager to create a CertificateRequest, then signs the request +- Waits for the issuer to handle the CertificateRequest +- Verifies the CertificateRequest has been successfully processed and an issuer secret is created with the related certificate information. This is currently configured as a Bash script, so it is necessary to run this on a UNIX-compatible machine. -Instructions on how to run the e2e test suite are within the [run_tests.sh](./run_tests.sh) file. \ No newline at end of file +## Requirements +- An available EJBCA is running and configured as described in the [root README](../README.md#configuring-command) + - OAuth is used to communicate with Command +- Docker (>= 28.2.2) +- Minikube (>= v1.35.0) +- kubectl (>= v1.32.2) +- helm (>= v3.17.1) +- cmctl (>= v2.1.1) + +## 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 EJBCCommand instance. + +A `.env.example` file is available as a template for your environment variables. + +```bash +# copy .env.example to .env +cp .env.example .env +``` + +Modify the fields as needed. + +## 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. + +> NOTE: This check can be disabled by setting the env variable `DISABLE_CA_CHECK=true`. + +## Running the script + +```bash +# enable the script to be executed +chmod +x ./run_test.sh + +# load the environment variables +source .env + +# run the end-to-end tests +./run_tests.sh +``` \ No newline at end of file diff --git a/e2e/certs/.gitkeep b/e2e/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index 84987d6..5ccc557 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -1,7 +1,7 @@ #!/bin/bash ## ======================= LICENSE =================================== -# Copyright © 2025 Keyfactor +# Copyright © 2026 Keyfactor # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -26,15 +26,6 @@ ## ======================================================================= -## ======================= Requirements =================================== -# - Minikube running -# - Helm installed -# - Docker installed -# - kubectl installed -# - cmctl installed -# - cert-manager Helm chart available -## =========================================================================== - ## ======================= How to run =================================== # Enable the script to run: # > chmod +x run_tests.sh @@ -87,6 +78,9 @@ SIGNER_CA_SECRET_NAME="ca-secret" CERTIFICATEREQUEST_CRD_FQTN="certificaterequests.cert-manager.io" +CA_CERTS_PATH="e2e/certs" +CA_SECRET_NAME="ca-trust-secret" + CR_CR_NAME="req" @@ -116,6 +110,7 @@ check_env() { validate_env_present OAUTH_CLIENT_SECRET true validate_env_present CERTIFICATE_AUTHORITY_HOSTNAME false + validate_env_present DISABLE_CA_CHECK false } ns_exists () { @@ -242,6 +237,12 @@ create_issuer() { return 1 fi + caSecretNameSpec="caSecretName: $CA_SECRET_NAME" + if [[ "$DISABLE_CA_CHECK" == "true" ]]; then + echo "Disabling CA check as per DISABLE_CA_CHECK environment variable" + caSecretNameSpec="" + fi + kubectl -n "$ISSUER_NAMESPACE" apply -f - </dev/null | grep -v '.gitkeep')" ]; then + echo "✅ Certificates found in $CA_CERTS_PATH directory." + return 0 + fi + + echo "⚠️ No certificates found in $CA_CERTS_PATH directory. May result in test failures." +} + +create_ca_secret () { + echo "🔐 Creating CA secret resource..." + + check_for_certificates + + kubectl -n ${MANAGER_NAMESPACE} create secret generic $CA_SECRET_NAME --from-file=$CA_CERTS_PATH + + echo "✅ CA secret '$CA_SECRET_NAME' created successfully" +} + +delete_ca_secret() { + echo "🗑️ Deleting CA secret..." + + kubectl -n ${MANAGER_NAMESPACE} delete secret $CA_SECRET_NAME || true + + echo "✅ CA secret '$CA_SECRET_NAME' deleted successfully" +} + +regenerate_ca_secret() { + echo "🔄 Regenerating CA secret..." + + delete_ca_secret + create_ca_secret + + echo "✅ CA secret regenerated successfully" +} # ================= BEGIN: Resource Deployment ===================== @@ -579,6 +624,10 @@ echo "" delete_certificate_request echo "" +echo """🔐 Creating CA secret used for testing..." +regenerate_ca_secret +echo "" + # Deploy Issuer echo "🔐 Deploying $ISSUER_NAMESPACE namespace if not exists..." kubectl create namespace ${ISSUER_NAMESPACE} --dry-run=client -o yaml | kubectl apply -f - @@ -620,6 +669,8 @@ check_certificate_request_status echo "🧪✅ Test 1a completed successfully." echo "" +exit 0 + echo "🧪💬 Test 2: Add EnrollmentPatternId to Issuer resource" regenerate_issuer delete_issuer_specification_field certificateTemplate Issuer From 867284f54d61e7d72ee3a19bd9307f439aefc7d9 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Thu, 15 Jan 2026 17:09:59 -0500 Subject: [PATCH 07/43] chore(docs): add docs around trust-manager Signed-off-by: Matthew H. Irby --- docs/ca-bundle/README.md | 205 +++++++++++++++++++++++++++++++++++++++ docsource/content.md | 27 ++++-- e2e/run_tests.sh | 4 +- 3 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 docs/ca-bundle/README.md diff --git a/docs/ca-bundle/README.md b/docs/ca-bundle/README.md new file mode 100644 index 0000000..49e8039 --- /dev/null +++ b/docs/ca-bundle/README.md @@ -0,0 +1,205 @@ +# CA Bundle + +The command-cert-manager-issuer integration requires a secure, trusted connection with the targeted Keyfactor Command instance. + +## Using Self-Signed Certificates + +If the targeted Keyfactor Command API is configured to use a self-signed certificate or with a certificate whose issuer isn't widely trusted, the CA certificate **must be provided** as a Kubernetes secret. The secret must belong to the same namespace that command-cert-manager-issuer is deployed to. + +```shell +kubectl -n command-issuer-system create secret generic command-ca-secret --from-file=ca.crt +``` + +In the issuer specification, reference the created secret with the `caSecretName` field. The issuer resource does not need to belong to the same namespace as the secret. + +```yaml + apiVersion: command-issuer.keyfactor.com/v1alpha1 + kind: Issuer + metadata: + name: issuer-sample + namespace: default + spec: + ... + caSecretName: "command-ca-secret" +``` + +## Using Publicly Trusted Certificates + +If the targeted Keyfactor Command API is configured with a publicly trusted certificate authority (Sectigo / LetsEncrypt / etc.), the command-cert-manager-issuer image is built with a pre-bundled trust store of publicly trusted certificates but with a ***very important caveat***. The trust store may become out-of-sync over time, especially if the certificate authority issuing the Keyfactor Command certificate is updated. + +It is not required to use the `caSecretName` specification, but it is **recommended** to maintain a list of trusted certificates instead of relying on the pre-bundled certificate store when the command-cert-manager-issuer image is created. This will reduce the likelihood of connectivity issues if the Keyfactor Command instance is updated to use a new CA or if the command-cert-manager-issuer image is updated and it does not include the Command CA in its trust store. + +### trust-manager + +[trust-manager](https://cert-manager.io/docs/trust/trust-manager/) can be used to sync CA trust bundles in a Kubernetes cluster. trust-manager can synchronize a list of publicly trusted CAs as well as any custom CAs to be included in the trust chain. + +#### Pre-requisites + +- cert-manager is already installed in the Kubernetes cluster +- a namespace is already created where trust-manager will sync CA bundles to (i.e. command-issuer-system) + +#### Security Considerations + +> ⚠️ Important: Required Permissions + +trust-manager requires **cluster-wide read access** to secrets. This is a hard technical requirement of trust-manager's architecture. + +| Permission Type | Scope | Resources | Verbs | Purpose | +| --------------- | ------------------ | ------------------- | ----------------------------- | --------------------------------- | +| **Read** | Cluster-wide | secrets, configmaps | get, list, watch | Discover sources, monitor targets | +| **Write** | Namespace-specific | secrets | create, update, patch, delete | Deploy CA bundles | + +#### Setting up trust-manager + +1. Install trust-manager + + Please refer to the [trust-manager installation documentation](https://cert-manager.io/docs/trust/ trust-manager/installation/) for up-to-date installation instructions. + + ```bash + # Install trust-manager in the cert-manager namespace + helm install trust-manager oci://quay.io/jetstack/charts/trust-manager \ + --namespace cert-manager \ + --set secretTargets.enabled=true \ + --create-namespace \ + --wait + ``` +2. Create a Secret from a PEM file + + Create a secret containing the PEM of the CA certificates you want to trust. Create the secret in the same namespace trust-manager is deployed to. + + ```bash + kubectl create secret generic enterprise-root-ca \ + --from-file=ca.crt=/path/to/root-ca.pem \ + --namespace=cert-manager \ + --dry-run=client -o yaml | kubectl apply -f - + ``` + +3a. Create a ClusterRole for trust-manager + + This RBAC policy gives trust-manager read permission to secrets across the entire cluster + + ```yaml + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: trust-manager-read + labels: + app.kubernetes.io/name: trust-manager + rules: + - apiGroups: ["trust.cert-manager.io"] + resources: ["bundles"] + verbs: ["get", "list", "watch"] + - apiGroups: ["trust.cert-manager.io"] + resources: ["bundles/status"] + verbs: ["patch", "update"] + - apiGroups: [""] + resources: ["secrets", "configmaps"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: trust-manager-read + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: trust-manager-read + subjects: + - kind: ServiceAccount + name: trust-manager + namespace: cert-manager + ``` +3b. Create a namepaced Role for trust-manager + + For each namespace that trust-manager should sync secrets to, create a role that allows trust-manager to write secrets + + ```yaml + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + metadata: + name: trust-manager-write + namespace: command-issuer-system # change to your namespace + rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["create", "update", "patch", "delete"] + + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + name: trust-manager-write + namespace: command-issuer-system # change to your namespace + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: trust-manager-write + subjects: + - kind: ServiceAccount + name: trust-manager + namespace: cert-manager + ``` +4. Label target namespaces + + Label the namespace command-cert-manager-issuer is deployed to annotate trust-manager should write secrets to it + + ```bash + kubectl label namespace command-issuer-system command-issuer-ca-bundle=enabled # change to your namespace + ``` + +5. Create a Bundle + + Create a bundle resource to tell trust-manager what secrets to synchronize and whether to include publicly trusted CAs as part of the sync. + + ```yaml + apiVersion: trust.cert-manager.io/v1alpha1 + kind: Bundle + metadata: + name: command-issuer-ca-bundle + spec: + sources: + - useDefaultCAs: true # determines whether to bundle publicly trusted certificates used to validate most TLS certificates on the internet (Let's Encrypt, Google, Amazon, etc.) + + - secret: + name: "enterprise-root-ca" + key: "ca.crt" + + # Additional intermediate or partner CAs + #- secret: + # name: "enterprise-ca-bundle" + # key: "ca.crt" + + target: + secret: + key: "ca.crt" + + # Distribute to all namespaces with this label + namespaceSelector: + matchLabels: + command-issuer-ca-bundle: "enabled" + ``` + +#### Using the trust bundle + +Once the setup is complete, a secret called `command-issuer-ca-bundle` will appear in the desired namespace (i.e. `command-issuer-system`) and the trusted CA bundle will appear in the `ca.crt` key. + +In your issuer specification (Issuer/ClusterIssuer), reference the secret in the `caSecretName` specification field: + +```yaml + apiVersion: command-issuer.keyfactor.com/v1alpha1 + kind: Issuer # or ClusterIssuer + metadata: + ... + spec: + ... + caSecretName: "command-issuer-ca-bundle" +``` \ No newline at end of file diff --git a/docsource/content.md b/docsource/content.md index d77894c..809c593 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -112,8 +112,23 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C --create-namespace ``` + You can also install a specific version of the commnad-cert-manager-issuer Helm chart: + + ```shell + helm search repo command-issuer/command-cert-manager-issuer --versions + ``` + + ```shell + helm install command-cert-manager-issuer command-issuer/command-cert-manager-issuer \ + --namespace command-issuer-system \ + --version 2.4.0 + --create-namespace + ``` + > The Helm chart installs the Command Issuer CRDs by default. The CRDs can be installed manually with the `make install` target. +> A list of configurable Helm chart parameters can be found [in the Helm chart docs](./deploy/charts/command-cert-manager-issuer/README.md#configuration) + # Authentication ## Explicit Credentials @@ -179,11 +194,7 @@ This section has moved. Please refer to [this link](./docs/ambient-providers/azu # CA Bundle -If the Command API is configured to use a self-signed certificate or with a certificate whose issuer isn't widely trusted, the CA certificate must be provided as a Kubernetes secret. - -```shell -kubectl -n command-issuer-system create secret generic command-ca-secret --from-file=ca.crt -``` +This section has been moved. Please refer to the new [CA Bundle docs](./docs/ca-bundle/README.md) documentation regarding CA trust with command-cert-manager-issuer. # Creating Issuer and ClusterIssuer resources @@ -210,7 +221,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | hostname | The hostname of the Command API Server. | | apiPath | (optional) The base path of the Command REST API. Defaults to `KeyfactorAPI`. | | commandSecretName | (optional) The name of the Kubernetes secret containing basic auth credentials or OAuth 2.0 credentials. Omit if using ambient credentials. | - | caSecretName | (optional) The name of the Kubernetes secret containing the CA certificate. Required if the Command API uses a self-signed certificate or it was signed by a CA that is not widely trusted. | + | caSecretName | (optional) The name of the Kubernetes secret containing the CA certificate trust chain. See the [CA Bundle docs](./docs/ca-bundle/README.md) for more information. | | certificateAuthorityLogicalName | The logical name of the Certificate Authority to use in Command. For example, `Sub-CA` | | certificateAuthorityHostname | (optional) The hostname of the Certificate Authority specified by `certificateAuthorityLogicalName`. This field is usually only required if the CA in Command is a DCOM (MSCA-like) CA. | | enrollmentPatternId | The ID of the [Enrollment Pattern](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Enrollment-Patterns.htm) to use when this Issuer/ClusterIssuer enrolls CSRs. **Supported by Keyfactor Command 25.1 and above**. If `certificateTemplate` and `enrollmentPatternId` are both specified, the enrollment pattern parameter will take precedence. If `enrollmentPatternId` and `enrollmentPatternName` are both specified, `enrollmentPatternId` will take precedence. Enrollment will fail if the specified certificate template is not compatible with the enrollment pattern. | @@ -240,7 +251,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou hostname: "$HOSTNAME" apiPath: "/KeyfactorAPI" # Preceding & trailing slashes are handled automatically commandSecretName: "command-secret" # references the secret created above. Omit if using ambient credentials. - caSecretName: "command-ca-secret" # references the secret created above + caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" @@ -270,7 +281,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou hostname: "$HOSTNAME" apiPath: "/KeyfactorAPI" # Preceding & trailing slashes are handled automatically commandSecretName: "command-secret" # references the secret created above. Omit if using ambient credentials. - caSecretName: "command-ca-secret" # references the secret created above + caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index 5ccc557..759064e 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -239,7 +239,7 @@ create_issuer() { caSecretNameSpec="caSecretName: $CA_SECRET_NAME" if [[ "$DISABLE_CA_CHECK" == "true" ]]; then - echo "Disabling CA check as per DISABLE_CA_CHECK environment variable" + echo "⚠️ Disabling CA check as per DISABLE_CA_CHECK environment variable" caSecretNameSpec="" fi @@ -286,7 +286,7 @@ create_cluster_issuer() { caSecretNameSpec="caSecretName: $CA_SECRET_NAME" if [[ "$DISABLE_CA_CHECK" == "true" ]]; then - echo "Disabling CA check as per DISABLE_CA_CHECK environment variable" + echo "⚠️ Disabling CA check as per DISABLE_CA_CHECK environment variable" caSecretNameSpec="" fi From 60c078b1df6709a4a7f8ccb2a82af9959ed1086f Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Fri, 16 Jan 2026 12:56:22 -0500 Subject: [PATCH 08/43] chore(docs): improve ca-bundle documentation for trust-manager Signed-off-by: Matthew H. Irby --- docs/ca-bundle/README.md | 66 ++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/ca-bundle/README.md b/docs/ca-bundle/README.md index 49e8039..b78eecb 100644 --- a/docs/ca-bundle/README.md +++ b/docs/ca-bundle/README.md @@ -4,7 +4,7 @@ The command-cert-manager-issuer integration requires a secure, trusted connectio ## Using Self-Signed Certificates -If the targeted Keyfactor Command API is configured to use a self-signed certificate or with a certificate whose issuer isn't widely trusted, the CA certificate **must be provided** as a Kubernetes secret. The secret must belong to the same namespace that command-cert-manager-issuer is deployed to. +If the targeted Keyfactor Command API is configured to use a self-signed certificate or with a certificate whose issuer isn't widely trusted, the CA certificate **must be provided** as a Kubernetes secret. The secret must belong to the same namespace that command-cert-manager-issuer is deployed to (i.e. `command-issuer-system`). ```shell kubectl -n command-issuer-system create secret generic command-ca-secret --from-file=ca.crt @@ -29,10 +29,14 @@ If the targeted Keyfactor Command API is configured with a publicly trusted cert It is not required to use the `caSecretName` specification, but it is **recommended** to maintain a list of trusted certificates instead of relying on the pre-bundled certificate store when the command-cert-manager-issuer image is created. This will reduce the likelihood of connectivity issues if the Keyfactor Command instance is updated to use a new CA or if the command-cert-manager-issuer image is updated and it does not include the Command CA in its trust store. +This document covers available tools to help manage CA trust bundles. + ### trust-manager [trust-manager](https://cert-manager.io/docs/trust/trust-manager/) can be used to sync CA trust bundles in a Kubernetes cluster. trust-manager can synchronize a list of publicly trusted CAs as well as any custom CAs to be included in the trust chain. +TODO: trust-manager also supports configMap targets, which are more secure to write to with better RBAC policy. + #### Pre-requisites - cert-manager is already installed in the Kubernetes cluster @@ -51,15 +55,15 @@ trust-manager requires **cluster-wide read access** to secrets. This is a hard t #### Setting up trust-manager -1. Install trust-manager +> NOTE: The below instructions are subject to become outdated over time. Please always refer to the [cert-manager](https://cert-manager.io/docs/trust/trust-manager/installation/) documentation for updated installation instructions. - Please refer to the [trust-manager installation documentation](https://cert-manager.io/docs/trust/ trust-manager/installation/) for up-to-date installation instructions. +1. Install trust-manager ```bash # Install trust-manager in the cert-manager namespace helm install trust-manager oci://quay.io/jetstack/charts/trust-manager \ --namespace cert-manager \ - --set secretTargets.enabled=true \ + --set secretTargets.enabled=true \ # required trust-manager to write to Kubernetes secrets --create-namespace \ --wait ``` @@ -74,54 +78,58 @@ trust-manager requires **cluster-wide read access** to secrets. This is a hard t --dry-run=client -o yaml | kubectl apply -f - ``` -3a. Create a ClusterRole for trust-manager +3. Label target namespaces + + Label the namespace command-cert-manager-issuer is deployed to annotate trust-manager should write secrets to it + + ```bash + kubectl label namespace command-issuer-system command-issuer-ca-bundle=enabled # change to your namespace + ``` + +4. Configure RBAC policies for trust-manager + +Due to Kubernetes constraints, writing to secrets outside of trust-manager's namespace is forbidden unless explicit policy is provided. trust-manager needs cluster-level access to read secrets, so a ClusterRole RBAC policy must be created to grant cluster-level read secret access to trust-manager + +4a. Create a ClusterRole for trust-manager This RBAC policy gives trust-manager read permission to secrets across the entire cluster ```yaml + kubectl apply -f - < Date: Fri, 16 Jan 2026 17:59:41 +0000 Subject: [PATCH 09/43] Update generated docs --- README.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ac19f0e..fb77a4d 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,23 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C --create-namespace ``` + You can also install a specific version of the commnad-cert-manager-issuer Helm chart: + + ```shell + helm search repo command-issuer/command-cert-manager-issuer --versions + ``` + + ```shell + helm install command-cert-manager-issuer command-issuer/command-cert-manager-issuer \ + --namespace command-issuer-system \ + --version 2.4.0 + --create-namespace + ``` + > The Helm chart installs the Command Issuer CRDs by default. The CRDs can be installed manually with the `make install` target. +> A list of configurable Helm chart parameters can be found [in the Helm chart docs](./deploy/charts/command-cert-manager-issuer/README.md#configuration) + # Authentication ## Explicit Credentials @@ -211,11 +226,7 @@ This section has moved. Please refer to [this link](./docs/ambient-providers/azu # CA Bundle -If the Command API is configured to use a self-signed certificate or with a certificate whose issuer isn't widely trusted, the CA certificate must be provided as a Kubernetes secret. - -```shell -kubectl -n command-issuer-system create secret generic command-ca-secret --from-file=ca.crt -``` +This section has been moved. Please refer to the new [CA Bundle docs](./docs/ca-bundle/README.md) documentation regarding CA trust with command-cert-manager-issuer. # Creating Issuer and ClusterIssuer resources @@ -242,7 +253,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | hostname | The hostname of the Command API Server. | | apiPath | (optional) The base path of the Command REST API. Defaults to `KeyfactorAPI`. | | commandSecretName | (optional) The name of the Kubernetes secret containing basic auth credentials or OAuth 2.0 credentials. Omit if using ambient credentials. | - | caSecretName | (optional) The name of the Kubernetes secret containing the CA certificate. Required if the Command API uses a self-signed certificate or it was signed by a CA that is not widely trusted. | + | caSecretName | (optional) The name of the Kubernetes secret containing the CA certificate trust chain. See the [CA Bundle docs](./docs/ca-bundle/README.md) for more information. | | certificateAuthorityLogicalName | The logical name of the Certificate Authority to use in Command. For example, `Sub-CA` | | certificateAuthorityHostname | (optional) The hostname of the Certificate Authority specified by `certificateAuthorityLogicalName`. This field is usually only required if the CA in Command is a DCOM (MSCA-like) CA. | | enrollmentPatternId | The ID of the [Enrollment Pattern](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Enrollment-Patterns.htm) to use when this Issuer/ClusterIssuer enrolls CSRs. **Supported by Keyfactor Command 25.1 and above**. If `certificateTemplate` and `enrollmentPatternId` are both specified, the enrollment pattern parameter will take precedence. If `enrollmentPatternId` and `enrollmentPatternName` are both specified, `enrollmentPatternId` will take precedence. Enrollment will fail if the specified certificate template is not compatible with the enrollment pattern. | @@ -272,7 +283,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou hostname: "$HOSTNAME" apiPath: "/KeyfactorAPI" # Preceding & trailing slashes are handled automatically commandSecretName: "command-secret" # references the secret created above. Omit if using ambient credentials. - caSecretName: "command-ca-secret" # references the secret created above + caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" @@ -302,7 +313,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou hostname: "$HOSTNAME" apiPath: "/KeyfactorAPI" # Preceding & trailing slashes are handled automatically commandSecretName: "command-secret" # references the secret created above. Omit if using ambient credentials. - caSecretName: "command-ca-secret" # references the secret created above + caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" From 994c8fdf8990ec81b09b30e425e6ab35bb414c8f Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 20 Jan 2026 10:22:52 -0500 Subject: [PATCH 10/43] chore: re-run Make generate to regenerate templates Signed-off-by: Matthew H. Irby --- Makefile | 2 +- ...d-issuer.keyfactor.com_clusterissuers.yaml | 48 +++++++++---------- .../command-issuer.keyfactor.com_issuers.yaml | 48 +++++++++---------- 3 files changed, 49 insertions(+), 49 deletions(-) diff --git a/Makefile b/Makefile index e59af68..f49281c 100644 --- a/Makefile +++ b/Makefile @@ -67,7 +67,7 @@ test: manifests generate fmt vet envtest ## Run tests. # 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: - go test ./test/e2e/ -v -ginkgo.v + source e2e/.env && ./e2e/run_tests.sh .PHONY: lint lint: golangci-lint ## Run golangci-lint linter & yamllint diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index 0d7feef..ae0aebf 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -68,31 +68,51 @@ spec: CertificateAuthorityLogicalName is the logical name of the certificate authority to use E.g. "Keyfactor Root CA" or "Intermediate CA" type: string + certificateTemplate: + description: |- + Deprecated. CertificateTemplate is the name of the certificate template to use. If using Keyfactor Command 25.1 or later, use EnrollmentPatternName or EnrollmentPatternId instead. + If both enrollment pattern and certificate template are specified, enrollment pattern will take precedence. + Enrollment will fail if the specified template is not compatible with the enrollment pattern. + Refer to the Keyfactor Command documentation for more information. + type: string + commandSecretName: + description: |- + A reference to a K8s kubernetes.io/basic-auth Secret containing basic auth + credentials for the Command instance configured in Hostname. The secret must + be in the same namespace as the referent. If the + referent is a ClusterIssuer, the reference instead refers to the resource + with the given name in the configured 'cluster resource namespace', which + is set as a flag on the controller component (and defaults to the + namespace that the controller runs in). + type: string enrollmentPatternId: description: |- EnrollmentPatternId is the ID of the enrollment pattern to use. Supported in Keyfactor Command 25.1 and later. If both enrollment pattern and certificate template are specified, enrollment pattern will take precedence. - If both enrollmentPatternId and enrollmentPatternName are specified, enrollmentPatternId will take precedence. + If EnrollmentPatternId and EnrollmentPatternName are both specified, EnrollmentPatternId will take precedence. Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. - type: integer format: int32 + type: integer enrollmentPatternName: description: |- EnrollmentPatternName is the name of the enrollment pattern to use. Supported in Keyfactor Command 25.1 and later. If both enrollment pattern and certificate template are specified, enrollment pattern will take precedence. - If both enrollmentPatternId and enrollmentPatternName are specified, enrollmentPatternId will take precedence. + If EnrollmentPatternId and EnrollmentPatternName are both specified, EnrollmentPatternId will take precedence. Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. type: string + hostname: + description: Hostname is the hostname of a Keyfactor Command instance. + type: string ownerRoleId: description: |- OwnerRoleId is the ID of the security role assigned as the certificate owner. The specified security role must be assigned to the authorized identity context. If OwnerRoleId and OwnerRoleName are both specified, OwnerRoleId will take precedence. This field is required if the enrollment pattern, certificate template, or system-wide settings has been configured as Required. - type: integer format: int32 + type: integer ownerRoleName: description: |- OwnerRoleName is the name of the security role assigned as the certificate owner. This name must match the existing name of the security role. @@ -100,26 +120,6 @@ spec: If OwnerRoleId and OwnerRoleName are both specified, OwnerRoleId will take precedence. This field is required if the enrollment pattern, certificate template, or system-wide settings has been configured as Required. type: string - certificateTemplate: - description: |- - CertificateTemplate is the name of the certificate template to use. Deprecated in favor of EnrollmentPattern as of Keyfactor Command 25.1. - If both enrollment pattern and certificate template are specified, enrollment pattern will take precedence. - Enrollment will fail if the specified template is not compatible with the enrollment pattern. - Refer to the Keyfactor Command documentation for more information. - type: string - commandSecretName: - description: |- - A reference to a K8s kubernetes.io/basic-auth Secret containing basic auth - credentials for the Command instance configured in Hostname. The secret must - be in the same namespace as the referent. If the - referent is a ClusterIssuer, the reference instead refers to the resource - with the given name in the configured 'cluster resource namespace', which - is set as a flag on the controller component (and defaults to the - namespace that the controller runs in). - type: string - hostname: - description: Hostname is the hostname of a Keyfactor Command instance. - type: string scopes: description: |- A list of comma separated scopes used when requesting a Bearer token from an ambient token provider implied diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index a4034ce..33ab160 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -68,31 +68,51 @@ spec: CertificateAuthorityLogicalName is the logical name of the certificate authority to use E.g. "Keyfactor Root CA" or "Intermediate CA" type: string + certificateTemplate: + description: |- + Deprecated. CertificateTemplate is the name of the certificate template to use. If using Keyfactor Command 25.1 or later, use EnrollmentPatternName or EnrollmentPatternId instead. + If both enrollment pattern and certificate template are specified, enrollment pattern will take precedence. + Enrollment will fail if the specified template is not compatible with the enrollment pattern. + Refer to the Keyfactor Command documentation for more information. + type: string + commandSecretName: + description: |- + A reference to a K8s kubernetes.io/basic-auth Secret containing basic auth + credentials for the Command instance configured in Hostname. The secret must + be in the same namespace as the referent. If the + referent is a ClusterIssuer, the reference instead refers to the resource + with the given name in the configured 'cluster resource namespace', which + is set as a flag on the controller component (and defaults to the + namespace that the controller runs in). + type: string enrollmentPatternId: description: |- EnrollmentPatternId is the ID of the enrollment pattern to use. Supported in Keyfactor Command 25.1 and later. If both enrollment pattern and certificate template are specified, enrollment pattern will take precedence. - If both enrollmentPatternId and enrollmentPatternName are specified, enrollmentPatternId will take precedence. + If EnrollmentPatternId and EnrollmentPatternName are both specified, EnrollmentPatternId will take precedence. Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. - type: integer format: int32 + type: integer enrollmentPatternName: description: |- EnrollmentPatternName is the name of the enrollment pattern to use. Supported in Keyfactor Command 25.1 and later. If both enrollment pattern and certificate template are specified, enrollment pattern will take precedence. - If both enrollmentPatternId and enrollmentPatternName are specified, enrollmentPatternId will take precedence. + If EnrollmentPatternId and EnrollmentPatternName are both specified, EnrollmentPatternId will take precedence. Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. type: string + hostname: + description: Hostname is the hostname of a Keyfactor Command instance. + type: string ownerRoleId: description: |- OwnerRoleId is the ID of the security role assigned as the certificate owner. The specified security role must be assigned to the authorized identity context. If OwnerRoleId and OwnerRoleName are both specified, OwnerRoleId will take precedence. This field is required if the enrollment pattern, certificate template, or system-wide settings has been configured as Required. - type: integer format: int32 + type: integer ownerRoleName: description: |- OwnerRoleName is the name of the security role assigned as the certificate owner. This name must match the existing name of the security role. @@ -100,26 +120,6 @@ spec: If OwnerRoleId and OwnerRoleName are both specified, OwnerRoleId will take precedence. This field is required if the enrollment pattern, certificate template, or system-wide settings has been configured as Required. type: string - certificateTemplate: - description: |- - CertificateTemplate is the name of the certificate template to use. Deprecated in favor of EnrollmentPattern as of Keyfactor Command 25.1. - If both enrollment pattern and certificate template are specified, enrollment pattern will take precedence. - Enrollment will fail if the specified template is not compatible with the enrollment pattern. - Refer to the Keyfactor Command documentation for more information. - type: string - commandSecretName: - description: |- - A reference to a K8s kubernetes.io/basic-auth Secret containing basic auth - credentials for the Command instance configured in Hostname. The secret must - be in the same namespace as the referent. If the - referent is a ClusterIssuer, the reference instead refers to the resource - with the given name in the configured 'cluster resource namespace', which - is set as a flag on the controller component (and defaults to the - namespace that the controller runs in). - type: string - hostname: - description: Hostname is the hostname of a Keyfactor Command instance. - type: string scopes: description: |- A list of comma separated scopes used when requesting a Bearer token from an ambient token provider implied From 92f9d234cfb1703669cc4ca98fa3fd4cebbc6c76 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 20 Jan 2026 10:23:24 -0500 Subject: [PATCH 11/43] chore(tests): update e2e test documentation Signed-off-by: Matthew H. Irby --- e2e/README.md | 8 ++++++-- e2e/run_tests.sh | 4 +--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index 0348d38..1e16a71 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -13,7 +13,7 @@ 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 EJBCA is running and configured as described in the [root README](../README.md#configuring-command) +- 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 - Docker (>= 28.2.2) - Minikube (>= v1.35.0) @@ -21,8 +21,12 @@ This is currently configured as a Bash script, so it is necessary to run this on - helm (>= v3.17.1) - cmctl (>= v2.1.1) +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 + ## 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 EJBCCommand instance. +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. diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index 759064e..558c128 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -405,7 +405,7 @@ approve_certificate_request() { check_certificate_request_status() { echo "🔎 Checking certificate request status..." - if [[ ! $(kubectl wait --for=condition=Ready certificaterequest/$CR_CR_NAME -n $ISSUER_NAMESPACE --timeout=30s) ]]; then + if [[ ! $(kubectl wait --for=condition=Ready certificaterequest/$CR_CR_NAME -n $ISSUER_NAMESPACE --timeout=90s) ]]; then echo "⚠️ Certificate request did not become ready within the timeout period." echo "Check the Issuer / Command Issuer logs for errors. Check the configuration of your Issuer or CertificateRequest resources." echo "🚫 Test failed" @@ -669,8 +669,6 @@ check_certificate_request_status echo "🧪✅ Test 1a completed successfully." echo "" -exit 0 - echo "🧪💬 Test 2: Add EnrollmentPatternId to Issuer resource" regenerate_issuer delete_issuer_specification_field certificateTemplate Issuer From 96511644bf86700e8b855232be6c8136f8a20a8a Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 20 Jan 2026 10:23:54 -0500 Subject: [PATCH 12/43] chore(tests): add unit tests around method to generate command config Signed-off-by: Matthew H. Irby --- internal/controller/issuer_controller_test.go | 426 ++++++++++++++++++ 1 file changed, 426 insertions(+) diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index fe754c5..e270934 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_test.go @@ -21,6 +21,7 @@ import ( "errors" "testing" + commandissuer "github.com/Keyfactor/command-cert-manager-issuer/api/v1alpha1" commandissuerv1alpha1 "github.com/Keyfactor/command-cert-manager-issuer/api/v1alpha1" "github.com/Keyfactor/command-cert-manager-issuer/internal/command" logrtesting "github.com/go-logr/logr/testing" @@ -619,3 +620,428 @@ func TestIssuerReconcile(t *testing.T) { }) } } + +func TestCommandConfigFromIssuer(t *testing.T) { + type testCase struct { + name string + issuerSpec commandissuerv1alpha1.IssuerSpec + secretNamespace string + secrets []client.Object + expectedConfig *command.Config + expectedError error + expectedErrorMsg string + } + + tests := []testCase{ + { + name: "success-basic-auth", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + SecretName: "auth-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + }, + expectedConfig: &command.Config{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + BasicAuth: &command.BasicAuth{ + Username: "username", + Password: "password", + }, + AmbientCredentialScopes: []string{""}, + AmbientCredentialAudience: "", + }, + }, + { + name: "success-basic-auth-with-ca-cert", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + SecretName: "auth-secret", + CaSecretName: "ca-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + &corev1.Secret{ + Type: corev1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "ca-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + "ca.crt": []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), + }, + }, + }, + expectedConfig: &command.Config{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + CaCertsBytes: []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), + BasicAuth: &command.BasicAuth{ + Username: "username", + Password: "password", + }, + AmbientCredentialScopes: []string{""}, + AmbientCredentialAudience: "", + }, + }, + { + name: "success-oauth-minimal", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + SecretName: "oauth-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "oauth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + commandissuer.OAuthTokenURLKey: []byte("https://oauth.example.com/token"), + commandissuer.OAuthClientIDKey: []byte("client-id"), + commandissuer.OAuthClientSecretKey: []byte("client-secret"), + }, + }, + }, + expectedConfig: &command.Config{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + OAuth: &command.OAuth{ + TokenURL: "https://oauth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + }, + AmbientCredentialScopes: []string{""}, + AmbientCredentialAudience: "", + }, + }, + { + name: "success-oauth-with-scopes-and-audience", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + SecretName: "oauth-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "oauth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + commandissuer.OAuthTokenURLKey: []byte("https://oauth.example.com/token"), + commandissuer.OAuthClientIDKey: []byte("client-id"), + commandissuer.OAuthClientSecretKey: []byte("client-secret"), + commandissuer.OAuthScopesKey: []byte("scope1,scope2,scope3"), + commandissuer.OAuthAudienceKey: []byte("https://api.example.com"), + }, + }, + }, + expectedConfig: &command.Config{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + OAuth: &command.OAuth{ + TokenURL: "https://oauth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + Scopes: []string{"scope1", "scope2", "scope3"}, + Audience: "https://api.example.com", + }, + AmbientCredentialScopes: []string{""}, + AmbientCredentialAudience: "", + }, + }, + { + name: "success-ambient-credentials-with-scopes", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + Scopes: "scope1,scope2", + Audience: "https://api.example.com", + }, + secretNamespace: "ns1", + secrets: []client.Object{}, + expectedConfig: &command.Config{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + AmbientCredentialScopes: []string{"scope1", "scope2"}, + AmbientCredentialAudience: "https://api.example.com", + }, + }, + { + name: "success-no-auth-secret", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + }, + secretNamespace: "ns1", + secrets: []client.Object{}, + expectedConfig: &command.Config{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + AmbientCredentialScopes: []string{""}, + AmbientCredentialAudience: "", + }, + }, + { + name: "error-auth-secret-not-found", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + SecretName: "missing-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{}, + expectedError: errGetAuthSecret, + }, + { + name: "error-ca-secret-not-found", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + CaSecretName: "missing-ca-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{}, + expectedError: errGetCaSecret, + }, + { + name: "error-basic-auth-no-username", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + SecretName: "auth-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + }, + expectedError: errGetAuthSecret, + expectedErrorMsg: "found basic auth secret with no username", + }, + { + name: "error-basic-auth-no-password", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + SecretName: "auth-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + }, + }, + }, + expectedError: errGetAuthSecret, + expectedErrorMsg: "found basic auth secret with no password", + }, + { + name: "error-oauth-no-token-url", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + SecretName: "oauth-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "oauth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + commandissuer.OAuthClientIDKey: []byte("client-id"), + commandissuer.OAuthClientSecretKey: []byte("client-secret"), + }, + }, + }, + expectedError: errGetAuthSecret, + expectedErrorMsg: "found secret with no tokenUrl", + }, + { + name: "error-oauth-no-client-id", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + SecretName: "oauth-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "oauth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + commandissuer.OAuthTokenURLKey: []byte("https://oauth.example.com/token"), + commandissuer.OAuthClientSecretKey: []byte("client-secret"), + }, + }, + }, + expectedError: errGetAuthSecret, + expectedErrorMsg: "found secret with no clientId", + }, + { + name: "error-oauth-no-client-secret", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + SecretName: "oauth-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "oauth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + commandissuer.OAuthTokenURLKey: []byte("https://oauth.example.com/token"), + commandissuer.OAuthClientIDKey: []byte("client-id"), + }, + }, + }, + expectedError: errGetAuthSecret, + expectedErrorMsg: "found secret with no clientSecret", + }, + { + name: "error-unsupported-secret-type", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + SecretName: "auth-secret", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeTLS, + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + "tls.crt": []byte("cert"), + "tls.key": []byte("key"), + }, + }, + }, + expectedError: errGetAuthSecret, + expectedErrorMsg: "found secret with unsupported type", + }, + { + name: "success-cluster-scoped-secret-namespace", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + SecretName: "auth-secret", + }, + secretNamespace: "kube-system", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-secret", + Namespace: "kube-system", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + }, + expectedConfig: &command.Config{ + Hostname: "https://ca.example.com", + BasicAuth: &command.BasicAuth{ + Username: "username", + Password: "password", + }, + AmbientCredentialScopes: []string{""}, + AmbientCredentialAudience: "", + }, + }, + } + + scheme := runtime.NewScheme() + require.NoError(t, commandissuerv1alpha1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tc.secrets...). + Build() + + // Create a minimal issuer with the test spec + issuer := &commandissuerv1alpha1.Issuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-issuer", + Namespace: tc.secretNamespace, + }, + Spec: tc.issuerSpec, + } + + ctx := context.Background() + config, err := commandConfigFromIssuer(ctx, fakeClient, issuer, tc.secretNamespace) + + if tc.expectedError != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tc.expectedError) + if tc.expectedErrorMsg != "" { + assert.Contains(t, err.Error(), tc.expectedErrorMsg) + } + assert.Nil(t, config) + } else { + require.NoError(t, err) + require.NotNil(t, config) + assert.Equal(t, tc.expectedConfig.Hostname, config.Hostname) + assert.Equal(t, tc.expectedConfig.APIPath, config.APIPath) + assert.Equal(t, tc.expectedConfig.CaCertsBytes, config.CaCertsBytes) + assert.Equal(t, tc.expectedConfig.BasicAuth, config.BasicAuth) + assert.Equal(t, tc.expectedConfig.OAuth, config.OAuth) + assert.Equal(t, tc.expectedConfig.AmbientCredentialScopes, config.AmbientCredentialScopes) + assert.Equal(t, tc.expectedConfig.AmbientCredentialAudience, config.AmbientCredentialAudience) + } + }) + } +} From d9e2ff3f7b16fae3df81fb0377ff104e2bbcff85 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 20 Jan 2026 11:12:48 -0500 Subject: [PATCH 13/43] feat(issuer): add configmap spec for ca bundle Signed-off-by: Matthew H. Irby --- api/v1alpha1/issuer_types.go | 10 +- ...d-issuer.keyfactor.com_clusterissuers.yaml | 10 +- .../command-issuer.keyfactor.com_issuers.yaml | 10 +- internal/controller/issuer_controller.go | 29 ++++- internal/controller/issuer_controller_test.go | 111 +++++++++++++++++- 5 files changed, 161 insertions(+), 9 deletions(-) diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index ac0cdc7..013364f 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -101,10 +101,18 @@ type IssuerSpec struct { // The name of the secret containing the CA bundle to use when verifying // Command's server certificate. If specified, the CA bundle will be added to - // the client trust roots for the Command issuer. + // the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + // are specified, caBundleConfigMapName will take precedence. // +optional CaSecretName string `json:"caSecretName,omitempty"` + // The name of the ConfigMap containing the CA bundle to use when verifying + // Command's server certificate. If specified, the CA bundle will be added to + // the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + // are specified, caBundleConfigMapName will take precedence. + // +optional + CaBundleConfigMapName string `json:"caBundleConfigMapName,omitempty"` + // A list of comma separated scopes used when requesting a Bearer token from an ambient token provider implied // by the environment, rather than by commandSecretName. For example, could be set to // api://{tenant ID}/.default when requesting an access token for Entra ID (DefaultAzureCredential). Has no diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index ae0aebf..c88824f 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -52,11 +52,19 @@ spec: the URL of your Command environment.Has no effect on OAuth 2.0 Client Credential configuration - please specify the audience for this method in an Opaque secret. type: string + caBundleConfigMapName: + description: |- + The name of the ConfigMap containing the CA bundle to use when verifying + Command's server certificate. If specified, the CA bundle will be added to + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. + type: string caSecretName: description: |- The name of the secret containing the CA bundle to use when verifying Command's server certificate. If specified, the CA bundle will be added to - the client trust roots for the Command issuer. + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. type: string certificateAuthorityHostname: description: |- diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index 33ab160..4217172 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -52,11 +52,19 @@ spec: the URL of your Command environment.Has no effect on OAuth 2.0 Client Credential configuration - please specify the audience for this method in an Opaque secret. type: string + caBundleConfigMapName: + description: |- + The name of the ConfigMap containing the CA bundle to use when verifying + Command's server certificate. If specified, the CA bundle will be added to + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. + type: string caSecretName: description: |- The name of the secret containing the CA bundle to use when verifying Command's server certificate. If specified, the CA bundle will be added to - the client trust roots for the Command issuer. + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. type: string certificateAuthorityHostname: description: |- diff --git a/internal/controller/issuer_controller.go b/internal/controller/issuer_controller.go index 5c44ec7..a58848b 100644 --- a/internal/controller/issuer_controller.go +++ b/internal/controller/issuer_controller.go @@ -40,6 +40,7 @@ const ( var ( errGetAuthSecret = errors.New("failed to get Secret containing Issuer credentials") + errGetCaConfigMap = errors.New("caBundleConfigMapName specified a name, but failed to get ConfigMap containing CA certificate") errGetCaSecret = errors.New("caSecretName specified a name, but failed to get Secret containing CA certificate") errHealthCheckerBuilder = errors.New("failed to build the healthchecker") errHealthCheckerCheck = errors.New("healthcheck failed") @@ -214,23 +215,43 @@ func commandConfigFromIssuer(ctx context.Context, c client.Client, issuer comman } } - var caSecret corev1.Secret - // If the CA secret name is not specified, we will not attempt to retrieve it - if issuer.GetSpec().CaSecretName != "" { + var caData map[string][]byte + + if issuer.GetSpec().CaBundleConfigMapName != "" { + var configMap corev1.ConfigMap + err := c.Get(ctx, types.NamespacedName{ + Name: issuer.GetSpec().CaBundleConfigMapName, + Namespace: secretNamespace, + }, &configMap) + + if err != nil { + return nil, fmt.Errorf("%w, configmap name: %s, reason: %w", errGetCaConfigMap, issuer.GetSpec().CaBundleConfigMapName, err) + } + + caData = make(map[string][]byte) + for key, value := range configMap.Data { + caData[key] = []byte(value) + } + } else if issuer.GetSpec().CaSecretName != "" { + var caSecret corev1.Secret + err := c.Get(ctx, types.NamespacedName{ Name: issuer.GetSpec().CaSecretName, Namespace: secretNamespace, }, &caSecret) + if err != nil { return nil, fmt.Errorf("%w, secret name: %s, reason: %w", errGetCaSecret, issuer.GetSpec().CaSecretName, err) } + + caData = caSecret.Data } var caCertBytes []byte // There is no requirement that the CA certificate is stored under a specific // key in the secret, so we can just iterate over the map and effectively select // the last value in the map - for _, bytes := range caSecret.Data { + for _, bytes := range caData { caCertBytes = bytes } diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index e270934..b024fc8 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2024 Keyfactor +Copyright © 2026 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -666,7 +666,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, }, { - name: "success-basic-auth-with-ca-cert", + name: "success-basic-auth-with-ca-cert-secret", issuerSpec: commandissuerv1alpha1.IssuerSpec{ Hostname: "https://ca.example.com", APIPath: "/api/v1", @@ -709,6 +709,103 @@ func TestCommandConfigFromIssuer(t *testing.T) { AmbientCredentialAudience: "", }, }, + { + name: "success-basic-auth-with-ca-cert-configmap", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + SecretName: "auth-secret", + CaBundleConfigMapName: "ca-configmap", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ca-configmap", + Namespace: "ns1", + }, + Data: map[string]string{ + "ca.crt": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + }, + }, + }, + expectedConfig: &command.Config{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + CaCertsBytes: []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), + BasicAuth: &command.BasicAuth{ + Username: "username", + Password: "password", + }, + AmbientCredentialScopes: []string{""}, + AmbientCredentialAudience: "", + }, + }, + { + name: "success-basic-auth-with-ca-cert-configmap-overwrites-secret", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + SecretName: "auth-secret", + CaSecretName: "ca-secret", + CaBundleConfigMapName: "ca-configmap", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + &corev1.Secret{ + Type: corev1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "ca-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + "ca.crt": []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), + }, + }, + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ca-configmap", + Namespace: "ns1", + }, + Data: map[string]string{ + "ca.crt": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + }, + }, + }, + expectedConfig: &command.Config{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + CaCertsBytes: []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), + BasicAuth: &command.BasicAuth{ + Username: "username", + Password: "password", + }, + AmbientCredentialScopes: []string{""}, + AmbientCredentialAudience: "", + }, + }, { name: "success-oauth-minimal", issuerSpec: commandissuerv1alpha1.IssuerSpec{ @@ -833,6 +930,16 @@ func TestCommandConfigFromIssuer(t *testing.T) { secrets: []client.Object{}, expectedError: errGetCaSecret, }, + { + name: "error-ca-configmap-not-found", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + CaBundleConfigMapName: "missing-ca-bundle", + }, + secretNamespace: "ns1", + secrets: []client.Object{}, + expectedError: errGetCaConfigMap, + }, { name: "error-basic-auth-no-username", issuerSpec: commandissuerv1alpha1.IssuerSpec{ From 35d1481f2116c30916faaa024187d6e7574a53a2 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 20 Jan 2026 14:43:51 -0500 Subject: [PATCH 14/43] feat(issuer): add caConfigMap and caBundleKey to deployment chart Signed-off-by: Matthew H. Irby --- api/v1alpha1/issuer_types.go | 6 + cmd/main.go | 36 +++-- ...d-issuer.keyfactor.com_clusterissuers.yaml | 6 + .../command-issuer.keyfactor.com_issuers.yaml | 6 + .../templates/crds/clusterissuers.yaml | 16 +- .../templates/crds/issuers.yaml | 16 +- .../templates/deployment.yaml | 3 + .../templates/role.yaml | 16 ++ .../templates/rolebinding.yaml | 15 ++ .../command-cert-manager-issuer/values.yaml | 9 ++ e2e/run_tests.sh | 86 ++++++++++- internal/controller/issuer_controller.go | 18 ++- internal/controller/issuer_controller_test.go | 140 +++++++++++++++++- 13 files changed, 354 insertions(+), 19 deletions(-) diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index 013364f..d70b728 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -113,6 +113,12 @@ type IssuerSpec struct { // +optional CaBundleConfigMapName string `json:"caBundleConfigMapName,omitempty"` + // The key in the Secret or ConfigMap containing the CA certificate bundle. + // Applies to both caSecretName and caBundleConfigMapName. + // If unspecifed, the last key alphabetically in the Secret or ConfigMap data will be used. + // +optional + CaBundleKey string `json:"caBundleKey,omitempty"` + // A list of comma separated scopes used when requesting a Bearer token from an ambient token provider implied // by the environment, rather than by commandSecretName. For example, could be set to // api://{tenant ID}/.default when requesting an access token for Entra ID (DefaultAzureCredential). Has no diff --git a/cmd/main.go b/cmd/main.go index 51ca91e..af14a44 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -69,6 +69,7 @@ func main() { var clusterResourceNamespace string var disableApprovedCheck bool var secretAccessGrantedAtClusterLevel bool + var configMapAccessGrantedAtClusterLevel bool flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -84,6 +85,8 @@ func main() { "Disables waiting for CertificateRequests to have an approved condition before signing.") flag.BoolVar(&secretAccessGrantedAtClusterLevel, "secret-access-granted-at-cluster-level", false, "Set this flag to true if the secret access is granted at cluster level. This will allow the controller to access secrets in any namespace. ") + flag.BoolVar(&configMapAccessGrantedAtClusterLevel, "configmap-access-granted-at-cluster-level", false, + "Set this flag to true if the config map access is granted at cluster level. This will allow the controller to access config maps in any namespace. ") opts := zap.Options{ Development: true, } @@ -126,16 +129,31 @@ func main() { } var cacheOpts cache.Options - if secretAccessGrantedAtClusterLevel { - setupLog.Info("expecting SA to have Get+List+Watch permissions for corev1 Secret resources at cluster level") - } else { - setupLog.Info(fmt.Sprintf("expecting SA to have Get+List+Watch permissions for corev1 Secret resources in the %q namespace", clusterResourceNamespace)) + + // Build the ByObject map if either resource is namespace-scoped + if !secretAccessGrantedAtClusterLevel || !configMapAccessGrantedAtClusterLevel { + byObject := make(map[client.Object]cache.ByObject) + + if !secretAccessGrantedAtClusterLevel { + setupLog.Info(fmt.Sprintf("expecting SA to have Get+List+Watch permissions for corev1 Secret resources in the %q namespace", clusterResourceNamespace)) + byObject[&corev1.Secret{}] = cache.ByObject{ + Namespaces: map[string]cache.Config{clusterResourceNamespace: {}}, + } + } else { + setupLog.Info("expecting SA to have Get+List+Watch permissions for corev1 Secret resources at cluster level") + } + + if !configMapAccessGrantedAtClusterLevel { + setupLog.Info(fmt.Sprintf("expecting SA to have Get+List+Watch permissions for corev1 ConfigMap resources in the %q namespace", clusterResourceNamespace)) + byObject[&corev1.ConfigMap{}] = cache.ByObject{ + Namespaces: map[string]cache.Config{clusterResourceNamespace: {}}, + } + } else { + setupLog.Info("expecting SA to have Get+List+Watch permissions for corev1 ConfigMap resources at cluster level") + } + cacheOpts = cache.Options{ - ByObject: map[client.Object]cache.ByObject{ - &corev1.Secret{}: { - Namespaces: map[string]cache.Config{clusterResourceNamespace: cache.Config{}}, - }, - }, + ByObject: byObject, } } diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index c88824f..963320f 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -59,6 +59,12 @@ spec: the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName are specified, caBundleConfigMapName will take precedence. type: string + caBundleKey: + description: |- + The key in the Secret or ConfigMap containing the CA certificate bundle. + Applies to both caSecretName and caBundleConfigMapName. + If unspecifed, the last key alphabetically in the Secret or ConfigMap data will be used. + type: string caSecretName: description: |- The name of the secret containing the CA bundle to use when verifying diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index 4217172..6a1e923 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -59,6 +59,12 @@ spec: the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName are specified, caBundleConfigMapName will take precedence. type: string + caBundleKey: + description: |- + The key in the Secret or ConfigMap containing the CA certificate bundle. + Applies to both caSecretName and caBundleConfigMapName. + If unspecifed, the last key alphabetically in the Secret or ConfigMap data will be used. + type: string caSecretName: description: |- The name of the secret containing the CA bundle to use when verifying diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml index ec8b4a9..e82b09c 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml @@ -46,11 +46,25 @@ spec: description: APIPath is the base path of the Command API. KeyfactorAPI by default type: string + caBundleConfigMapName: + description: |- + The name of the ConfigMap containing the CA bundle to use when verifying + Command's server certificate. If specified, the CA bundle will be added to + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. + type: string + caBundleKey: + description: |- + The key in the Secret or ConfigMap containing the CA certificate bundle. + Applies to both caSecretName and caBundleConfigMapName. + If unspecifed, the last key alphabetically in the Secret or ConfigMap data will be used. + type: string caSecretName: description: |- The name of the secret containing the CA bundle to use when verifying Command's server certificate. If specified, the CA bundle will be added to - the client trust roots for the Command issuer. + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. type: string certificateAuthorityHostname: description: |- diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml index ea377ca..b88f04e 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml @@ -46,11 +46,25 @@ spec: description: APIPath is the base path of the Command API. KeyfactorAPI by default type: string + caBundleConfigMapName: + description: |- + The name of the ConfigMap containing the CA bundle to use when verifying + Command's server certificate. If specified, the CA bundle will be added to + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. + type: string + caBundleKey: + description: |- + The key in the Secret or ConfigMap containing the CA certificate bundle. + Applies to both caSecretName and caBundleConfigMapName. + If unspecifed, the last key alphabetically in the Secret or ConfigMap data will be used. + type: string caSecretName: description: |- The name of the secret containing the CA bundle to use when verifying Command's server certificate. If specified, the CA bundle will be added to - the client trust roots for the Command issuer. + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. type: string certificateAuthorityHostname: description: |- diff --git a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml index 856ace0..bdbe04f 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml @@ -36,6 +36,9 @@ spec: {{- if .Values.secretConfig.useClusterRoleForSecretAccess}} - --secret-access-granted-at-cluster-level {{- end}} + {{- if .Values.secretConfig.useClusterRoleForConfigMapAccess}} + - --configmap-access-granted-at-cluster-level + {{- end}} command: - /manager image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.Version }}" diff --git a/deploy/charts/command-cert-manager-issuer/templates/role.yaml b/deploy/charts/command-cert-manager-issuer/templates/role.yaml index 9c8617d..eff6781 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/role.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/role.yaml @@ -40,3 +40,19 @@ rules: - get - list - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: {{ if .Values.secretConfig.useClusterRoleForConfigMapAccess }}ClusterRole{{ else }}Role{{ end }} +metadata: + labels: + {{- include "command-cert-manager-issuer.labels" . | nindent 4 }} + name: {{ include "command-cert-manager-issuer.name" . }}-configmap-reader-role +rules: + - apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch diff --git a/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml b/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml index 631df66..1125fd9 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml @@ -27,3 +27,18 @@ subjects: - kind: ServiceAccount name: {{ include "command-cert-manager-issuer.serviceAccountName" . }} namespace: {{ .Release.Namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: {{ if .Values.secretConfig.useClusterRoleForConfigMapAccess }}ClusterRoleBinding{{ else }}RoleBinding{{ end }} +metadata: + labels: + {{- include "command-cert-manager-issuer.labels" . | nindent 4 }} + name: {{ include "command-cert-manager-issuer.name" . }}-configmap-reader-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: {{ if .Values.secretConfig.useClusterRoleForConfigMapAccess }}ClusterRole{{ else }}Role{{ end }} + name: {{ include "command-cert-manager-issuer.name" . }}-configmap-reader-role +subjects: + - kind: ServiceAccount + name: {{ include "command-cert-manager-issuer.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} diff --git a/deploy/charts/command-cert-manager-issuer/values.yaml b/deploy/charts/command-cert-manager-issuer/values.yaml index 6fd5bcb..ccb6d96 100644 --- a/deploy/charts/command-cert-manager-issuer/values.yaml +++ b/deploy/charts/command-cert-manager-issuer/values.yaml @@ -23,6 +23,15 @@ secretConfig: # namespace the chart is deployed in. useClusterRoleForSecretAccess: false + # If true, when using Issuer resources, the configmap resource must be created in the same namespace as the + # Issuer resource. This access is facilitated by granting the ServiceAccount [get, list, watch] for the config map + # API at the cluster level. + # + # If false, both Issuer and ClusterIssuer must reference a config map in the same namespace as the chart/reconciler. + # This access is facilitated by granting the ServiceAccount [get, list, watch] for the config map API only for the + # namespace the chart is deployed in. + useClusterRoleForConfigMapAccess: false + crd: # Specifies whether CRDs will be created create: true diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index 558c128..d6b911d 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -80,6 +80,7 @@ CERTIFICATEREQUEST_CRD_FQTN="certificaterequests.cert-manager.io" CA_CERTS_PATH="e2e/certs" CA_SECRET_NAME="ca-trust-secret" +CA_CONFIG_MAP_NAME="ca-trust-configmap" CR_CR_NAME="req" @@ -528,6 +529,33 @@ regenerate_ca_secret() { echo "✅ CA secret regenerated successfully" } +create_ca_config_map() { + echo "🔐 Creating CA config map resource..." + + check_for_certificates + + kubectl -n ${MANAGER_NAMESPACE} create configmap $CA_CONFIG_MAP_NAME --from-file=$CA_CERTS_PATH + + echo "✅ CA config map '$CA_CONFIG_MAP_NAME' created successfully" +} + +delete_ca_config_map() { + echo "🗑️ Deleting CA config map..." + + kubectl -n ${MANAGER_NAMESPACE} delete configmap $CA_CONFIG_MAP_NAME || true + + echo "✅ CA config map '$CA_CONFIG_MAP_NAME' deleted successfully" +} + +regenerate_ca_config_map() { + echo "🔄 Regenerating CA config map..." + + delete_ca_config_map + create_ca_config_map + + echo "✅ CA config map regenerated successfully" +} + # ================= BEGIN: Resource Deployment ===================== @@ -812,8 +840,64 @@ check_certificate_request_status echo "🧪✅ Test 104 completed successfully." echo "" +## =================== END: Annotation Tests ============================ + +## =================== BEGIN: CA Secret / ConfigMap Tests ============================ + +if [[ "$DISABLE_CA_CHECK" == "true" ]]; then + echo "⚠️ Skipping CA Secret / ConfigMap Tests as DISABLE_CA_CHECK is set to true" +else + echo "🧪💬 Test 200: Use Secret for CA Bundle" + regenerate_issuer + delete_issuer_specification_field caSecretName Issuer + regenerate_ca_secret + add_issuer_specification_field caSecretName "\"$CA_SECRET_NAME\"" Issuer + regenerate_certificate_request Issuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 200 completed successfully." + echo "" + + echo "🧪💬 Test 201: Use ConfigMap for CA Bundle" + regenerate_issuer + delete_issuer_specification_field caSecretName Issuer + regenerate_ca_config_map + add_issuer_specification_field caBundleConfigMapName "\"$CA_CONFIG_MAP_NAME\"" Issuer + regenerate_certificate_request Issuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 201 completed successfully." + echo "" + + echo "🧪💬 Test 202: Use Secret with CA Key" + regenerate_issuer + delete_issuer_specification_field caSecretName Issuer + regenerate_ca_secret + add_issuer_specification_field caSecretName "\"$CA_SECRET_NAME\"" Issuer + add_issuer_specification_field caBundleKey "\"ca.crt\"" Issuer + regenerate_certificate_request Issuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 202 completed successfully." + echo "" + + echo "🧪💬 Test 203: Use ConfigMap with CA Key" + regenerate_issuer + delete_issuer_specification_field caSecretName Issuer + regenerate_ca_config_map + add_issuer_specification_field caBundleConfigMapName "\"$CA_CONFIG_MAP_NAME\"" Issuer + add_issuer_specification_field caBundleKey "\"ca.crt\"" Issuer + regenerate_certificate_request Issuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 203 completed successfully." + echo "" +fi + + + echo "🎉🎉🎉 Tests have completed successfully!" -## =================== END: Annotation Tests ============================ +## =================== END: CA Secret / ConfigMap Tests ============================ # ================= END: Test Execution ======================== \ No newline at end of file diff --git a/internal/controller/issuer_controller.go b/internal/controller/issuer_controller.go index a58848b..d56035b 100644 --- a/internal/controller/issuer_controller.go +++ b/internal/controller/issuer_controller.go @@ -42,6 +42,7 @@ var ( errGetAuthSecret = errors.New("failed to get Secret containing Issuer credentials") errGetCaConfigMap = errors.New("caBundleConfigMapName specified a name, but failed to get ConfigMap containing CA certificate") errGetCaSecret = errors.New("caSecretName specified a name, but failed to get Secret containing CA certificate") + errGetCaBundleKey = errors.New("failed to get CA bundle key from CA certificate data") errHealthCheckerBuilder = errors.New("failed to build the healthchecker") errHealthCheckerCheck = errors.New("healthcheck failed") ) @@ -248,11 +249,18 @@ func commandConfigFromIssuer(ctx context.Context, c client.Client, issuer comman } var caCertBytes []byte - // There is no requirement that the CA certificate is stored under a specific - // key in the secret, so we can just iterate over the map and effectively select - // the last value in the map - for _, bytes := range caData { - caCertBytes = bytes + + if issuer.GetSpec().CaBundleKey != "" { + caCert, ok := caData[issuer.GetSpec().CaBundleKey] + if !ok { + return nil, fmt.Errorf("%w: caBundleKey '%s' not found in CA bundle data", errGetCaBundleKey, issuer.GetSpec().CaBundleKey) + } + caCertBytes = caCert + } else { + // If no caBundleKey is specified, take the last entry in the caData map + for _, bytes := range caData { + caCertBytes = bytes + } } return &command.Config{ diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index b024fc8..e19d900 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_test.go @@ -693,7 +693,54 @@ func TestCommandConfigFromIssuer(t *testing.T) { Namespace: "ns1", }, Data: map[string][]byte{ - "ca.crt": []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), + "ca.crt": []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), + }, + }, + }, + expectedConfig: &command.Config{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + CaCertsBytes: []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), + BasicAuth: &command.BasicAuth{ + Username: "username", + Password: "password", + }, + AmbientCredentialScopes: []string{""}, + AmbientCredentialAudience: "", + }, + }, + { + name: "success-basic-auth-with-ca-cert-secret-with-key", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + SecretName: "auth-secret", + CaSecretName: "ca-secret", + CaBundleKey: "ca.crt", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + &corev1.Secret{ + Type: corev1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "ca-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), + "ca.crt": []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), }, }, }, @@ -736,7 +783,53 @@ func TestCommandConfigFromIssuer(t *testing.T) { Namespace: "ns1", }, Data: map[string]string{ - "ca.crt": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + "ca.crt": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + "tls.crt": "-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----", + }, + }, + }, + expectedConfig: &command.Config{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + CaCertsBytes: []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), + BasicAuth: &command.BasicAuth{ + Username: "username", + Password: "password", + }, + AmbientCredentialScopes: []string{""}, + AmbientCredentialAudience: "", + }, + }, + { + name: "success-basic-auth-with-ca-cert-configmap-with-key", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + APIPath: "/api/v1", + SecretName: "auth-secret", + CaBundleConfigMapName: "ca-configmap", + CaBundleKey: "ca.crt", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ca-configmap", + Namespace: "ns1", + }, + Data: map[string]string{ + "ca.crt": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + "tls.crt": "-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----", }, }, }, @@ -930,6 +1023,28 @@ func TestCommandConfigFromIssuer(t *testing.T) { secrets: []client.Object{}, expectedError: errGetCaSecret, }, + { + name: "error-ca-secret-key-not-found", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + CaSecretName: "ca-secret", + CaBundleKey: "ca.crt", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.Secret{ + Type: corev1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "ca-secret", + Namespace: "ns1", + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), + }, + }, + }, + expectedError: errGetCaBundleKey, + }, { name: "error-ca-configmap-not-found", issuerSpec: commandissuerv1alpha1.IssuerSpec{ @@ -940,6 +1055,27 @@ func TestCommandConfigFromIssuer(t *testing.T) { secrets: []client.Object{}, expectedError: errGetCaConfigMap, }, + { + name: "error-ca-configmap-key-not-found", + issuerSpec: commandissuerv1alpha1.IssuerSpec{ + Hostname: "https://ca.example.com", + CaBundleConfigMapName: "ca-configmap", + CaBundleKey: "ca.crt", + }, + secretNamespace: "ns1", + secrets: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ca-configmap", + Namespace: "ns1", + }, + Data: map[string]string{ + "tls.crt": "-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----", + }, + }, + }, + expectedError: errGetCaBundleKey, + }, { name: "error-basic-auth-no-username", issuerSpec: commandissuerv1alpha1.IssuerSpec{ From 088cc344f45363e8e7405cb8af8ae0553341f9fb Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 21 Jan 2026 10:41:14 -0500 Subject: [PATCH 15/43] feat(e2e): add test cases for ClusterIsusers handling CA certs. Fix issue with cert handling Signed-off-by: Matthew H. Irby --- e2e/run_tests.sh | 130 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 22 deletions(-) diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index d6b911d..22ec2c4 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -74,13 +74,12 @@ CERT_MANAGER_NAMESPACE="cert-manager" ISSUER_NAMESPACE="issuer-playground" SIGNER_SECRET_NAME="auth-secret" -SIGNER_CA_SECRET_NAME="ca-secret" CERTIFICATEREQUEST_CRD_FQTN="certificaterequests.cert-manager.io" CA_CERTS_PATH="e2e/certs" -CA_SECRET_NAME="ca-trust-secret" -CA_CONFIG_MAP_NAME="ca-trust-configmap" +SIGNER_CA_SECRET_NAME="ca-trust-secret" +SIGNER_CA_CONFIGMAP_NAME="ca-trust-configmap" CR_CR_NAME="req" @@ -238,7 +237,10 @@ create_issuer() { return 1 fi - caSecretNameSpec="caSecretName: $CA_SECRET_NAME" + regenerate_ca_secret + regenerate_ca_config_map + + caSecretNameSpec="caSecretName: $SIGNER_CA_SECRET_NAME" if [[ "$DISABLE_CA_CHECK" == "true" ]]; then echo "⚠️ Disabling CA check as per DISABLE_CA_CHECK environment variable" caSecretNameSpec="" @@ -285,7 +287,10 @@ create_cluster_issuer() { return 1 fi - caSecretNameSpec="caSecretName: $CA_SECRET_NAME" + regenerate_ca_secret + regenerate_ca_config_map + + caSecretNameSpec="caSecretName: $SIGNER_CA_SECRET_NAME" if [[ "$DISABLE_CA_CHECK" == "true" ]]; then echo "⚠️ Disabling CA check as per DISABLE_CA_CHECK environment variable" caSecretNameSpec="" @@ -406,7 +411,7 @@ approve_certificate_request() { check_certificate_request_status() { echo "🔎 Checking certificate request status..." - if [[ ! $(kubectl wait --for=condition=Ready certificaterequest/$CR_CR_NAME -n $ISSUER_NAMESPACE --timeout=90s) ]]; then + if [[ ! $(kubectl wait --for=condition=Ready certificaterequest/$CR_CR_NAME -n $ISSUER_NAMESPACE --timeout=70s) ]]; then echo "⚠️ Certificate request did not become ready within the timeout period." echo "Check the Issuer / Command Issuer logs for errors. Check the configuration of your Issuer or CertificateRequest resources." echo "🚫 Test failed" @@ -507,17 +512,20 @@ create_ca_secret () { check_for_certificates - kubectl -n ${MANAGER_NAMESPACE} create secret generic $CA_SECRET_NAME --from-file=$CA_CERTS_PATH + kubectl -n ${MANAGER_NAMESPACE} create secret generic $SIGNER_CA_SECRET_NAME --from-literal=ca.crt="$( + find e2e/certs -type f ! -name '.gitignore' -exec cat {} \; + )" \ + --dry-run=client -o yaml | kubectl apply -f - - echo "✅ CA secret '$CA_SECRET_NAME' created successfully" + echo "✅ CA secret '$SIGNER_CA_SECRET_NAME' created successfully" } delete_ca_secret() { echo "🗑️ Deleting CA secret..." - kubectl -n ${MANAGER_NAMESPACE} delete secret $CA_SECRET_NAME || true + kubectl -n ${MANAGER_NAMESPACE} delete secret $SIGNER_CA_SECRET_NAME || true - echo "✅ CA secret '$CA_SECRET_NAME' deleted successfully" + echo "✅ CA secret '$SIGNER_CA_SECRET_NAME' deleted successfully" } regenerate_ca_secret() { @@ -529,22 +537,41 @@ regenerate_ca_secret() { echo "✅ CA secret regenerated successfully" } +add_bad_cert_to_ca_secret() { + echo "🔐 Adding bad certificate to CA secret..." + + kubectl -n ${MANAGER_NAMESPACE} patch secret $SIGNER_CA_SECRET_NAME\ + --type='json' \ + -p='[ + { + "op": "add", + "path": "/data/zzz.crt", + "value": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClRISVNfSVNfTk9UX0FfUkVBTF9DRVJUCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + } + ]' + + echo "✅ Bad certificate added to CA secret successfully." +} + create_ca_config_map() { echo "🔐 Creating CA config map resource..." check_for_certificates - kubectl -n ${MANAGER_NAMESPACE} create configmap $CA_CONFIG_MAP_NAME --from-file=$CA_CERTS_PATH + kubectl -n ${MANAGER_NAMESPACE} create configmap $SIGNER_CA_CONFIGMAP_NAME --from-literal=ca.crt="$( + find e2e/certs -type f ! -name '.gitignore' -exec cat {} \; + )" \ + --dry-run=client -o yaml | kubectl apply -f - - echo "✅ CA config map '$CA_CONFIG_MAP_NAME' created successfully" + echo "✅ CA config map '$SIGNER_CA_CONFIGMAP_NAME' created successfully" } delete_ca_config_map() { echo "🗑️ Deleting CA config map..." - kubectl -n ${MANAGER_NAMESPACE} delete configmap $CA_CONFIG_MAP_NAME || true + kubectl -n ${MANAGER_NAMESPACE} delete configmap $SIGNER_CA_CONFIGMAP_NAME || true - echo "✅ CA config map '$CA_CONFIG_MAP_NAME' deleted successfully" + echo "✅ CA config map '$SIGNER_CA_CONFIGMAP_NAME' deleted successfully" } regenerate_ca_config_map() { @@ -556,6 +583,22 @@ regenerate_ca_config_map() { echo "✅ CA config map regenerated successfully" } +add_bad_cert_to_ca_config_map() { + echo "🔐 Adding bad certificate to CA config map..." + + kubectl -n ${MANAGER_NAMESPACE} patch configmap $SIGNER_CA_CONFIGMAP_NAME\ + --type='json' \ + -p='[ + { + "op": "add", + "path": "/data/zzz.crt", + "value": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClRISVNfSVNfTk9UX0FfUkVBTF9DRVJUCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + } + ]' + + echo "✅ Bad certificate added to CA config map successfully." +} + # ================= BEGIN: Resource Deployment ===================== @@ -654,6 +697,7 @@ echo "" echo """🔐 Creating CA secret used for testing..." regenerate_ca_secret +regenerate_ca_config_map echo "" # Deploy Issuer @@ -850,30 +894,48 @@ else echo "🧪💬 Test 200: Use Secret for CA Bundle" regenerate_issuer delete_issuer_specification_field caSecretName Issuer - regenerate_ca_secret - add_issuer_specification_field caSecretName "\"$CA_SECRET_NAME\"" Issuer + add_issuer_specification_field caSecretName "\"$SIGNER_CA_SECRET_NAME\"" Issuer regenerate_certificate_request Issuer approve_certificate_request check_certificate_request_status echo "🧪✅ Test 200 completed successfully." echo "" + echo "🧪💬 Test 200a: Use Secret for CA Bundle ClusterIssuer" + regenerate_cluster_issuer + delete_issuer_specification_field caSecretName ClusterIssuer + add_issuer_specification_field caSecretName "\"$SIGNER_CA_SECRET_NAME\"" ClusterIssuer + regenerate_certificate_request ClusterIssuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 200a completed successfully." + echo "" + echo "🧪💬 Test 201: Use ConfigMap for CA Bundle" regenerate_issuer delete_issuer_specification_field caSecretName Issuer - regenerate_ca_config_map - add_issuer_specification_field caBundleConfigMapName "\"$CA_CONFIG_MAP_NAME\"" Issuer + add_issuer_specification_field caBundleConfigMapName "\"$SIGNER_CA_CONFIGMAP_NAME\"" Issuer regenerate_certificate_request Issuer approve_certificate_request check_certificate_request_status echo "🧪✅ Test 201 completed successfully." echo "" + echo "🧪💬 Test 201a: Use ConfigMap for CA Bundle ClusterIssuer" + regenerate_cluster_issuer + delete_issuer_specification_field caSecretName ClusterIssuer + add_issuer_specification_field caBundleConfigMapName "\"$SIGNER_CA_CONFIGMAP_NAME\"" ClusterIssuer + regenerate_certificate_request ClusterIssuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 201a completed successfully." + echo "" + echo "🧪💬 Test 202: Use Secret with CA Key" regenerate_issuer delete_issuer_specification_field caSecretName Issuer - regenerate_ca_secret - add_issuer_specification_field caSecretName "\"$CA_SECRET_NAME\"" Issuer + add_bad_cert_to_ca_secret + add_issuer_specification_field caSecretName "\"$SIGNER_CA_SECRET_NAME\"" Issuer add_issuer_specification_field caBundleKey "\"ca.crt\"" Issuer regenerate_certificate_request Issuer approve_certificate_request @@ -881,17 +943,41 @@ else echo "🧪✅ Test 202 completed successfully." echo "" + echo "🧪💬 Test 202a: Use Secret with CA Key ClusterIssuer" + regenerate_cluster_issuer + delete_issuer_specification_field caSecretName ClusterIssuer + add_bad_cert_to_ca_secret + add_issuer_specification_field caSecretName "\"$SIGNER_CA_SECRET_NAME\"" ClusterIssuer + add_issuer_specification_field caBundleKey "\"ca.crt\"" ClusterIssuer + regenerate_certificate_request ClusterIssuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 202a completed successfully." + echo "" + echo "🧪💬 Test 203: Use ConfigMap with CA Key" regenerate_issuer delete_issuer_specification_field caSecretName Issuer - regenerate_ca_config_map - add_issuer_specification_field caBundleConfigMapName "\"$CA_CONFIG_MAP_NAME\"" Issuer + add_bad_cert_to_ca_config_map + add_issuer_specification_field caBundleConfigMapName "\"$SIGNER_CA_CONFIGMAP_NAME\"" Issuer add_issuer_specification_field caBundleKey "\"ca.crt\"" Issuer regenerate_certificate_request Issuer approve_certificate_request check_certificate_request_status echo "🧪✅ Test 203 completed successfully." echo "" + + echo "🧪💬 Test 203a: Use ConfigMap with CA Key ClusterIssuer" + regenerate_cluster_issuer + delete_issuer_specification_field caSecretName ClusterIssuer + add_bad_cert_to_ca_config_map + add_issuer_specification_field caBundleConfigMapName "\"$SIGNER_CA_CONFIGMAP_NAME\"" ClusterIssuer + add_issuer_specification_field caBundleKey "\"ca.crt\"" ClusterIssuer + regenerate_certificate_request ClusterIssuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 203a completed successfully." + echo "" fi From 837328cf12559905b0ce7e7e830ca31d13a73d84 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 21 Jan 2026 11:58:36 -0500 Subject: [PATCH 16/43] chore(docs): add updated documentation for configmaps in trust-manager docs and root Signed-off-by: Matthew H. Irby --- Makefile | 2 +- docs/ca-bundle/README.md | 167 ++++++++++++++++++++++++++++++++++----- docsource/content.md | 10 ++- 3 files changed, 155 insertions(+), 24 deletions(-) diff --git a/Makefile b/Makefile index f49281c..f30d196 100644 --- a/Makefile +++ b/Makefile @@ -67,7 +67,7 @@ test: manifests generate fmt vet envtest ## Run tests. # 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: - source e2e/.env && ./e2e/run_tests.sh + cd e2e && source .env && ./run_tests.sh .PHONY: lint lint: golangci-lint ## Run golangci-lint linter & yamllint diff --git a/docs/ca-bundle/README.md b/docs/ca-bundle/README.md index b78eecb..4ddcca5 100644 --- a/docs/ca-bundle/README.md +++ b/docs/ca-bundle/README.md @@ -4,13 +4,15 @@ The command-cert-manager-issuer integration requires a secure, trusted connectio ## Using Self-Signed Certificates -If the targeted Keyfactor Command API is configured to use a self-signed certificate or with a certificate whose issuer isn't widely trusted, the CA certificate **must be provided** as a Kubernetes secret. The secret must belong to the same namespace that command-cert-manager-issuer is deployed to (i.e. `command-issuer-system`). +If the targeted Keyfactor Command API is configured to use a self-signed certificate or with a certificate whose issuer isn't widely trusted, the CA certificate **must be provided** via a Kubernetes Secret of ConfigMap. The secret must belong to the same namespace that command-cert-manager-issuer is deployed to (i.e. `command-issuer-system`). ```shell kubectl -n command-issuer-system create secret generic command-ca-secret --from-file=ca.crt + +kubectl -n command-issuer-system create configmap command-ca --from-file=ca.crt ``` -In the issuer specification, reference the created secret with the `caSecretName` field. The issuer resource does not need to belong to the same namespace as the secret. +In the Issuer / ClusterIssuer specification, reference the created resource. ```yaml apiVersion: command-issuer.keyfactor.com/v1alpha1 @@ -20,22 +22,28 @@ In the issuer specification, reference the created secret with the `caSecretName namespace: default spec: ... - caSecretName: "command-ca-secret" + caSecretName: "command-ca-secret" # if using Kubernetes Secret + caBundleConfigMapName: "command-ca" # if using Kubernetes ConfigMap + caBundleKey: "ca.crt" # optional key name, pulls the last key in resource if not specified ``` ## Using Publicly Trusted Certificates -If the targeted Keyfactor Command API is configured with a publicly trusted certificate authority (Sectigo / LetsEncrypt / etc.), the command-cert-manager-issuer image is built with a pre-bundled trust store of publicly trusted certificates but with a ***very important caveat***. The trust store may become out-of-sync over time, especially if the certificate authority issuing the Keyfactor Command certificate is updated. +If the targeted Keyfactor Command API is configured with a publicly trusted certificate authority (Sectigo / LetsEncrypt / etc.), the command-cert-manager-issuer container image is built with a pre-bundled trust store of publicly trusted certificates but with a ***very important caveat***. The trust store may become out-of-sync over time, especially if the certificate authority issuing the Keyfactor Command certificate is updated. -It is not required to use the `caSecretName` specification, but it is **recommended** to maintain a list of trusted certificates instead of relying on the pre-bundled certificate store when the command-cert-manager-issuer image is created. This will reduce the likelihood of connectivity issues if the Keyfactor Command instance is updated to use a new CA or if the command-cert-manager-issuer image is updated and it does not include the Command CA in its trust store. +It is **not required** to use the `caSecretName` / `caBundleConfigMapName` specification if Keyfactor Command's TLS certificate is built using a publicly trusted root, but it is **recommended for production workloads to maintain a list of trusted certificates** instead of relying on the pre-bundled certificate store when the command-cert-manager-issuer image is created. This will reduce the likelihood of connectivity issues if the Keyfactor Command instance is updated to use a new CA or if the command-cert-manager-issuer image is updated and it does not include the Keyfactor Command TLS certificate's root CA in its trust store. This document covers available tools to help manage CA trust bundles. ### trust-manager -[trust-manager](https://cert-manager.io/docs/trust/trust-manager/) can be used to sync CA trust bundles in a Kubernetes cluster. trust-manager can synchronize a list of publicly trusted CAs as well as any custom CAs to be included in the trust chain. +[trust-manager](https://cert-manager.io/docs/trust/trust-manager/) can be used to sync CA trust bundles in a Kubernetes cluster. trust-manager can synchronize a list of publicly trusted CAs as well as any custom CAs to be included in the trust chain. It is recommended to add your Keyfactor Command's intermediate and root CAs to a Kubernetes Secret / ConfigMap and synchronize this with the trust-manager bundle. + +The publicly trusted certificates are tied to the trust-manager image. To pull up-to-date publicly trusted CAs, update the trust-manager deployment to the latest version. + +trust-manager can synchronize the CA trust bundle to either a Kubernetes Secret or ConfigMap, this documentation will cover both methods. -TODO: trust-manager also supports configMap targets, which are more secure to write to with better RBAC policy. +> NOTE: For the latest documentation and installation instructions, please refer to the [cert-manager trust-manager documentation](https://cert-manager.io/docs/trust/trust-manager/installation/). The instructions below may become outdated over time. #### Pre-requisites @@ -44,18 +52,33 @@ TODO: trust-manager also supports configMap targets, which are more secure to wr #### Security Considerations -> ⚠️ Important: Required Permissions +> ⚠️ Important: Required Permissions. Please Read! + +trust-manager requires different permission scopes depending on your synchronization target: + +**Synchronizing to ConfigMaps (Recommended):** +- ✅ Only requires cluster-wide **read** access to ConfigMaps +- ✅ Lower security risk +- ✅ Suitable for most environments -trust-manager requires **cluster-wide read access** to secrets. This is a hard technical requirement of trust-manager's architecture. +**Synchronizing to Secrets:** +- ⚠️ Requires cluster-wide **read** access to **all Secrets** +- ⚠️ Higher security risk - trust-manager can read any secret in the cluster +- ⚠️ Requires explicit RBAC configuration (shown below) +- ⚠️ Only use if you have specific requirements for Secret storage -| Permission Type | Scope | Resources | Verbs | Purpose | -| --------------- | ------------------ | ------------------- | ----------------------------- | --------------------------------- | -| **Read** | Cluster-wide | secrets, configmaps | get, list, watch | Discover sources, monitor targets | -| **Write** | Namespace-specific | secrets | create, update, patch, delete | Deploy CA bundles | +**Permission Summary:** -#### Setting up trust-manager +| Target Type | Read Scope | Write Scope | Security Impact | +|-------------|----------------|--------------------|-----------------| +| ConfigMap | ConfigMaps | Namespace-specific | Low | +| Secret | **All Secrets**| Namespace-specific | High | -> NOTE: The below instructions are subject to become outdated over time. Please always refer to the [cert-manager](https://cert-manager.io/docs/trust/trust-manager/installation/) documentation for updated installation instructions. +For most deployments, **Option 1 (ConfigMap)** is recommended unless you have compliance requirements mandating Secret storage. + +#### Option 1: Synchronizing to a ConfigMap + +##### Setting up trust-manager 1. Install trust-manager @@ -63,7 +86,98 @@ trust-manager requires **cluster-wide read access** to secrets. This is a hard t # Install trust-manager in the cert-manager namespace helm install trust-manager oci://quay.io/jetstack/charts/trust-manager \ --namespace cert-manager \ - --set secretTargets.enabled=true \ # required trust-manager to write to Kubernetes secrets + --create-namespace \ + --wait + ``` +2. Create a ConfigMap from a PEM file + + Create a ConfigMap containing the PEM of the CA certificates you want to trust. Create the ConfigMap in the same namespace trust-manager is deployed to. + + ```bash + kubectl create configmap enterprise-root-ca \ + --from-file=ca.crt=/path/to/root-ca.pem \ + --namespace=cert-manager \ + --dry-run=client -o yaml | kubectl apply -f - + ``` + +3. Label target namespaces + + Label the namespace command-cert-manager-issuer is deployed to annotate trust-manager should write ConfigMaps to it + + ```bash + kubectl label namespace command-issuer-system command-issuer-ca-bundle=enabled # change to your namespace + ``` + +4. Create a Bundle + + Create a bundle resource to tell trust-manager what ConfigMaps to synchronize and whether to include publicly trusted CAs as part of the sync. + + ```yaml + kubectl apply -f - < Date: Wed, 21 Jan 2026 12:01:16 -0500 Subject: [PATCH 17/43] chore(ci): revert bootstrap workflow upgrade Signed-off-by: Matthew H. Irby --- .github/workflows/keyfactor-bootstrap-workflow.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml index 21fb50b..f72b649 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -46,12 +46,13 @@ jobs: run: go test -v ./... call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@v4 + uses: keyfactor/actions/.github/workflows/starter.yml@3.2.0 needs: test secrets: token: ${{ secrets.V2BUILDTOKEN}} gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} + APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}} scan_token: ${{ secrets.SAST_TOKEN }} docker-user: ${{ secrets.DOCKER_USER }} docker-token: ${{ secrets.DOCKER_PWD }} From 328de0368a118dc1f3f871481e86e9de91c94883 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Wed, 21 Jan 2026 17:04:15 +0000 Subject: [PATCH 18/43] Update generated docs --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fb77a4d..ccf4a2d 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,8 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | apiPath | (optional) The base path of the Command REST API. Defaults to `KeyfactorAPI`. | | commandSecretName | (optional) The name of the Kubernetes secret containing basic auth credentials or OAuth 2.0 credentials. Omit if using ambient credentials. | | caSecretName | (optional) The name of the Kubernetes secret containing the CA certificate trust chain. See the [CA Bundle docs](./docs/ca-bundle/README.md) for more information. | + | caBundleConfigMapName | (optional) The name of the Kubernetes ConfigMap containing the CA certificate trust chain. See the [CA Bundle docs](./docs/ca-bundle/README.md) for more information. | + | caBundleKey | (optional) The name of the key in the ConfigMap or Secret specified by `caSecretName` or `caBundleConfigMapName` that contains the CA bundle. If omitted, the last key of the ConfigMap / Secret resource will be used. | | certificateAuthorityLogicalName | The logical name of the Certificate Authority to use in Command. For example, `Sub-CA` | | certificateAuthorityHostname | (optional) The hostname of the Certificate Authority specified by `certificateAuthorityLogicalName`. This field is usually only required if the CA in Command is a DCOM (MSCA-like) CA. | | enrollmentPatternId | The ID of the [Enrollment Pattern](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Enrollment-Patterns.htm) to use when this Issuer/ClusterIssuer enrolls CSRs. **Supported by Keyfactor Command 25.1 and above**. If `certificateTemplate` and `enrollmentPatternId` are both specified, the enrollment pattern parameter will take precedence. If `enrollmentPatternId` and `enrollmentPatternName` are both specified, `enrollmentPatternId` will take precedence. Enrollment will fail if the specified certificate template is not compatible with the enrollment pattern. | @@ -283,7 +285,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou hostname: "$HOSTNAME" apiPath: "/KeyfactorAPI" # Preceding & trailing slashes are handled automatically commandSecretName: "command-secret" # references the secret created above. Omit if using ambient credentials. - caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) + # caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) + # caBundleConfigMapName: "command-ca-configmap" # references a configmap containing the CA trust chain (see CA Bundle docs for more info) + # caBundleKey: "ca.crt" # references the key in the secret/configmap containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" @@ -313,7 +317,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou hostname: "$HOSTNAME" apiPath: "/KeyfactorAPI" # Preceding & trailing slashes are handled automatically commandSecretName: "command-secret" # references the secret created above. Omit if using ambient credentials. - caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) + # caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) + # caBundleConfigMapName: "command-ca-configmap" # references a configmap containing the CA trust chain (see CA Bundle docs for more info) + # caBundleKey: "ca.crt" # references the key in the secret/configmap containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" From 543990616a584c0f326349eff80634d5da6ad73f Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 21 Jan 2026 12:54:29 -0500 Subject: [PATCH 19/43] chore: update e2e tests to have better handling of testing cert -> issuance flow. Signed-off-by: Matthew H. Irby --- e2e/.env.example | 5 +- e2e/run_tests.sh | 265 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 259 insertions(+), 11 deletions(-) diff --git a/e2e/.env.example b/e2e/.env.example index c0028a3..abe4f8a 100644 --- a/e2e/.env.example +++ b/e2e/.env.example @@ -9,4 +9,7 @@ export OAUTH_TOKEN_URL="https://example.com/oauth2/token" export OAUTH_CLIENT_ID="changeme" export OAUTH_CLIENT_SECRET='changeme' -export DISABLE_CA_CHECK="false" # Set to true to disable CA check in tests \ No newline at end of file +export DISABLE_CA_CHECK="false" # Set to true to disable CA check in tests + +export OAUTH_SCOPES='optional' # remove if not needed +export OAUTH_AUDIENCE='optional' # remove if not needed diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index 22ec2c4..f4f87a3 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -43,13 +43,12 @@ IMAGE_TAG="local" # Uncomment if you want to build the image locally FULL_IMAGE_NAME="${IMAGE_REPO}/${IMAGE_NAME}:${IMAGE_TAG}" 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 +#H ELM_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 IS_LOCAL_DEPLOYMENT=$([ "$IMAGE_TAG" = "local" ] && echo "true" || echo "false") IS_LOCAL_HELM=$([ "$HELM_CHART_VERSION" = "local" ] && echo "true" || echo "false") -# TODO: Handle both in the e2e tests ISSUER_TYPE="Issuer" CLUSTER_ISSUER_TYPE="ClusterIssuer" @@ -75,17 +74,21 @@ ISSUER_NAMESPACE="issuer-playground" SIGNER_SECRET_NAME="auth-secret" +CERTIFICATE_CRD_FQTN="certificates.cert-manager.io" CERTIFICATEREQUEST_CRD_FQTN="certificaterequests.cert-manager.io" CA_CERTS_PATH="e2e/certs" SIGNER_CA_SECRET_NAME="ca-trust-secret" SIGNER_CA_CONFIGMAP_NAME="ca-trust-configmap" - -CR_CR_NAME="req" +CR_C_NAME="command-cert" +CR_CR_NAME="command-cert-1" +CR_C_SECRET_NAME="$CR_C_NAME-tls" set -e # Exit on any error +# checks if environment variable is available in system. if it is not present but the variable is required +# an error is thrown validate_env_present() { local env_var=$1 local required=$2 @@ -100,6 +103,7 @@ validate_env_present() { fi } +# checks whether the following environment variables are provided. some environment variables are optional. check_env() { validate_env_present HOSTNAME true validate_env_present API_PATH true @@ -108,11 +112,14 @@ check_env() { validate_env_present OAUTH_TOKEN_URL true validate_env_present OAUTH_CLIENT_ID true validate_env_present OAUTH_CLIENT_SECRET true + validate_env_present OAUTH_AUDIENCE false + validate_env_present OAUTH_SCOPES false validate_env_present CERTIFICATE_AUTHORITY_HOSTNAME false validate_env_present DISABLE_CA_CHECK false } +# checks whether the provided kubernetes namespace exists ns_exists () { local ns=$1 if [ "$(kubectl get namespace -o json | jq --arg namespace "$ns" -e '.items[] | select(.metadata.name == $namespace) | .metadata.name')" ]; then @@ -121,6 +128,7 @@ ns_exists () { return 1 } +# checks whether the provided helm chart has been deployed to the cluster (namespaced) helm_exists () { local namespace=$1 local chart_name=$2 @@ -130,6 +138,7 @@ helm_exists () { return 1 } +# checks whether the provided custom resource can be found in the cluster (namespaced) cr_exists () { local fqtn=$1 local ns=$2 @@ -141,6 +150,7 @@ cr_exists () { return 1 } +# checks whether the provided secret name exists in the cluster (namespaced) secret_exists () { local ns=$1 local name=$2 @@ -151,6 +161,7 @@ secret_exists () { return 1 } +# installs cert-manager onto the Kubernetes cluster install_cert_manager() { echo "📦 Installing cert-manager..." @@ -174,6 +185,7 @@ install_cert_manager() { echo "✅ cert-manager installed successfully" } +# installs the issuer to the Kubernetes cluster install_cert_manager_issuer() { echo "📦 Installing instance of $IMAGE_NAME with tag $IMAGE_TAG..." @@ -189,6 +201,12 @@ install_cert_manager_issuer() { VERSION_PARAM="" else + # Add command-issuer repository if not already added + if ! helm repo list | grep -q command-issuer; then + echo "Adding command-issuer Helm repository..." + helm repo add command-issuer https://keyfactor.github.io/command-cert-manager-issuer + fi + CHART_PATH="command-issuer/command-cert-manager-issuer" echo "Using Helm chart from repository for version ${HELM_CHART_VERSION}: $CHART_PATH..." VERSION_PARAM="--version ${HELM_CHART_VERSION}" @@ -200,6 +218,15 @@ install_cert_manager_issuer() { else IMAGE_REPO_PARAM="" fi + + + + # Only set the pull policy to Never if we are deploying locally + if [[ "$IS_LOCAL_DEPLOYMENT" == "true" ]]; then + PULL_POLICY_PARAM="--set image.pullPolicy=Never" + else + PULL_POLICY_PARAM="" + fi # Helm chart could be out of date for release candidates, so we will install from # the chart defined in the repository. @@ -209,12 +236,116 @@ install_cert_manager_issuer() { $IMAGE_REPO_PARAM \ --set "fullnameOverride=${IMAGE_NAME}" \ --set image.tag=${IMAGE_TAG} \ - --set image.pullPolicy=Never \ - --wait + $PULL_POLICY_PARAM \ + --wait \ + --timeout 30s echo "✅ $IMAGE_NAME installed successfully" } +# performs a redeployment of the cert-manager. helpful for recycling TLS certificates that have expired. +deploy_cert_manager() { + # Restart all cert-manager components + kubectl rollout restart deployment/cert-manager -n ${CERT_MANAGER_NAMESPACE} + kubectl rollout restart deployment/cert-manager-webhook -n ${CERT_MANAGER_NAMESPACE} + kubectl rollout restart deployment/cert-manager-cainjector -n ${CERT_MANAGER_NAMESPACE} + + # Wait for them to be ready + kubectl rollout status deployment/cert-manager -n ${CERT_MANAGER_NAMESPACE} + kubectl rollout status deployment/cert-manager-webhook -n ${CERT_MANAGER_NAMESPACE} + kubectl rollout status deployment/cert-manager-cainjector -n ${CERT_MANAGER_NAMESPACE} +} + +# deploys the issuer to the Kubernetes cluster +deploy_cert_manager_issuer() { + # Find the deployment name (assuming it follows a pattern) + DEPLOYMENT_NAME=$(kubectl get deployments -n ${MANAGER_NAMESPACE} -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "$IMAGE_NAME") + + # Between runs, we want to make sure that the running issuer has the latest version of the code we want. + # Doing this patch and redeployment forces the container to restart with the latest desired version + if kubectl get deployment ${DEPLOYMENT_NAME} -n ${MANAGER_NAMESPACE} >/dev/null 2>&1; then + # Patch the deployment + kubectl patch deployment ${DEPLOYMENT_NAME} -n ${MANAGER_NAMESPACE} -p "{ + \"spec\": { + \"template\": { + \"spec\": { + \"containers\": [{ + \"name\": \"${IMAGE_NAME}\", + \"image\": \"${FULL_IMAGE_NAME}\", + \"imagePullPolicy\": \"Never\" + }] + } + } + } + }" + + # Rollout deployment changes and apply the patch + kubectl rollout restart deployment/${DEPLOYMENT_NAME} -n ${MANAGER_NAMESPACE} + kubectl rollout status deployment/${DEPLOYMENT_NAME} -n ${MANAGER_NAMESPACE} --timeout=300s + + + echo "✅ Deployment patched and rolled out successfully" + else + echo "⚠️ Deployment ${DEPLOYMENT_NAME} not found. The Helm chart might use a different naming convention." + echo "Available deployments in ${MANAGER_NAMESPACE}:" + kubectl get deployments -n ${MANAGER_NAMESPACE} + fi + + echo "" + echo "🎉 Deployment complete!" + echo "" +} + +# check the expiration of the cert-manager TLS certificate +check_cert_manager_webhook_cert() { + local namespace=${1:-cert-manager} + local secret_name=${2:-cert-manager-webhook-ca} + + echo "🔍 Checking cert-manager webhook certificate..." + + # Check if secret exists + if ! kubectl get secret "$secret_name" -n "$namespace" >/dev/null 2>&1; then + echo "❌ Secret $secret_name not found in namespace $namespace" + return 1 + fi + + # Get certificate data + local cert_data=$(kubectl get secret "$secret_name" -n "$namespace" -o jsonpath='{.data.tls\.crt}' 2>/dev/null) + + if [ -z "$cert_data" ]; then + echo "❌ No certificate data found in secret" + return 1 + fi + + # Decode and check certificate + local cert_info=$(echo "$cert_data" | base64 -d | openssl x509 -noout -dates 2>/dev/null) + + if [ $? -ne 0 ]; then + echo "❌ Failed to parse certificate" + return 1 + fi + + echo "📋 Certificate validity:" + echo "$cert_info" + + # Check if certificate is currently valid + if echo "$cert_data" | base64 -d | openssl x509 -noout -checkend 0 >/dev/null 2>&1; then + echo "✅ Certificate is currently valid" + + # Check if expires within 7 days + if ! echo "$cert_data" | base64 -d | openssl x509 -noout -checkend 604800 >/dev/null 2>&1; then + echo "⚠️ Certificate expires within 7 days" + return 2 # Warning status + fi + + return 0 # Valid + else + echo "❌ Certificate is expired or not yet valid" + return 1 # Expired + fi +} + +# creates a new issuer custom resource create_issuer() { echo "🔐 Creating issuer resource..." @@ -265,6 +396,7 @@ EOF echo "✅ Issuer resources created successfully" } +# creates a new cluster issuer custom resource create_cluster_issuer() { echo "🔐 Creating cluster issuer resource..." @@ -315,6 +447,7 @@ EOF echo "✅ Issuer resources created successfully" } +# deletes Issuer and ClusterIssuer custom resources from the Kubernetes cluster delete_issuers() { echo "🗑️ Deleting issuer resources..." @@ -338,6 +471,59 @@ delete_issuers() { echo "✅ Issuer resources deleted successfully" } +# creates a Certificate custom resource. this is picked up by cert-manager and converted to a CertificateRequest. +create_certificate() { + local issuer_type=$1 + + echo "Generating a certificate object for issuer type: $issuer_type" + + kubectl -n "$ISSUER_NAMESPACE" apply -f - < Date: Fri, 24 Oct 2025 13:11:09 -0400 Subject: [PATCH 20/43] feat: add health check interval to issuer spec Signed-off-by: Matthew H. Irby --- api/v1alpha1/issuer_types.go | 4 ++++ api/v1alpha1/zz_generated.deepcopy.go | 9 +++++++-- .../command-issuer.keyfactor.com_clusterissuers.yaml | 5 +++++ .../crd/bases/command-issuer.keyfactor.com_issuers.yaml | 5 +++++ .../templates/crds/clusterissuers.yaml | 5 +++++ .../templates/crds/issuers.yaml | 5 +++++ 6 files changed, 31 insertions(+), 2 deletions(-) diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index d70b728..e93889e 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -46,6 +46,10 @@ type IssuerSpec struct { // +kubebuilder:default:=KeyfactorAPI APIPath string `json:"apiPath,omitempty"` + // The number of seconds between successful health checks. 60 seconds (1 minute) by default + // +kubebuilder:default:=60 + HealthCheckIntervalSeconds *int `json:"healthCheckIntervalSeconds,omitempty"` + // EnrollmentPatternId is the ID of the enrollment pattern to use. Supported in Keyfactor Command 25.1 and later. // If both enrollment pattern and certificate template are specified, enrollment pattern will take precedence. // If EnrollmentPatternId and EnrollmentPatternName are both specified, EnrollmentPatternId will take precedence. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 3eb08e1..9555ca1 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -29,7 +29,7 @@ func (in *ClusterIssuer) DeepCopyInto(out *ClusterIssuer) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -88,7 +88,7 @@ func (in *Issuer) DeepCopyInto(out *Issuer) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -164,6 +164,11 @@ func (in *IssuerList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IssuerSpec) DeepCopyInto(out *IssuerSpec) { *out = *in + if in.HealthCheckIntervalSeconds != nil { + in, out := &in.HealthCheckIntervalSeconds, &out.HealthCheckIntervalSeconds + *out = new(int) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IssuerSpec. diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index 963320f..69ec711 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -116,6 +116,11 @@ spec: Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. type: string + healthCheckIntervalSeconds: + default: 60 + description: The number of seconds between successful health checks. + 60 seconds (1 minute) by default + type: integer hostname: description: Hostname is the hostname of a Keyfactor Command instance. type: string diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index 6a1e923..bde06f9 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -116,6 +116,11 @@ spec: Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. type: string + healthCheckIntervalSeconds: + default: 60 + description: The number of seconds between successful health checks. + 60 seconds (1 minute) by default + type: integer hostname: description: Hostname is the hostname of a Keyfactor Command instance. type: string diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml index e82b09c..7af80c5 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml @@ -93,6 +93,11 @@ spec: Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. type: string + healthCheckIntervalSeconds: + default: 60 + description: The number of seconds between successful health checks. + 60 seconds (1 minute) by default + type: integer ownerRoleId: description: |- OwnerRoleId is the ID of the security role assigned as the certificate owner. diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml index b88f04e..d4776e8 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml @@ -93,6 +93,11 @@ spec: Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. type: string + healthCheckIntervalSeconds: + default: 60 + description: The number of seconds between successful health checks. + 60 seconds (1 minute) by default + type: integer ownerRoleId: description: |- OwnerRoleId is the ID of the security role assigned as the certificate owner. From d43f635deda2529d1bd10aa25138c2e28a6089fd Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Fri, 24 Oct 2025 13:57:28 -0400 Subject: [PATCH 21/43] chore: update docs Signed-off-by: Matthew H. Irby --- api/v1alpha1/issuer_types.go | 2 +- .../crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml | 3 ++- config/crd/bases/command-issuer.keyfactor.com_issuers.yaml | 3 ++- .../templates/crds/clusterissuers.yaml | 3 ++- .../command-cert-manager-issuer/templates/crds/issuers.yaml | 3 ++- docsource/content.md | 3 +++ 6 files changed, 12 insertions(+), 5 deletions(-) diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index e93889e..010a585 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -46,7 +46,7 @@ type IssuerSpec struct { // +kubebuilder:default:=KeyfactorAPI APIPath string `json:"apiPath,omitempty"` - // The number of seconds between successful health checks. 60 seconds (1 minute) by default + // The number of seconds between successful health checks. 60 seconds (1 minute) by default. Setting to 0 will disable the health check. // +kubebuilder:default:=60 HealthCheckIntervalSeconds *int `json:"healthCheckIntervalSeconds,omitempty"` diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index 69ec711..d953b83 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -119,7 +119,8 @@ spec: healthCheckIntervalSeconds: default: 60 description: The number of seconds between successful health checks. - 60 seconds (1 minute) by default + 60 seconds (1 minute) by default. Setting to 0 will disable the + health check. type: integer hostname: description: Hostname is the hostname of a Keyfactor Command instance. diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index bde06f9..a90db1b 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -119,7 +119,8 @@ spec: healthCheckIntervalSeconds: default: 60 description: The number of seconds between successful health checks. - 60 seconds (1 minute) by default + 60 seconds (1 minute) by default. Setting to 0 will disable the + health check. type: integer hostname: description: Hostname is the hostname of a Keyfactor Command instance. diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml index 7af80c5..147d47d 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml @@ -96,7 +96,8 @@ spec: healthCheckIntervalSeconds: default: 60 description: The number of seconds between successful health checks. - 60 seconds (1 minute) by default + 60 seconds (1 minute) by default. Setting to 0 will disable the + health check. type: integer ownerRoleId: description: |- diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml index d4776e8..7a96cd0 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml @@ -96,7 +96,8 @@ spec: healthCheckIntervalSeconds: default: 60 description: The number of seconds between successful health checks. - 60 seconds (1 minute) by default + 60 seconds (1 minute) by default. Setting to 0 will disable the + health check. type: integer ownerRoleId: description: |- diff --git a/docsource/content.md b/docsource/content.md index 6739be5..46c4bcb 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -233,6 +233,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | + | healthCheckIntervalSeconds | (Optional) Defines the health check interval, in seconds, for a healthy issuer. If ommitted, defaults to 60 seconds. If set to 0, it will disable the health check. If there is a failure when running the health check, it will retry in 10 seconds with an exponential backoff strategy. Value must not be negative. | > If a different combination of hostname/certificate authority/certificate template is required, a new Issuer or ClusterIssuer resource must be created. Each resource instantiation represents a single configuration. @@ -266,6 +267,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required # audience: "https://your-command-url.com" # Uncomment if desired + # healthCheckIntervalSeconds: 60 # Uncomment if desired. Setting to 0 disables health check. EOF kubectl -n default apply -f issuer.yaml @@ -298,6 +300,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required # audience: "https://your-command-url.com" # Uncomment if desired + # healthCheckIntervalSeconds: 60 # Uncomment if desired. Setting to 0 disables health check. EOF kubectl apply -f clusterissuer.yaml From cba82d0f28041176a7b58d96a6e4f9574e44cfcc Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Fri, 24 Oct 2025 14:10:12 -0400 Subject: [PATCH 22/43] feat: implement health check logic Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 4 + internal/controller/issuer_controller.go | 39 +++++- internal/controller/issuer_controller_test.go | 129 ++++++++++++++++-- 3 files changed, 161 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5a6df..482dda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# v2.3.2 +## Features +- Add a `healthCheckIntervalSeconds` specification to Issuer / ClusterIssuer resources, allowing flexibility in the health check interval. + # v2.3.1 ## Fixes - Add a manual dispatch of Helm chart release. diff --git a/internal/controller/issuer_controller.go b/internal/controller/issuer_controller.go index d56035b..41beaa2 100644 --- a/internal/controller/issuer_controller.go +++ b/internal/controller/issuer_controller.go @@ -25,6 +25,7 @@ import ( commandissuer "github.com/Keyfactor/command-cert-manager-issuer/api/v1alpha1" "github.com/Keyfactor/command-cert-manager-issuer/internal/command" + "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -88,8 +89,6 @@ func (r *IssuerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res return ctrl.Result{}, nil } - log.Info(fmt.Sprintf("Starting %s reconciliation run", issuer.GetObjectKind().GroupVersionKind().Kind)) - // Always attempt to update the Ready condition defer func() { if err != nil { @@ -101,6 +100,17 @@ func (r *IssuerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res } }() + healthCheckInterval, err := getHealthCheckInterval(log, issuer) + if err != nil { + log.Error(err, "en error occurred while getting the health check interval") + issuer.GetStatus().SetCondition(ctx, commandissuer.IssuerConditionReady, commandissuer.ConditionFalse, issuerReadyConditionReason, err.Error()) + issuer.GetStatus().SetCondition(ctx, commandissuer.IssuerConditionSupportsMetadata, commandissuer.ConditionUnknown, "", "") + return ctrl.Result{}, nil + } + + log.Info(fmt.Sprintf("Starting %s reconciliation run", issuer.GetObjectKind().GroupVersionKind().Kind)) + log.Info(fmt.Sprintf("Issuer %s has been configured with a health check interval of %d seconds", issuer.GetObjectKind().GroupVersionKind().Kind, int(healthCheckInterval/time.Second))) + var secretNamespace string switch { @@ -144,7 +154,30 @@ func (r *IssuerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res issuer.GetStatus().SetCondition(ctx, commandissuer.IssuerConditionSupportsMetadata, commandissuer.ConditionFalse, "Metadata fields are not defined", "Connected Command platform doesn't have the Command Issuer metadata fields defined.") } - return ctrl.Result{RequeueAfter: defaultHealthCheckInterval}, nil + return ctrl.Result{RequeueAfter: healthCheckInterval}, nil +} + +func getHealthCheckInterval(log logr.Logger, issuer commandissuer.IssuerLike) (time.Duration, error) { + spec := issuer.GetSpec() + + if spec.HealthCheckIntervalSeconds == nil { + log.Info(fmt.Sprintf("health check spec value is nil, using default: %d", int(defaultHealthCheckInterval/time.Second))) + return defaultHealthCheckInterval, nil + } + + interval := *spec.HealthCheckIntervalSeconds + + // Health check interval should not be negative + if interval < 0 { + return 0, fmt.Errorf("interval %d is invalid, must be greater than or equal to 0", interval) + } + + // Issuer may be configured to ignore future health checks + if interval == 0 { + log.Info("health check interval is configured to be 0. this will disable future health checks for issuer.") + } + + return time.Duration(interval) * time.Second, nil } func commandConfigFromIssuer(ctx context.Context, c client.Client, issuer commandissuer.IssuerLike, secretNamespace string) (*command.Config, error) { diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index e19d900..6e9b148 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_test.go @@ -20,7 +20,9 @@ import ( "context" "errors" "testing" + "time" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" commandissuer "github.com/Keyfactor/command-cert-manager-issuer/api/v1alpha1" commandissuerv1alpha1 "github.com/Keyfactor/command-cert-manager-issuer/api/v1alpha1" "github.com/Keyfactor/command-cert-manager-issuer/internal/command" @@ -53,19 +55,13 @@ func (f *fakeHealthChecker) CommandSupportsMetadata() (bool, error) { var newFakeHealthCheckerBuilder = func(builderErr error, checkerErr error, supportsMetadata bool) func(context.Context, *command.Config) (command.HealthChecker, error) { return func(context.Context, *command.Config) (command.HealthChecker, error) { return &fakeHealthChecker{ - errCheck: checkerErr, + supportsMetadata: supportsMetadata, + errCheck: checkerErr, }, builderErr } } func TestIssuerReconcile(t *testing.T) { - // caCert, rootKey := issueTestCertificate(t, "Root-CA", nil, nil) - // caCertPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}) - - // serverCert, _ := issueTestCertificate(t, "Server", caCert, rootKey) - // serverCertPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverCert.Raw}) - // caChain := append(serverCertPem, caCertPem...) - type testCase struct { kind string name types.NamespacedName @@ -573,6 +569,123 @@ func TestIssuerReconcile(t *testing.T) { expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, }, + "success-custom-healthcheck-interval-issuer": { + kind: "Issuer", + name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, + objects: []client.Object{ + &commandissuerv1alpha1.Issuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "issuer1", + Namespace: "ns1", + }, + Spec: commandissuerv1alpha1.IssuerSpec{ + SecretName: "issuer1-credentials", + HealthCheckIntervalSeconds: to.Ptr(30), + }, + Status: commandissuerv1alpha1.IssuerStatus{ + Conditions: []commandissuerv1alpha1.IssuerCondition{ + { + Type: commandissuerv1alpha1.IssuerConditionReady, + Status: commandissuerv1alpha1.ConditionUnknown, + }, + }, + }, + }, + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "issuer1-credentials", + Namespace: "ns1", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + }, + healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), + expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedResult: ctrl.Result{RequeueAfter: time.Duration(30) * time.Second}, + }, + "success-custom-healthcheck-interval-clusterissuer": { + kind: "ClusterIssuer", + name: types.NamespacedName{Name: "clusterissuer1"}, + objects: []client.Object{ + &commandissuerv1alpha1.ClusterIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1", + }, + Spec: commandissuerv1alpha1.IssuerSpec{ + SecretName: "clusterissuer1-credentials", + HealthCheckIntervalSeconds: to.Ptr(30), + }, + Status: commandissuerv1alpha1.IssuerStatus{ + Conditions: []commandissuerv1alpha1.IssuerCondition{ + { + Type: commandissuerv1alpha1.IssuerConditionReady, + Status: commandissuerv1alpha1.ConditionUnknown, + }, + }, + }, + }, + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1-credentials", + Namespace: "kube-system", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + }, + healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), + clusterResourceNamespace: "kube-system", + expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedResult: ctrl.Result{RequeueAfter: time.Duration(30) * time.Second}, + }, + "error-healthcheck-negative-value": { + kind: "Issuer", + name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, + objects: []client.Object{ + &commandissuerv1alpha1.Issuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "issuer1", + Namespace: "ns1", + }, + Spec: commandissuerv1alpha1.IssuerSpec{ + SecretName: "issuer1-credentials", + HealthCheckIntervalSeconds: to.Ptr(-30), + }, + Status: commandissuerv1alpha1.IssuerStatus{ + Conditions: []commandissuerv1alpha1.IssuerCondition{ + { + Type: commandissuerv1alpha1.IssuerConditionReady, + Status: commandissuerv1alpha1.ConditionUnknown, + }, + }, + }, + }, + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "issuer1-credentials", + Namespace: "ns1", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + }, + healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), + expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionUnknown, + expectedResult: ctrl.Result{}, + }, } scheme := runtime.NewScheme() From 075be33c67ef7aab1c4920c30eaf3797cc21e276 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Fri, 24 Oct 2025 14:10:30 -0400 Subject: [PATCH 23/43] feat(actions): Ensure that CRDs are not out of date Signed-off-by: Matthew H. Irby --- .github/workflows/keyfactor-bootstrap-workflow.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml index f72b649..b4fff0e 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -12,7 +12,7 @@ on: jobs: build: - name: Build and Lint + name: Build and Check CRDs runs-on: ubuntu-latest timeout-minutes: 8 steps: @@ -23,11 +23,17 @@ jobs: cache: true - run: go mod download - run: go build -v ./cmd/main.go + - name: Regenerate CRDs + run: make generate manifests + - name: Check for CRD drift + run: | + git diff --compact-summary --exit-code || \ + (echo; echo "Unexpected difference in directories after code generation. Run 'make generate manifests' and commit."; exit 1) # - name: Run linters # uses: golangci/golangci-lint-action@08e2f20817b15149a52b5b3ebe7de50aff2ba8c5 # v3.4.0 # with: # version: latest - + test: name: Go Test needs: build From 482917a8f9c47dde7fe7ec6e4769e9f0f4cae15d Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Fri, 24 Oct 2025 18:17:13 +0000 Subject: [PATCH 24/43] Update generated docs --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index ccf4a2d..07dd156 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | + | healthCheckIntervalSeconds | (Optional) Defines the health check interval, in seconds, for a healthy issuer. If ommitted, defaults to 60 seconds. If set to 0, it will disable the health check. If there is a failure when running the health check, it will retry in 10 seconds with an exponential backoff strategy. Value must not be negative. | > If a different combination of hostname/certificate authority/certificate template is required, a new Issuer or ClusterIssuer resource must be created. Each resource instantiation represents a single configuration. @@ -298,6 +299,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required # audience: "https://your-command-url.com" # Uncomment if desired + # healthCheckIntervalSeconds: 60 # Uncomment if desired. Setting to 0 disables health check. EOF kubectl -n default apply -f issuer.yaml @@ -330,6 +332,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required # audience: "https://your-command-url.com" # Uncomment if desired + # healthCheckIntervalSeconds: 60 # Uncomment if desired. Setting to 0 disables health check. EOF kubectl apply -f clusterissuer.yaml From 2b0cbafc1018dde3664125f02385126b7ef38859 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Fri, 24 Oct 2025 14:21:36 -0400 Subject: [PATCH 25/43] chore(docs): update changelog version number Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 482dda4..1887129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# v2.3.2 +# v2.3.3 ## Features - Add a `healthCheckIntervalSeconds` specification to Issuer / ClusterIssuer resources, allowing flexibility in the health check interval. From 7fef163034e070a55e4a914fcbc1dadfe0731d70 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Mon, 27 Oct 2025 12:16:14 -0400 Subject: [PATCH 26/43] chore: Convert to healthcheck block instead of healthCheckIntervalSeconds Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 2 +- api/v1alpha1/issuer_types.go | 16 +- api/v1alpha1/zz_generated.deepcopy.go | 29 ++- ...d-issuer.keyfactor.com_clusterissuers.yaml | 22 +- .../command-issuer.keyfactor.com_issuers.yaml | 22 +- .../templates/crds/clusterissuers.yaml | 22 +- .../templates/crds/issuers.yaml | 22 +- docsource/content.md | 12 +- e2e/run_tests.sh | 4 +- internal/controller/issuer_controller.go | 27 +- internal/controller/issuer_controller_test.go | 231 +++++++++++++++++- 11 files changed, 354 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1887129..9539c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # v2.3.3 ## Features -- Add a `healthCheckIntervalSeconds` specification to Issuer / ClusterIssuer resources, allowing flexibility in the health check interval. +- Add a `healthcheck` specification to Issuer / ClusterIssuer resources, allowing flexibility in the health check interval. # v2.3.1 ## Fixes diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index 010a585..690ccbf 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -46,9 +46,10 @@ type IssuerSpec struct { // +kubebuilder:default:=KeyfactorAPI APIPath string `json:"apiPath,omitempty"` - // The number of seconds between successful health checks. 60 seconds (1 minute) by default. Setting to 0 will disable the health check. - // +kubebuilder:default:=60 - HealthCheckIntervalSeconds *int `json:"healthCheckIntervalSeconds,omitempty"` + // The healthcheck configuration for the issuer. This configures the frequency at which the issuer will perform + // a health check to determine issuer's connectivity to Command instance. + // +kubebuilder:validation:Optional + HealthCheck *HealthCheckConfig `json:"healthcheck,omitempty"` // EnrollmentPatternId is the ID of the enrollment pattern to use. Supported in Keyfactor Command 25.1 and later. // If both enrollment pattern and certificate template are specified, enrollment pattern will take precedence. @@ -297,6 +298,15 @@ const ( ConditionUnknown ConditionStatus = "Unknown" ) +type HealthCheckConfig struct { + // Determines whether to the health check when the issuer is healthy. Default: true + Enabled bool `json:"enabled"` + + // The interval at which to health check the issuer when healthy. Defaults to 1 minute. Must not be less than "30s". + // +kubebuilder:validation:Optional + Interval *metav1.Duration `json:"interval"` +} + func init() { SchemeBuilder.Register(&Issuer{}, &IssuerList{}) } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 9555ca1..372708f 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,6 +21,7 @@ limitations under the License. package v1alpha1 import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -83,6 +84,26 @@ func (in *ClusterIssuerList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HealthCheckConfig) DeepCopyInto(out *HealthCheckConfig) { + *out = *in + if in.Interval != nil { + in, out := &in.Interval, &out.Interval + *out = new(v1.Duration) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HealthCheckConfig. +func (in *HealthCheckConfig) DeepCopy() *HealthCheckConfig { + if in == nil { + return nil + } + out := new(HealthCheckConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Issuer) DeepCopyInto(out *Issuer) { *out = *in @@ -164,10 +185,10 @@ func (in *IssuerList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IssuerSpec) DeepCopyInto(out *IssuerSpec) { *out = *in - if in.HealthCheckIntervalSeconds != nil { - in, out := &in.HealthCheckIntervalSeconds, &out.HealthCheckIntervalSeconds - *out = new(int) - **out = **in + if in.HealthCheck != nil { + in, out := &in.HealthCheck, &out.HealthCheck + *out = new(HealthCheckConfig) + (*in).DeepCopyInto(*out) } } diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index d953b83..d6ae5b9 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -116,12 +116,22 @@ spec: Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. type: string - healthCheckIntervalSeconds: - default: 60 - description: The number of seconds between successful health checks. - 60 seconds (1 minute) by default. Setting to 0 will disable the - health check. - type: integer + healthcheck: + description: |- + The healthcheck configuration for the issuer. This configures the frequency at which the issuer will perform + a health check to determine issuer's connectivity to Command instance. + properties: + enabled: + description: 'Determines whether to the health check when the + issuer is healthy. Default: true' + type: boolean + interval: + description: The interval at which to health check the issuer + when healthy. Defaults to 1 minute. Must not be less than "30s". + type: string + required: + - enabled + type: object hostname: description: Hostname is the hostname of a Keyfactor Command instance. type: string diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index a90db1b..fbe45e9 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -116,12 +116,22 @@ spec: Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. type: string - healthCheckIntervalSeconds: - default: 60 - description: The number of seconds between successful health checks. - 60 seconds (1 minute) by default. Setting to 0 will disable the - health check. - type: integer + healthcheck: + description: |- + The healthcheck configuration for the issuer. This configures the frequency at which the issuer will perform + a health check to determine issuer's connectivity to Command instance. + properties: + enabled: + description: 'Determines whether to the health check when the + issuer is healthy. Default: true' + type: boolean + interval: + description: The interval at which to health check the issuer + when healthy. Defaults to 1 minute. Must not be less than "30s". + type: string + required: + - enabled + type: object hostname: description: Hostname is the hostname of a Keyfactor Command instance. type: string diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml index 147d47d..5915eee 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml @@ -93,12 +93,22 @@ spec: Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. type: string - healthCheckIntervalSeconds: - default: 60 - description: The number of seconds between successful health checks. - 60 seconds (1 minute) by default. Setting to 0 will disable the - health check. - type: integer + healthcheck: + description: |- + The healthcheck configuration for the issuer. This configures the frequency at which the issuer will perform + a health check to determine issuer's connectivity to Command instance. + properties: + enabled: + description: 'Determines whether to the health check when the + issuer is healthy. Default: true' + type: boolean + interval: + description: The interval at which to health check the issuer + when healthy. Defaults to 1 minute. + type: string + required: + - enabled + type: object ownerRoleId: description: |- OwnerRoleId is the ID of the security role assigned as the certificate owner. diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml index 7a96cd0..5db21f4 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml @@ -93,12 +93,22 @@ spec: Enrollment will fail if the specified template is not compatible with the enrollment pattern. Refer to the Keyfactor Command documentation for more information. type: string - healthCheckIntervalSeconds: - default: 60 - description: The number of seconds between successful health checks. - 60 seconds (1 minute) by default. Setting to 0 will disable the - health check. - type: integer + healthcheck: + description: |- + The healthcheck configuration for the issuer. This configures the frequency at which the issuer will perform + a health check to determine issuer's connectivity to Command instance. + properties: + enabled: + description: 'Determines whether to the health check when the + issuer is healthy. Default: true' + type: boolean + interval: + description: The interval at which to health check the issuer + when healthy. Defaults to 1 minute. + type: string + required: + - enabled + type: object ownerRoleId: description: |- OwnerRoleId is the ID of the security role assigned as the certificate owner. diff --git a/docsource/content.md b/docsource/content.md index 46c4bcb..e7b0f74 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -233,7 +233,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | - | healthCheckIntervalSeconds | (Optional) Defines the health check interval, in seconds, for a healthy issuer. If ommitted, defaults to 60 seconds. If set to 0, it will disable the health check. If there is a failure when running the health check, it will retry in 10 seconds with an exponential backoff strategy. Value must not be negative. | + | healthcheck | (Optional) Defines the health check configuration for the issuer. If ommitted, health checks will be enabled and default to 60 seconds. If left disabled, the issuer will not perform a health check when the issuer is healthy and may cause CertificateRequest resources to silently fail. | + | healthcheck.enabled | (Required if health check block provided) Boolean to enable / disable health checks. By default, health checks are enabled. | + | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `60s`. | > If a different combination of hostname/certificate authority/certificate template is required, a new Issuer or ClusterIssuer resource must be created. Each resource instantiation represents a single configuration. @@ -267,7 +269,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required # audience: "https://your-command-url.com" # Uncomment if desired - # healthCheckIntervalSeconds: 60 # Uncomment if desired. Setting to 0 disables health check. + # healthcheck: # Optional health check configuration + # enabled: true + # interval: 30s EOF kubectl -n default apply -f issuer.yaml @@ -300,7 +304,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required # audience: "https://your-command-url.com" # Uncomment if desired - # healthCheckIntervalSeconds: 60 # Uncomment if desired. Setting to 0 disables health check. + # healthcheck: # Optional health check configuration + # enabled: true + # interval: 30s EOF kubectl apply -f clusterissuer.yaml diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index f4f87a3..d1095ef 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -43,7 +43,7 @@ IMAGE_TAG="local" # Uncomment if you want to build the image locally FULL_IMAGE_NAME="${IMAGE_REPO}/${IMAGE_NAME}:${IMAGE_TAG}" HELM_CHART_NAME="command-cert-manager-issuer" -#H ELM_CHART_VERSION="2.1.0" # Uncomment if you want to use a specific version from the Helm repository +# 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 IS_LOCAL_DEPLOYMENT=$([ "$IMAGE_TAG" = "local" ] && echo "true" || echo "false") @@ -209,7 +209,7 @@ install_cert_manager_issuer() { CHART_PATH="command-issuer/command-cert-manager-issuer" echo "Using Helm chart from repository for version ${HELM_CHART_VERSION}: $CHART_PATH..." - VERSION_PARAM="--version ${HELM_CHART_VERSION}" + VERSION_PARAM="--version ${HELM_CHART_VERSION} --devel" fi # Only set the image repository parameter if we are deploying locally diff --git a/internal/controller/issuer_controller.go b/internal/controller/issuer_controller.go index 41beaa2..5ce4d1a 100644 --- a/internal/controller/issuer_controller.go +++ b/internal/controller/issuer_controller.go @@ -160,24 +160,31 @@ func (r *IssuerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res func getHealthCheckInterval(log logr.Logger, issuer commandissuer.IssuerLike) (time.Duration, error) { spec := issuer.GetSpec() - if spec.HealthCheckIntervalSeconds == nil { - log.Info(fmt.Sprintf("health check spec value is nil, using default: %d", int(defaultHealthCheckInterval/time.Second))) + defaultInterval := int(defaultHealthCheckInterval / time.Second) + + if spec.HealthCheck == nil { + log.Info(fmt.Sprintf("health check spec value is nil, using default: %d", defaultInterval)) return defaultHealthCheckInterval, nil } - interval := *spec.HealthCheckIntervalSeconds + if !spec.HealthCheck.Enabled { + log.Info("health check has been disabled") + return 0, nil + } - // Health check interval should not be negative - if interval < 0 { - return 0, fmt.Errorf("interval %d is invalid, must be greater than or equal to 0", interval) + if spec.HealthCheck.Interval == nil { + log.Info(fmt.Sprintf("health check spec value is nil, using default: %d", defaultInterval)) + return defaultHealthCheckInterval, nil } - // Issuer may be configured to ignore future health checks - if interval == 0 { - log.Info("health check interval is configured to be 0. this will disable future health checks for issuer.") + healthCheckInterval := *spec.HealthCheck.Interval + + // To prevent from overloading the server, health check interval should not be less than 30 seconds + if healthCheckInterval.Duration < time.Duration(30)*time.Second { + return 0, fmt.Errorf("interval %s is invalid, must be greater than or equal to '30s'", healthCheckInterval) } - return time.Duration(interval) * time.Second, nil + return healthCheckInterval.Duration, nil } func commandConfigFromIssuer(ctx context.Context, c client.Client, issuer commandissuer.IssuerLike, secretNamespace string) (*command.Config, error) { diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index 6e9b148..83e711e 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_test.go @@ -579,8 +579,11 @@ func TestIssuerReconcile(t *testing.T) { Namespace: "ns1", }, Spec: commandissuerv1alpha1.IssuerSpec{ - SecretName: "issuer1-credentials", - HealthCheckIntervalSeconds: to.Ptr(30), + SecretName: "issuer1-credentials", + HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + Enabled: true, + Interval: to.Ptr(metav1.Duration{Duration: 30 * time.Second}), + }, }, Status: commandissuerv1alpha1.IssuerStatus{ Conditions: []commandissuerv1alpha1.IssuerCondition{ @@ -617,8 +620,11 @@ func TestIssuerReconcile(t *testing.T) { Name: "clusterissuer1", }, Spec: commandissuerv1alpha1.IssuerSpec{ - SecretName: "clusterissuer1-credentials", - HealthCheckIntervalSeconds: to.Ptr(30), + SecretName: "clusterissuer1-credentials", + HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + Enabled: true, + Interval: to.Ptr(metav1.Duration{Duration: 120 * time.Second}), + }, }, Status: commandissuerv1alpha1.IssuerStatus{ Conditions: []commandissuerv1alpha1.IssuerCondition{ @@ -645,9 +651,215 @@ func TestIssuerReconcile(t *testing.T) { clusterResourceNamespace: "kube-system", expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedResult: ctrl.Result{RequeueAfter: time.Duration(30) * time.Second}, + expectedResult: ctrl.Result{RequeueAfter: time.Duration(120) * time.Second}, + }, + "success-healthcheck-disabled": { + kind: "ClusterIssuer", + name: types.NamespacedName{Name: "clusterissuer1"}, + objects: []client.Object{ + &commandissuerv1alpha1.ClusterIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1", + }, + Spec: commandissuerv1alpha1.IssuerSpec{ + SecretName: "clusterissuer1-credentials", + HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + Enabled: false, + }, + }, + Status: commandissuerv1alpha1.IssuerStatus{ + Conditions: []commandissuerv1alpha1.IssuerCondition{ + { + Type: commandissuerv1alpha1.IssuerConditionReady, + Status: commandissuerv1alpha1.ConditionUnknown, + }, + }, + }, + }, + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1-credentials", + Namespace: "kube-system", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + }, + healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), + clusterResourceNamespace: "kube-system", + expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedResult: ctrl.Result{RequeueAfter: time.Duration(0)}, + }, + "success-no-healthcheck-interval": { + kind: "ClusterIssuer", + name: types.NamespacedName{Name: "clusterissuer1"}, + objects: []client.Object{ + &commandissuerv1alpha1.ClusterIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1", + }, + Spec: commandissuerv1alpha1.IssuerSpec{ + SecretName: "clusterissuer1-credentials", + HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + Enabled: true, + Interval: nil, + }, + }, + Status: commandissuerv1alpha1.IssuerStatus{ + Conditions: []commandissuerv1alpha1.IssuerCondition{ + { + Type: commandissuerv1alpha1.IssuerConditionReady, + Status: commandissuerv1alpha1.ConditionUnknown, + }, + }, + }, + }, + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1-credentials", + Namespace: "kube-system", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + }, + healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), + clusterResourceNamespace: "kube-system", + expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedResult: ctrl.Result{RequeueAfter: defaultHealthCheckInterval}, }, - "error-healthcheck-negative-value": { + "success-nil-healthcheck-interval-defaults": { + kind: "ClusterIssuer", + name: types.NamespacedName{Name: "clusterissuer1"}, + objects: []client.Object{ + &commandissuerv1alpha1.ClusterIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1", + }, + Spec: commandissuerv1alpha1.IssuerSpec{ + SecretName: "clusterissuer1-credentials", + HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + Enabled: true, + Interval: nil, + }, + }, + Status: commandissuerv1alpha1.IssuerStatus{ + Conditions: []commandissuerv1alpha1.IssuerCondition{ + { + Type: commandissuerv1alpha1.IssuerConditionReady, + Status: commandissuerv1alpha1.ConditionUnknown, + }, + }, + }, + }, + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1-credentials", + Namespace: "kube-system", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + }, + healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), + clusterResourceNamespace: "kube-system", + expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedResult: ctrl.Result{RequeueAfter: time.Duration(60) * time.Second}, + }, + "success-nil-healthcheck-defaults": { + kind: "ClusterIssuer", + name: types.NamespacedName{Name: "clusterissuer1"}, + objects: []client.Object{ + &commandissuerv1alpha1.ClusterIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1", + }, + Spec: commandissuerv1alpha1.IssuerSpec{ + SecretName: "clusterissuer1-credentials", + HealthCheck: nil, + }, + Status: commandissuerv1alpha1.IssuerStatus{ + Conditions: []commandissuerv1alpha1.IssuerCondition{ + { + Type: commandissuerv1alpha1.IssuerConditionReady, + Status: commandissuerv1alpha1.ConditionUnknown, + }, + }, + }, + }, + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1-credentials", + Namespace: "kube-system", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + }, + healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), + clusterResourceNamespace: "kube-system", + expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedResult: ctrl.Result{RequeueAfter: time.Duration(60) * time.Second}, + }, + // "error-healthcheck-invalid-parsing": { + // kind: "Issuer", + // name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, + // objects: []client.Object{ + // &commandissuerv1alpha1.Issuer{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "issuer1", + // Namespace: "ns1", + // }, + // Spec: commandissuerv1alpha1.IssuerSpec{ + // SecretName: "issuer1-credentials", + // HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + // Enabled: true, + // Interval: to.Ptr(metav1.Duration{Duration: 30 * time.Second}), + // }, + // }, + // Status: commandissuerv1alpha1.IssuerStatus{ + // Conditions: []commandissuerv1alpha1.IssuerCondition{ + // { + // Type: commandissuerv1alpha1.IssuerConditionReady, + // Status: commandissuerv1alpha1.ConditionUnknown, + // }, + // }, + // }, + // }, + // &corev1.Secret{ + // Type: corev1.SecretTypeBasicAuth, + // ObjectMeta: metav1.ObjectMeta{ + // Name: "issuer1-credentials", + // Namespace: "ns1", + // }, + // Data: map[string][]byte{ + // corev1.BasicAuthUsernameKey: []byte("username"), + // corev1.BasicAuthPasswordKey: []byte("password"), + // }, + // }, + // }, + // healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), + // expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, + // expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionUnknown, + // expectedResult: ctrl.Result{}, + // }, + "error-healthcheck-minimum-value": { kind: "Issuer", name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ @@ -657,8 +869,11 @@ func TestIssuerReconcile(t *testing.T) { Namespace: "ns1", }, Spec: commandissuerv1alpha1.IssuerSpec{ - SecretName: "issuer1-credentials", - HealthCheckIntervalSeconds: to.Ptr(-30), + SecretName: "issuer1-credentials", + HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + Enabled: true, + Interval: to.Ptr(metav1.Duration{Duration: 29 * time.Second}), + }, }, Status: commandissuerv1alpha1.IssuerStatus{ Conditions: []commandissuerv1alpha1.IssuerCondition{ From 519c8a4d9717d8509a5bd171feb752f77b9dfd5b Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Mon, 27 Oct 2025 16:19:17 +0000 Subject: [PATCH 27/43] Update generated docs --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 07dd156..66b0d92 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | - | healthCheckIntervalSeconds | (Optional) Defines the health check interval, in seconds, for a healthy issuer. If ommitted, defaults to 60 seconds. If set to 0, it will disable the health check. If there is a failure when running the health check, it will retry in 10 seconds with an exponential backoff strategy. Value must not be negative. | + | healthcheck | (Optional) Defines the health check configuration for the issuer. If ommitted, health checks will be enabled and default to 60 seconds. If left disabled, the issuer will not perform a health check when the issuer is healthy and may cause CertificateRequest resources to silently fail. | + | healthcheck.enabled | (Required if health check block provided) Boolean to enable / disable health checks. By default, health checks are enabled. | + | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `60s`. | > If a different combination of hostname/certificate authority/certificate template is required, a new Issuer or ClusterIssuer resource must be created. Each resource instantiation represents a single configuration. @@ -299,7 +301,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required # audience: "https://your-command-url.com" # Uncomment if desired - # healthCheckIntervalSeconds: 60 # Uncomment if desired. Setting to 0 disables health check. + # healthcheck: # Optional health check configuration + # enabled: true + # interval: 30s EOF kubectl -n default apply -f issuer.yaml @@ -332,7 +336,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required # audience: "https://your-command-url.com" # Uncomment if desired - # healthCheckIntervalSeconds: 60 # Uncomment if desired. Setting to 0 disables health check. + # healthcheck: # Optional health check configuration + # enabled: true + # interval: 30s EOF kubectl apply -f clusterissuer.yaml From b7de2e3f2cb81043eba171599d32f727153e785b Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Mon, 27 Oct 2025 12:42:37 -0400 Subject: [PATCH 28/43] chore: Change the specificaiton update to 2.4 Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9539c3a..d4e62fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# v2.3.3 +# v2.4.0 ## Features - Add a `healthcheck` specification to Issuer / ClusterIssuer resources, allowing flexibility in the health check interval. From aec2b8afb99a2a55aaa5bbc39203aa1317546040 Mon Sep 17 00:00:00 2001 From: JSpon <115185500+JSpon@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:51:19 -0500 Subject: [PATCH 29/43] Add the ability to specify the default issuer timeout across all issuers, if not specified. --- cmd/main.go | 17 +++++++++++++++++ .../command-cert-manager-issuer/README.md | 1 + .../templates/deployment.yaml | 3 +++ .../command-cert-manager-issuer/values.yaml | 2 ++ internal/controller/issuer_controller.go | 4 +++- internal/controller/issuer_controller_test.go | 1 + 6 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cmd/main.go b/cmd/main.go index af14a44..6946e45 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -22,6 +22,7 @@ import ( "flag" "fmt" "os" + "time" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -64,6 +65,7 @@ func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string + var healthCheckInterval string var secureMetrics bool var enableHTTP2 bool var clusterResourceNamespace string @@ -80,6 +82,8 @@ func main() { "If set the metrics endpoint is served securely") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") + flag.StringVar(&healthCheckInterval, "default-health-check-interval", "60s", + "If set, it is the default health check interval for issuers.") flag.StringVar(&clusterResourceNamespace, "cluster-resource-namespace", "", "The namespace for secrets in which cluster-scoped resources are found.") flag.BoolVar(&disableApprovedCheck, "disable-approved-check", false, "Disables waiting for CertificateRequests to have an approved condition before signing.") @@ -186,6 +190,17 @@ func main() { os.Exit(1) } + defaultHealthCheckInterval, err := time.ParseDuration(healthCheckInterval) + if err != nil { + setupLog.Error(err, "unable to parse default health check interval") + os.Exit(1) + } + + if defaultHealthCheckInterval < time.Duration(30) * time.Second { + setupLog.Error(err, fmt.Sprintf("interval %s is invalid, must be greater than or equal to '30s'", healthCheckInterval)) + os.Exit(1) + } + if err = (&controller.IssuerReconciler{ Client: mgr.GetClient(), Kind: "Issuer", @@ -193,6 +208,7 @@ func main() { SecretAccessGrantedAtClusterLevel: secretAccessGrantedAtClusterLevel, Scheme: mgr.GetScheme(), HealthCheckerBuilder: command.NewHealthChecker, + DefaultHealthCheckInterval: defaultHealthCheckInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Issuer") os.Exit(1) @@ -204,6 +220,7 @@ func main() { ClusterResourceNamespace: clusterResourceNamespace, SecretAccessGrantedAtClusterLevel: secretAccessGrantedAtClusterLevel, HealthCheckerBuilder: command.NewHealthChecker, + DefaultHealthCheckInterval: defaultHealthCheckInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ClusterIssuer") os.Exit(1) diff --git a/deploy/charts/command-cert-manager-issuer/README.md b/deploy/charts/command-cert-manager-issuer/README.md index 5256fde..b26bb88 100644 --- a/deploy/charts/command-cert-manager-issuer/README.md +++ b/deploy/charts/command-cert-manager-issuer/README.md @@ -84,3 +84,4 @@ The following table lists the configurable parameters of the `command-cert-manag | `nodeSelector` | Node labels for pod assignment | `{}` | | `tolerations` | Tolerations for pod assignment | `[]` | | `secretConfig.useClusterRoleForSecretAccess` | Specifies if the ServiceAccount should be granted access to the Secret resource using a ClusterRole | `false` | +| `defaultHealthCheckInterval` | Specifies the default health check interval for issuers | `""` (uses the default in the code which is 60s) | diff --git a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml index bdbe04f..da388fe 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml @@ -39,6 +39,9 @@ spec: {{- if .Values.secretConfig.useClusterRoleForConfigMapAccess}} - --configmap-access-granted-at-cluster-level {{- end}} + {{- if .Values.defaultHealthCheckInterval }} + - --default-health-check-interval={{ .Values.defaultHealthCheckInterval }} + {{- end }} command: - /manager image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.Version }}" diff --git a/deploy/charts/command-cert-manager-issuer/values.yaml b/deploy/charts/command-cert-manager-issuer/values.yaml index ccb6d96..2abb4af 100644 --- a/deploy/charts/command-cert-manager-issuer/values.yaml +++ b/deploy/charts/command-cert-manager-issuer/values.yaml @@ -79,3 +79,5 @@ resources: {} nodeSelector: {} tolerations: [] + +defaultHealthCheckInterval: "" diff --git a/internal/controller/issuer_controller.go b/internal/controller/issuer_controller.go index 5ce4d1a..8696c39 100644 --- a/internal/controller/issuer_controller.go +++ b/internal/controller/issuer_controller.go @@ -36,7 +36,6 @@ import ( const ( issuerReadyConditionReason = "command-issuer.IssuerController.Reconcile" - defaultHealthCheckInterval = time.Minute ) var ( @@ -46,6 +45,7 @@ var ( errGetCaBundleKey = errors.New("failed to get CA bundle key from CA certificate data") errHealthCheckerBuilder = errors.New("failed to build the healthchecker") errHealthCheckerCheck = errors.New("healthcheck failed") + defaultHealthCheckInterval = time.Minute ) // IssuerReconciler reconciles a Issuer object @@ -56,6 +56,7 @@ type IssuerReconciler struct { SecretAccessGrantedAtClusterLevel bool Scheme *runtime.Scheme HealthCheckerBuilder command.HealthCheckerBuilder + DefaultHealthCheckInterval time.Duration } //+kubebuilder:rbac:groups=command-issuer.keyfactor.com,resources=issuers;clusterissuers,verbs=get;list;watch @@ -69,6 +70,7 @@ func (r *IssuerReconciler) newIssuer() (commandissuer.IssuerLike, error) { if err != nil { return nil, err } + defaultHealthCheckInterval = r.DefaultHealthCheckInterval return ro.(commandissuer.IssuerLike), nil } diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index 83e711e..d611b25 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_test.go @@ -924,6 +924,7 @@ func TestIssuerReconcile(t *testing.T) { HealthCheckerBuilder: tc.healthCheckerBuilder, ClusterResourceNamespace: tc.clusterResourceNamespace, SecretAccessGrantedAtClusterLevel: true, + DefaultHealthCheckInterval: time.Minute, } result, err := controller.Reconcile( ctrl.LoggerInto(context.TODO(), logrtesting.NewTestLogger(t)), From 7452292882b019f455f3ca7437953281aa3f02fe Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 11 Nov 2025 12:01:01 -0500 Subject: [PATCH 30/43] chore: address copilot feedback Signed-off-by: Matthew H. Irby --- api/v1alpha1/issuer_types.go | 2 +- ...ommand-issuer.keyfactor.com_clusterissuers.yaml | 2 +- .../command-issuer.keyfactor.com_issuers.yaml | 2 +- .../templates/crds/clusterissuers.yaml | 2 +- .../templates/crds/issuers.yaml | 2 +- docsource/content.md | 2 +- internal/controller/issuer_controller.go | 14 +++++++------- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index 690ccbf..c192e19 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -299,7 +299,7 @@ const ( ) type HealthCheckConfig struct { - // Determines whether to the health check when the issuer is healthy. Default: true + // Determines whether to enable the health check when the issuer is healthy. Default: true Enabled bool `json:"enabled"` // The interval at which to health check the issuer when healthy. Defaults to 1 minute. Must not be less than "30s". diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index d6ae5b9..79f577d 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -122,7 +122,7 @@ spec: a health check to determine issuer's connectivity to Command instance. properties: enabled: - description: 'Determines whether to the health check when the + description: 'Determines whether to enable the health check when the issuer is healthy. Default: true' type: boolean interval: diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index fbe45e9..bef8308 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -122,7 +122,7 @@ spec: a health check to determine issuer's connectivity to Command instance. properties: enabled: - description: 'Determines whether to the health check when the + description: 'Determines whether to enable the health check when the issuer is healthy. Default: true' type: boolean interval: diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml index 5915eee..7b21d3f 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml @@ -99,7 +99,7 @@ spec: a health check to determine issuer's connectivity to Command instance. properties: enabled: - description: 'Determines whether to the health check when the + description: 'Determines whether to enable the health check when the issuer is healthy. Default: true' type: boolean interval: diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml index 5db21f4..1de4c0c 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml @@ -99,7 +99,7 @@ spec: a health check to determine issuer's connectivity to Command instance. properties: enabled: - description: 'Determines whether to the health check when the + description: 'Determines whether to enable the health check when the issuer is healthy. Default: true' type: boolean interval: diff --git a/docsource/content.md b/docsource/content.md index e7b0f74..4d00e5d 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -233,7 +233,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | - | healthcheck | (Optional) Defines the health check configuration for the issuer. If ommitted, health checks will be enabled and default to 60 seconds. If left disabled, the issuer will not perform a health check when the issuer is healthy and may cause CertificateRequest resources to silently fail. | + | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 60 seconds. If left disabled, the issuer will not perform a health check when the issuer is healthy and may cause CertificateRequest resources to silently fail. | | healthcheck.enabled | (Required if health check block provided) Boolean to enable / disable health checks. By default, health checks are enabled. | | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `60s`. | diff --git a/internal/controller/issuer_controller.go b/internal/controller/issuer_controller.go index 8696c39..8e82980 100644 --- a/internal/controller/issuer_controller.go +++ b/internal/controller/issuer_controller.go @@ -39,12 +39,12 @@ const ( ) var ( - errGetAuthSecret = errors.New("failed to get Secret containing Issuer credentials") - errGetCaConfigMap = errors.New("caBundleConfigMapName specified a name, but failed to get ConfigMap containing CA certificate") - errGetCaSecret = errors.New("caSecretName specified a name, but failed to get Secret containing CA certificate") - errGetCaBundleKey = errors.New("failed to get CA bundle key from CA certificate data") - errHealthCheckerBuilder = errors.New("failed to build the healthchecker") - errHealthCheckerCheck = errors.New("healthcheck failed") + errGetAuthSecret = errors.New("failed to get Secret containing Issuer credentials") + errGetCaConfigMap = errors.New("caBundleConfigMapName specified a name, but failed to get ConfigMap containing CA certificate") + errGetCaSecret = errors.New("caSecretName specified a name, but failed to get Secret containing CA certificate") + errGetCaBundleKey = errors.New("failed to get CA bundle key from CA certificate data") + errHealthCheckerBuilder = errors.New("failed to build the healthchecker") + errHealthCheckerCheck = errors.New("healthcheck failed") defaultHealthCheckInterval = time.Minute ) @@ -104,7 +104,7 @@ func (r *IssuerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res healthCheckInterval, err := getHealthCheckInterval(log, issuer) if err != nil { - log.Error(err, "en error occurred while getting the health check interval") + log.Error(err, "an error occurred while getting the health check interval") issuer.GetStatus().SetCondition(ctx, commandissuer.IssuerConditionReady, commandissuer.ConditionFalse, issuerReadyConditionReason, err.Error()) issuer.GetStatus().SetCondition(ctx, commandissuer.IssuerConditionSupportsMetadata, commandissuer.ConditionUnknown, "", "") return ctrl.Result{}, nil From 2a896581161a5587aad1aa0a4304a629c8a0c3fa Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 11 Nov 2025 12:41:35 -0500 Subject: [PATCH 31/43] chore: fix autogenerated CRDs Signed-off-by: Matthew H. Irby --- .../bases/command-issuer.keyfactor.com_clusterissuers.yaml | 4 ++-- config/crd/bases/command-issuer.keyfactor.com_issuers.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index 79f577d..1c881e4 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -122,8 +122,8 @@ spec: a health check to determine issuer's connectivity to Command instance. properties: enabled: - description: 'Determines whether to enable the health check when the - issuer is healthy. Default: true' + description: 'Determines whether to enable the health check when + the issuer is healthy. Default: true' type: boolean interval: description: The interval at which to health check the issuer diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index bef8308..a556f7e 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -122,8 +122,8 @@ spec: a health check to determine issuer's connectivity to Command instance. properties: enabled: - description: 'Determines whether to enable the health check when the - issuer is healthy. Default: true' + description: 'Determines whether to enable the health check when + the issuer is healthy. Default: true' type: boolean interval: description: The interval at which to health check the issuer From 6ffbe63dcc14e9e8f009a26fa991a8783a912993 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 11 Nov 2025 17:45:53 +0000 Subject: [PATCH 32/43] Update generated docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 66b0d92..b904fa7 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | - | healthcheck | (Optional) Defines the health check configuration for the issuer. If ommitted, health checks will be enabled and default to 60 seconds. If left disabled, the issuer will not perform a health check when the issuer is healthy and may cause CertificateRequest resources to silently fail. | + | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 60 seconds. If left disabled, the issuer will not perform a health check when the issuer is healthy and may cause CertificateRequest resources to silently fail. | | healthcheck.enabled | (Required if health check block provided) Boolean to enable / disable health checks. By default, health checks are enabled. | | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `60s`. | From d8215c1ae0644ee00fb1a4552847584145c4c3ac Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 11 Nov 2025 15:09:37 -0500 Subject: [PATCH 33/43] chore: address github copilot feedback Signed-off-by: Matthew H. Irby --- docsource/content.md | 4 +++- e2e/README.md | 2 +- e2e/run_tests.sh | 12 +++++++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docsource/content.md b/docsource/content.md index 4d00e5d..99cd071 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -112,7 +112,7 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C --create-namespace ``` - You can also install a specific version of the commnad-cert-manager-issuer Helm chart: + You can also install a specific version of the command-cert-manager-issuer Helm chart: ```shell helm search repo command-issuer/command-cert-manager-issuer --versions @@ -125,6 +125,8 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C --create-namespace ``` +> For all possible configuration values for the command-cert-manager-issuer Helm chart, please refer to [this list](./deploy/charts/command-cert-manager-issuer/README.md#configuration) + > The Helm chart installs the Command Issuer CRDs by default. The CRDs can be installed manually with the `make install` target. > A list of configurable Helm chart parameters can be found [in the Helm chart docs](./deploy/charts/command-cert-manager-issuer/README.md#configuration) diff --git a/e2e/README.md b/e2e/README.md index 1e16a71..48b81b4 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -48,7 +48,7 @@ Please place the CA certificates for the Keyfactor Command instance you'd like t ```bash # enable the script to be executed -chmod +x ./run_test.sh +chmod +x ./run_tests.sh # load the environment variables source .env diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index d1095ef..1d3ba30 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -209,7 +209,13 @@ install_cert_manager_issuer() { CHART_PATH="command-issuer/command-cert-manager-issuer" echo "Using Helm chart from repository for version ${HELM_CHART_VERSION}: $CHART_PATH..." - VERSION_PARAM="--version ${HELM_CHART_VERSION} --devel" + + # Only include --devel if HELM_CHART_VERSION is a pre-release (contains -alpha, -beta, -rc, etc.) + if [[ "${HELM_CHART_VERSION}" =~ -alpha|-beta|-rc ]]; then + VERSION_PARAM="--version ${HELM_CHART_VERSION} --devel" + else + VERSION_PARAM="--version ${HELM_CHART_VERSION}" + fi fi # Only set the image repository parameter if we are deploying locally @@ -506,10 +512,10 @@ delete_certificate() { echo "🗑️ Deleting certificate..." if cr_exists $CERTIFICATE_CRD_FQTN "$ISSUER_NAMESPACE" "$CR_C_NAME"; then - echo "Deleting Certificate called $CR_CR_NAME in $ISSUER_NAMESPACE" + echo "Deleting Certificate called $CR_C_NAME in $ISSUER_NAMESPACE" kubectl -n "$ISSUER_NAMESPACE" delete certificate "$CR_C_NAME" else - echo "⚠️ Certificate $CR_CR_NAME not found in $ISSUER_NAMESPACE" + echo "⚠️ Certificate $CR_C_NAME not found in $ISSUER_NAMESPACE" fi } From a5f0e29b526f8c469f88cd45e28b26b3d76f5ac2 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 11 Nov 2025 15:10:04 -0500 Subject: [PATCH 34/43] fix: resolve issue with default health check interval override Signed-off-by: Matthew H. Irby --- internal/controller/issuer_controller.go | 15 ++- internal/controller/issuer_controller_test.go | 103 ++++++++++-------- 2 files changed, 62 insertions(+), 56 deletions(-) diff --git a/internal/controller/issuer_controller.go b/internal/controller/issuer_controller.go index 8e82980..7dbeac6 100644 --- a/internal/controller/issuer_controller.go +++ b/internal/controller/issuer_controller.go @@ -70,7 +70,6 @@ func (r *IssuerReconciler) newIssuer() (commandissuer.IssuerLike, error) { if err != nil { return nil, err } - defaultHealthCheckInterval = r.DefaultHealthCheckInterval return ro.(commandissuer.IssuerLike), nil } @@ -102,7 +101,7 @@ func (r *IssuerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res } }() - healthCheckInterval, err := getHealthCheckInterval(log, issuer) + healthCheckInterval, err := r.getHealthCheckInterval(log, issuer) if err != nil { log.Error(err, "an error occurred while getting the health check interval") issuer.GetStatus().SetCondition(ctx, commandissuer.IssuerConditionReady, commandissuer.ConditionFalse, issuerReadyConditionReason, err.Error()) @@ -159,14 +158,14 @@ func (r *IssuerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res return ctrl.Result{RequeueAfter: healthCheckInterval}, nil } -func getHealthCheckInterval(log logr.Logger, issuer commandissuer.IssuerLike) (time.Duration, error) { +func (r *IssuerReconciler) getHealthCheckInterval(log logr.Logger, issuer commandissuer.IssuerLike) (time.Duration, error) { spec := issuer.GetSpec() - defaultInterval := int(defaultHealthCheckInterval / time.Second) + defaultInterval := int(r.DefaultHealthCheckInterval / time.Second) if spec.HealthCheck == nil { - log.Info(fmt.Sprintf("health check spec value is nil, using default: %d", defaultInterval)) - return defaultHealthCheckInterval, nil + log.Info(fmt.Sprintf("health check spec value is nil, using default: %d seconds", defaultInterval)) + return r.DefaultHealthCheckInterval, nil } if !spec.HealthCheck.Enabled { @@ -175,8 +174,8 @@ func getHealthCheckInterval(log logr.Logger, issuer commandissuer.IssuerLike) (t } if spec.HealthCheck.Interval == nil { - log.Info(fmt.Sprintf("health check spec value is nil, using default: %d", defaultInterval)) - return defaultHealthCheckInterval, nil + log.Info(fmt.Sprintf("health check spec value is nil, using default: %d seconds", defaultInterval)) + return r.DefaultHealthCheckInterval, nil } healthCheckInterval := *spec.HealthCheck.Interval diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index d611b25..27b8cde 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_test.go @@ -68,6 +68,7 @@ func TestIssuerReconcile(t *testing.T) { objects []client.Object healthCheckerBuilder command.HealthCheckerBuilder clusterResourceNamespace string + defaultHealthCheckInterval *time.Duration expectedResult ctrl.Result expectedError error expectedReadyConditionStatus commandissuerv1alpha1.ConditionStatus @@ -115,7 +116,7 @@ func TestIssuerReconcile(t *testing.T) { healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedResult: ctrl.Result{RequeueAfter: defaultHealthCheckInterval}, + expectedResult: ctrl.Result{RequeueAfter: time.Minute}, }, "issuer-basicauth-no-username": { kind: "Issuer", @@ -237,7 +238,7 @@ func TestIssuerReconcile(t *testing.T) { clusterResourceNamespace: "kube-system", expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedResult: ctrl.Result{RequeueAfter: defaultHealthCheckInterval}, + expectedResult: ctrl.Result{RequeueAfter: time.Minute}, }, "success-issuer-oauth": { kind: "Issuer", @@ -278,7 +279,7 @@ func TestIssuerReconcile(t *testing.T) { healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedResult: ctrl.Result{RequeueAfter: defaultHealthCheckInterval}, + expectedResult: ctrl.Result{RequeueAfter: time.Minute}, }, "issuer-oauth-no-tokenurl": { kind: "Issuer", @@ -448,7 +449,7 @@ func TestIssuerReconcile(t *testing.T) { clusterResourceNamespace: "kube-system", expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedResult: ctrl.Result{RequeueAfter: defaultHealthCheckInterval}, + expectedResult: ctrl.Result{RequeueAfter: time.Minute}, }, "issuer-kind-Unrecognized": { kind: "UnrecognizedType", @@ -734,7 +735,7 @@ func TestIssuerReconcile(t *testing.T) { clusterResourceNamespace: "kube-system", expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedResult: ctrl.Result{RequeueAfter: defaultHealthCheckInterval}, + expectedResult: ctrl.Result{RequeueAfter: time.Minute}, }, "success-nil-healthcheck-interval-defaults": { kind: "ClusterIssuer", @@ -778,6 +779,46 @@ func TestIssuerReconcile(t *testing.T) { expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, expectedResult: ctrl.Result{RequeueAfter: time.Duration(60) * time.Second}, }, + "success-default-healthcheck-interval": { + kind: "ClusterIssuer", + name: types.NamespacedName{Name: "clusterissuer1"}, + objects: []client.Object{ + &commandissuerv1alpha1.ClusterIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1", + }, + Spec: commandissuerv1alpha1.IssuerSpec{ + SecretName: "clusterissuer1-credentials", + HealthCheck: nil, + }, + Status: commandissuerv1alpha1.IssuerStatus{ + Conditions: []commandissuerv1alpha1.IssuerCondition{ + { + Type: commandissuerv1alpha1.IssuerConditionReady, + Status: commandissuerv1alpha1.ConditionUnknown, + }, + }, + }, + }, + &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterissuer1-credentials", + Namespace: "kube-system", + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("username"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + }, + }, + defaultHealthCheckInterval: to.Ptr(time.Duration(2) * time.Minute), + healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), + clusterResourceNamespace: "kube-system", + expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedResult: ctrl.Result{RequeueAfter: time.Duration(2) * time.Minute}, + }, "success-nil-healthcheck-defaults": { kind: "ClusterIssuer", name: types.NamespacedName{Name: "clusterissuer1"}, @@ -817,48 +858,6 @@ func TestIssuerReconcile(t *testing.T) { expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, expectedResult: ctrl.Result{RequeueAfter: time.Duration(60) * time.Second}, }, - // "error-healthcheck-invalid-parsing": { - // kind: "Issuer", - // name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, - // objects: []client.Object{ - // &commandissuerv1alpha1.Issuer{ - // ObjectMeta: metav1.ObjectMeta{ - // Name: "issuer1", - // Namespace: "ns1", - // }, - // Spec: commandissuerv1alpha1.IssuerSpec{ - // SecretName: "issuer1-credentials", - // HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ - // Enabled: true, - // Interval: to.Ptr(metav1.Duration{Duration: 30 * time.Second}), - // }, - // }, - // Status: commandissuerv1alpha1.IssuerStatus{ - // Conditions: []commandissuerv1alpha1.IssuerCondition{ - // { - // Type: commandissuerv1alpha1.IssuerConditionReady, - // Status: commandissuerv1alpha1.ConditionUnknown, - // }, - // }, - // }, - // }, - // &corev1.Secret{ - // Type: corev1.SecretTypeBasicAuth, - // ObjectMeta: metav1.ObjectMeta{ - // Name: "issuer1-credentials", - // Namespace: "ns1", - // }, - // Data: map[string][]byte{ - // corev1.BasicAuthUsernameKey: []byte("username"), - // corev1.BasicAuthPasswordKey: []byte("password"), - // }, - // }, - // }, - // healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), - // expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, - // expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionUnknown, - // expectedResult: ctrl.Result{}, - // }, "error-healthcheck-minimum-value": { kind: "Issuer", name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, @@ -917,6 +916,13 @@ func TestIssuerReconcile(t *testing.T) { if tc.kind == "" { tc.kind = "Issuer" } + + defaultHealthcheckInterval := time.Minute + + if tc.defaultHealthCheckInterval != nil { + defaultHealthcheckInterval = *tc.defaultHealthCheckInterval + } + controller := IssuerReconciler{ Kind: tc.kind, Client: fakeClient, @@ -924,8 +930,9 @@ func TestIssuerReconcile(t *testing.T) { HealthCheckerBuilder: tc.healthCheckerBuilder, ClusterResourceNamespace: tc.clusterResourceNamespace, SecretAccessGrantedAtClusterLevel: true, - DefaultHealthCheckInterval: time.Minute, + DefaultHealthCheckInterval: defaultHealthcheckInterval, } + result, err := controller.Reconcile( ctrl.LoggerInto(context.TODO(), logrtesting.NewTestLogger(t)), reconcile.Request{NamespacedName: tc.name}, From 5491e1653919690fa5644b873d4b15d2ec12af1a Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 11 Nov 2025 15:50:09 -0500 Subject: [PATCH 35/43] Update cmd/main.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/main.go b/cmd/main.go index 6946e45..1611f84 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -197,7 +197,7 @@ func main() { } if defaultHealthCheckInterval < time.Duration(30) * time.Second { - setupLog.Error(err, fmt.Sprintf("interval %s is invalid, must be greater than or equal to '30s'", healthCheckInterval)) + 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) } From 15264a44eddb4acb9363b3720ee7d50c9316fe4b Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 12 Nov 2025 14:07:45 -0500 Subject: [PATCH 36/43] chore: fix some typos and serialization per recommendations Signed-off-by: Matthew H. Irby --- api/v1alpha1/issuer_types.go | 2 +- internal/controller/issuer_controller.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index c192e19..e8c4351 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -304,7 +304,7 @@ type HealthCheckConfig struct { // The interval at which to health check the issuer when healthy. Defaults to 1 minute. Must not be less than "30s". // +kubebuilder:validation:Optional - Interval *metav1.Duration `json:"interval"` + Interval *metav1.Duration `json:"interval,omitempty"` } func init() { diff --git a/internal/controller/issuer_controller.go b/internal/controller/issuer_controller.go index 7dbeac6..e234b65 100644 --- a/internal/controller/issuer_controller.go +++ b/internal/controller/issuer_controller.go @@ -174,7 +174,7 @@ func (r *IssuerReconciler) getHealthCheckInterval(log logr.Logger, issuer comman } if spec.HealthCheck.Interval == nil { - log.Info(fmt.Sprintf("health check spec value is nil, using default: %d seconds", defaultInterval)) + log.Info(fmt.Sprintf("health check interval is nil, using default: %d seconds", defaultInterval)) return r.DefaultHealthCheckInterval, nil } From 840879cbda7547c99f8795a519be811ead3012b6 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Wed, 21 Jan 2026 18:03:31 +0000 Subject: [PATCH 37/43] Update generated docs --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b904fa7..c6c50b0 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C --create-namespace ``` - You can also install a specific version of the commnad-cert-manager-issuer Helm chart: + You can also install a specific version of the command-cert-manager-issuer Helm chart: ```shell helm search repo command-issuer/command-cert-manager-issuer --versions @@ -157,6 +157,8 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C --create-namespace ``` +> For all possible configuration values for the command-cert-manager-issuer Helm chart, please refer to [this list](./deploy/charts/command-cert-manager-issuer/README.md#configuration) + > The Helm chart installs the Command Issuer CRDs by default. The CRDs can be installed manually with the `make install` target. > A list of configurable Helm chart parameters can be found [in the Helm chart docs](./deploy/charts/command-cert-manager-issuer/README.md#configuration) From fa32ffa30db0d4a12bd1615a65ead11e239aaea2 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 21 Jan 2026 13:22:56 -0500 Subject: [PATCH 38/43] chore(CHANGELOG): add 2.5.0 changelog Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e62fc..993ccef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# v2.5.0 +## Features +- Add support to specify a ConfigMap for CA trust bundles in Issuer / ClusterIssuer resources via the `caBundleConfigMapName` specification. +- Add support for specifying a key on a Secret / ConfigMap resource for the CA trust bundle via the `caBundleKey` specification on an Issuer / ClusterIssuer resource. +- Add a timeout when fetching ambient Azure credentials to move onto other ambient credential methods. + +## Chores +- Add documentation for how to configure command-cert-manager-issuer with ambient credentials on Google Kubernetes Engine (GKE). +- Add documentation for configuring CA trust bundles via Secret and ConfigMap resources using trust-manager. + # v2.4.0 ## Features - Add a `healthcheck` specification to Issuer / ClusterIssuer resources, allowing flexibility in the health check interval. From 1baab0325d1a4b9511e9f096b2080233627b6be2 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 21 Jan 2026 14:06:40 -0500 Subject: [PATCH 39/43] chore(tests): update key for objects to add to unit tests Signed-off-by: Matthew H. Irby --- internal/controller/issuer_controller_test.go | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index 27b8cde..9e6764d 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_test.go @@ -962,7 +962,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { name string issuerSpec commandissuerv1alpha1.IssuerSpec secretNamespace string - secrets []client.Object + objects []client.Object expectedConfig *command.Config expectedError error expectedErrorMsg string @@ -977,7 +977,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { SecretName: "auth-secret", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeBasicAuth, ObjectMeta: metav1.ObjectMeta{ @@ -1010,7 +1010,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { CaSecretName: "ca-secret", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeBasicAuth, ObjectMeta: metav1.ObjectMeta{ @@ -1030,7 +1030,6 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, Data: map[string][]byte{ "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), - "ca.crt": []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), }, }, }, @@ -1056,7 +1055,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { CaBundleKey: "ca.crt", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeBasicAuth, ObjectMeta: metav1.ObjectMeta{ @@ -1101,7 +1100,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { CaBundleConfigMapName: "ca-configmap", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeBasicAuth, ObjectMeta: metav1.ObjectMeta{ @@ -1119,7 +1118,6 @@ func TestCommandConfigFromIssuer(t *testing.T) { Namespace: "ns1", }, Data: map[string]string{ - "ca.crt": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", "tls.crt": "-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----", }, }, @@ -1146,7 +1144,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { CaBundleKey: "ca.crt", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeBasicAuth, ObjectMeta: metav1.ObjectMeta{ @@ -1191,7 +1189,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { CaBundleConfigMapName: "ca-configmap", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeBasicAuth, ObjectMeta: metav1.ObjectMeta{ @@ -1243,7 +1241,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { SecretName: "oauth-secret", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeOpaque, ObjectMeta: metav1.ObjectMeta{ @@ -1277,7 +1275,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { SecretName: "oauth-secret", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeOpaque, ObjectMeta: metav1.ObjectMeta{ @@ -1316,7 +1314,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { Audience: "https://api.example.com", }, secretNamespace: "ns1", - secrets: []client.Object{}, + objects: []client.Object{}, expectedConfig: &command.Config{ Hostname: "https://ca.example.com", APIPath: "/api/v1", @@ -1331,7 +1329,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { APIPath: "/api/v1", }, secretNamespace: "ns1", - secrets: []client.Object{}, + objects: []client.Object{}, expectedConfig: &command.Config{ Hostname: "https://ca.example.com", APIPath: "/api/v1", @@ -1346,7 +1344,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { SecretName: "missing-secret", }, secretNamespace: "ns1", - secrets: []client.Object{}, + objects: []client.Object{}, expectedError: errGetAuthSecret, }, { @@ -1356,7 +1354,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { CaSecretName: "missing-ca-secret", }, secretNamespace: "ns1", - secrets: []client.Object{}, + objects: []client.Object{}, expectedError: errGetCaSecret, }, { @@ -1367,7 +1365,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { CaBundleKey: "ca.crt", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeOpaque, ObjectMeta: metav1.ObjectMeta{ @@ -1388,7 +1386,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { CaBundleConfigMapName: "missing-ca-bundle", }, secretNamespace: "ns1", - secrets: []client.Object{}, + objects: []client.Object{}, expectedError: errGetCaConfigMap, }, { @@ -1399,7 +1397,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { CaBundleKey: "ca.crt", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "ca-configmap", @@ -1419,7 +1417,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { SecretName: "auth-secret", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeBasicAuth, ObjectMeta: metav1.ObjectMeta{ @@ -1441,7 +1439,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { SecretName: "auth-secret", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeBasicAuth, ObjectMeta: metav1.ObjectMeta{ @@ -1463,7 +1461,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { SecretName: "oauth-secret", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeOpaque, ObjectMeta: metav1.ObjectMeta{ @@ -1486,7 +1484,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { SecretName: "oauth-secret", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeOpaque, ObjectMeta: metav1.ObjectMeta{ @@ -1509,7 +1507,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { SecretName: "oauth-secret", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeOpaque, ObjectMeta: metav1.ObjectMeta{ @@ -1532,7 +1530,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { SecretName: "auth-secret", }, secretNamespace: "ns1", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeTLS, ObjectMeta: metav1.ObjectMeta{ @@ -1555,7 +1553,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { SecretName: "auth-secret", }, secretNamespace: "kube-system", - secrets: []client.Object{ + objects: []client.Object{ &corev1.Secret{ Type: corev1.SecretTypeBasicAuth, ObjectMeta: metav1.ObjectMeta{ @@ -1588,7 +1586,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { t.Run(tc.name, func(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithObjects(tc.secrets...). + WithObjects(tc.objects...). Build() // Create a minimal issuer with the test spec From b1bb554b16efe67ce3e88a879c1b76ff47112617 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Wed, 21 Jan 2026 19:09:43 +0000 Subject: [PATCH 40/43] Update generated docs --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 0ad9786..c6c50b0 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,6 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C ``` > For all possible configuration values for the command-cert-manager-issuer Helm chart, please refer to [this list](./deploy/charts/command-cert-manager-issuer/README.md#configuration) - > For all possible configuration values for the command-cert-manager-issuer Helm chart, please refer to [this list](./deploy/charts/command-cert-manager-issuer/README.md#configuration) > The Helm chart installs the Command Issuer CRDs by default. The CRDs can be installed manually with the `make install` target. From 25ac002192174b52d7e45a5dd091de5b321c89a8 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 21 Jan 2026 14:20:23 -0500 Subject: [PATCH 41/43] chore: address copilot recommendations Signed-off-by: Matthew H. Irby --- api/v1alpha1/issuer_types.go | 2 +- ...command-issuer.keyfactor.com_clusterissuers.yaml | 2 +- .../bases/command-issuer.keyfactor.com_issuers.yaml | 2 +- .../templates/crds/clusterissuers.yaml | 2 +- .../templates/crds/issuers.yaml | 2 +- docs/ambient-providers/google.md | 2 +- docs/ca-bundle/README.md | 2 +- internal/controller/issuer_controller.go | 13 ++++++------- 8 files changed, 13 insertions(+), 14 deletions(-) diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index e8c4351..2b5b669 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -120,7 +120,7 @@ type IssuerSpec struct { // The key in the Secret or ConfigMap containing the CA certificate bundle. // Applies to both caSecretName and caBundleConfigMapName. - // If unspecifed, the last key alphabetically in the Secret or ConfigMap data will be used. + // If unspecified, the last key alphabetically in the Secret or ConfigMap data will be used. // +optional CaBundleKey string `json:"caBundleKey,omitempty"` diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index 1c881e4..33f2b32 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -63,7 +63,7 @@ spec: description: |- The key in the Secret or ConfigMap containing the CA certificate bundle. Applies to both caSecretName and caBundleConfigMapName. - If unspecifed, the last key alphabetically in the Secret or ConfigMap data will be used. + If unspecified, the last key alphabetically in the Secret or ConfigMap data will be used. type: string caSecretName: description: |- diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index a556f7e..27db089 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -63,7 +63,7 @@ spec: description: |- The key in the Secret or ConfigMap containing the CA certificate bundle. Applies to both caSecretName and caBundleConfigMapName. - If unspecifed, the last key alphabetically in the Secret or ConfigMap data will be used. + If unspecified, the last key alphabetically in the Secret or ConfigMap data will be used. type: string caSecretName: description: |- diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml index 7b21d3f..4206341 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml @@ -57,7 +57,7 @@ spec: description: |- The key in the Secret or ConfigMap containing the CA certificate bundle. Applies to both caSecretName and caBundleConfigMapName. - If unspecifed, the last key alphabetically in the Secret or ConfigMap data will be used. + If unspecified, the last key alphabetically in the Secret or ConfigMap data will be used. type: string caSecretName: description: |- diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml index 1de4c0c..efb2dea 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml @@ -57,7 +57,7 @@ spec: description: |- The key in the Secret or ConfigMap containing the CA certificate bundle. Applies to both caSecretName and caBundleConfigMapName. - If unspecifed, the last key alphabetically in the Secret or ConfigMap data will be used. + If unspecified, the last key alphabetically in the Secret or ConfigMap data will be used. type: string caSecretName: description: |- diff --git a/docs/ambient-providers/google.md b/docs/ambient-providers/google.md index da9feb5..407f6a0 100644 --- a/docs/ambient-providers/google.md +++ b/docs/ambient-providers/google.md @@ -261,7 +261,7 @@ For the below steps, configure your environment variables: ```bash # Get project-level metadata -export PROJECT_ID=$(gcloud config get project) # use "gcloud projects list" to get a list of projects and "gcloud config set project " to set the project +export PROJECT_ID=$(gcloud config get project) # use "gcloud projects list" to get a list of projects and "gcloud config set project " to set the project export PROJECT_NUMBER=$(gcloud projects describe ${PROJECT_ID} \ --format="value(projectNumber)") diff --git a/docs/ca-bundle/README.md b/docs/ca-bundle/README.md index 4ddcca5..6492a53 100644 --- a/docs/ca-bundle/README.md +++ b/docs/ca-bundle/README.md @@ -238,7 +238,7 @@ Due to Kubernetes constraints, writing to secrets outside of trust-manager's nam EOF ``` -4b. Create a namepaced Role for trust-manager +4b. Create a namespaced Role for trust-manager For each namespace that trust-manager should sync secrets to, create a role that allows trust-manager to write secrets diff --git a/internal/controller/issuer_controller.go b/internal/controller/issuer_controller.go index e234b65..aabbb2a 100644 --- a/internal/controller/issuer_controller.go +++ b/internal/controller/issuer_controller.go @@ -39,13 +39,12 @@ const ( ) var ( - errGetAuthSecret = errors.New("failed to get Secret containing Issuer credentials") - errGetCaConfigMap = errors.New("caBundleConfigMapName specified a name, but failed to get ConfigMap containing CA certificate") - errGetCaSecret = errors.New("caSecretName specified a name, but failed to get Secret containing CA certificate") - errGetCaBundleKey = errors.New("failed to get CA bundle key from CA certificate data") - errHealthCheckerBuilder = errors.New("failed to build the healthchecker") - errHealthCheckerCheck = errors.New("healthcheck failed") - defaultHealthCheckInterval = time.Minute + errGetAuthSecret = errors.New("failed to get Secret containing Issuer credentials") + errGetCaConfigMap = errors.New("caBundleConfigMapName specified a name, but failed to get ConfigMap containing CA certificate") + errGetCaSecret = errors.New("caSecretName specified a name, but failed to get Secret containing CA certificate") + errGetCaBundleKey = errors.New("failed to get CA bundle key from CA certificate data") + errHealthCheckerBuilder = errors.New("failed to build the healthchecker") + errHealthCheckerCheck = errors.New("healthcheck failed") ) // IssuerReconciler reconciles a Issuer object From 98a01f7bfb4f6d4137665ae20e72aef4fe447e7d Mon Sep 17 00:00:00 2001 From: JSpon <115185500+JSpon@users.noreply.github.com> Date: Tue, 30 Sep 2025 11:05:22 -0400 Subject: [PATCH 42/43] Add environmental variables to deployment for use with proxy configuration --- deploy/charts/command-cert-manager-issuer/README.md | 1 + .../command-cert-manager-issuer/templates/deployment.yaml | 4 ++++ deploy/charts/command-cert-manager-issuer/values.yaml | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/deploy/charts/command-cert-manager-issuer/README.md b/deploy/charts/command-cert-manager-issuer/README.md index b26bb88..0d64052 100644 --- a/deploy/charts/command-cert-manager-issuer/README.md +++ b/deploy/charts/command-cert-manager-issuer/README.md @@ -83,5 +83,6 @@ The following table lists the configurable parameters of the `command-cert-manag | `resources` | CPU/Memory resource requests/limits | `{}` (with commented out options) | | `nodeSelector` | Node labels for pod assignment | `{}` | | `tolerations` | Tolerations for pod assignment | `[]` | +| `env` | Environmental variables set for pod | `{}` | | `secretConfig.useClusterRoleForSecretAccess` | Specifies if the ServiceAccount should be granted access to the Secret resource using a ClusterRole | `false` | | `defaultHealthCheckInterval` | Specifies the default health check interval for issuers | `""` (uses the default in the code which is 60s) | diff --git a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml index da388fe..34e3bd1 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml @@ -44,6 +44,10 @@ spec: {{- end }} command: - /manager + {{- with .Values.env }} + env: + {{- toYaml . | nindent 12 }} + {{- end }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.Version }}" imagePullPolicy: {{ .Values.image.pullPolicy }} livenessProbe: diff --git a/deploy/charts/command-cert-manager-issuer/values.yaml b/deploy/charts/command-cert-manager-issuer/values.yaml index 2abb4af..c63b5c6 100644 --- a/deploy/charts/command-cert-manager-issuer/values.yaml +++ b/deploy/charts/command-cert-manager-issuer/values.yaml @@ -81,3 +81,10 @@ nodeSelector: {} tolerations: [] defaultHealthCheckInterval: "" + +env: {} + # This can be used to set an http proxy to access the Keyfactor instance + # - name: https_proxy + # value: http://someproxy:someport + # - name: no_proxy + # value: .somedomain.com,.local,10.0.0.1 From ae2b3e8753b358604a41d58992e5364fa658dc46 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 21 Jan 2026 14:35:18 -0500 Subject: [PATCH 43/43] chore(CHANGELOG): add env variable support to changelog Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 993ccef..c78ea9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Add support to specify a ConfigMap for CA trust bundles in Issuer / ClusterIssuer resources via the `caBundleConfigMapName` specification. - Add support for specifying a key on a Secret / ConfigMap resource for the CA trust bundle via the `caBundleKey` specification on an Issuer / ClusterIssuer resource. - Add a timeout when fetching ambient Azure credentials to move onto other ambient credential methods. +- Ability to specify environment variables on issuer deployment to set additional configuration options (i.e. HTTP proxy settings, etc.) ## Chores - Add documentation for how to configure command-cert-manager-issuer with ambient credentials on Google Kubernetes Engine (GKE).