Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions controller/deploy/operator/api/v1alpha1/jumpstarter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,13 @@ type IssuerReference struct {
// Only change this if using a custom issuer from a different API group.
// +kubebuilder:default="cert-manager.io"
Group string `json:"group,omitempty"`

// CABundle is an optional base64-encoded PEM CA certificate bundle for this issuer.
// Required when using external issuers with non-publicly-trusted CAs.
// This will be published to the {name}-service-ca-cert ConfigMap for clients to use.
// For self-signed CA mode, this is automatically populated from the CA secret.
// +optional
CABundle []byte `json:"caBundle,omitempty"`
}

// JumpstarterStatus defines the observed state of Jumpstarter.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ metadata:
}
]
capabilities: Basic Install
createdAt: "2026-01-30T11:40:29Z"
createdAt: "2026-02-03T10:30:49Z"
operators.operatorframework.io/builder: operator-sdk-v1.41.1
operators.operatorframework.io/project_layout: go.kubebuilder.io/v4
name: jumpstarter-operator.v0.8.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,14 @@ spec:
Use this to integrate with existing PKI infrastructure (ACME, Vault, etc.).
This overrides SelfSigned.Enabled = true which is the default setting
properties:
caBundle:
description: |-
CABundle is an optional base64-encoded PEM CA certificate bundle for this issuer.
Required when using external issuers with non-publicly-trusted CAs.
This will be published to the {name}-service-ca-cert ConfigMap for clients to use.
For self-signed CA mode, this is automatically populated from the CA secret.
format: byte
type: string
group:
default: cert-manager.io
description: |-
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,14 @@ spec:
Use this to integrate with existing PKI infrastructure (ACME, Vault, etc.).
This overrides SelfSigned.Enabled = true which is the default setting
properties:
caBundle:
description: |-
CABundle is an optional base64-encoded PEM CA certificate bundle for this issuer.
Required when using external issuers with non-publicly-trusted CAs.
This will be published to the {name}-service-ca-cert ConfigMap for clients to use.
For self-signed CA mode, this is automatically populated from the CA secret.
format: byte
type: string
group:
default: cert-manager.io
description: |-
Expand Down
8 changes: 8 additions & 0 deletions controller/deploy/operator/dist/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,14 @@ spec:
Use this to integrate with existing PKI infrastructure (ACME, Vault, etc.).
This overrides SelfSigned.Enabled = true which is the default setting
properties:
caBundle:
description: |-
CABundle is an optional base64-encoded PEM CA certificate bundle for this issuer.
Required when using external issuers with non-publicly-trusted CAs.
This will be published to the {name}-service-ca-cert ConfigMap for clients to use.
For self-signed CA mode, this is automatically populated from the CA secret.
format: byte
type: string
group:
default: cert-manager.io
description: |-
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import (
certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1"
operatorv1alpha1 "github.com/jumpstarter-dev/jumpstarter-controller/deploy/operator/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
logf "sigs.k8s.io/controller-runtime/pkg/log"
)
Expand All @@ -42,6 +45,9 @@ const (
caCertificateSuffix = "-ca"
controllerCertSuffix = "-controller-tls"
routerCertSuffix = "-router-%d-tls"

// CA ConfigMap naming
caConfigMapSuffix = "-service-ca-cert"
)

// getServerCertDurationSettings
Expand Down Expand Up @@ -73,6 +79,12 @@ func getServerCertDurationSettings(js *operatorv1alpha1.Jumpstarter) (time.Durat
func (r *JumpstarterReconciler) reconcileCertificates(ctx context.Context, js *operatorv1alpha1.Jumpstarter) error {
log := logf.FromContext(ctx)

// Always reconcile the CA ConfigMap first - this ensures cleanup during config transitions
// and provides the CA bundle for clients regardless of certificate reconciliation state
if err := r.reconcileCAConfigMap(ctx, js); err != nil {
return fmt.Errorf("failed to reconcile CA ConfigMap: %w", err)
}

if !js.Spec.CertManager.Enabled {
// If cert-manager integration is disabled, skip certificate reconciliation
// we do not remove existing certificates or issuers here,
Expand Down Expand Up @@ -462,6 +474,107 @@ func (r *JumpstarterReconciler) reconcileCertificateResource(ctx context.Context
return nil
}

// reconcileCAConfigMap creates or updates the CA certificate ConfigMap.
// This ConfigMap contains the CA bundle that clients can use to verify TLS connections.
// The ConfigMap is ALWAYS created to ensure proper cleanup during configuration transitions.
// This configmap is used by jmp admin cli when creating exporters or clients.
func (r *JumpstarterReconciler) reconcileCAConfigMap(ctx context.Context, js *operatorv1alpha1.Jumpstarter) error {
log := logf.FromContext(ctx)

// fixed name because we only support one "jumpstater" per namespace, and
// we want to ensure that the CA configmap is findable by jmp admin cli
// when creating exporters or clients
configMapName := "jumpstarter" + caConfigMapSuffix
caCert := ""

// If cert-manager is disabled, create empty ConfigMap
if !js.Spec.CertManager.Enabled {
log.V(1).Info("cert-manager disabled, creating empty CA ConfigMap")
} else if js.Spec.CertManager.Server != nil && js.Spec.CertManager.Server.IssuerRef != nil {
// External issuer mode
if len(js.Spec.CertManager.Server.IssuerRef.CABundle) > 0 {
// Use provided CA bundle
caCert = string(js.Spec.CertManager.Server.IssuerRef.CABundle)
log.V(1).Info("Using CA bundle from external issuer configuration")
} else {
// External issuer without CA bundle - leave empty (publicly trusted CA)
log.V(1).Info("External issuer without CA bundle, creating empty CA ConfigMap")
}
} else {
// Self-signed CA mode - read from CA secret
selfSignedEnabled := true
if js.Spec.CertManager.Server != nil && js.Spec.CertManager.Server.SelfSigned != nil {
selfSignedEnabled = js.Spec.CertManager.Server.SelfSigned.Enabled
}

if selfSignedEnabled {
caSecretName := js.Name + caCertificateSuffix
caSecret := &corev1.Secret{}
err := r.Client.Get(ctx, types.NamespacedName{
Name: caSecretName,
Namespace: js.Namespace,
}, caSecret)
if err != nil {
if !apierrors.IsNotFound(err) {
// Transient/API/RBAC error - return to requeue and retry
return fmt.Errorf("failed to get CA secret %s: %w", caSecretName, err)
}
// CA secret doesn't exist yet - this is expected during initial setup
// The ConfigMap will be updated once the CA certificate is ready
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the follow up PR we wait until this is really ready before creating the configmap to avoid the service from having a "blank" CA bundle injected. In this PR we still don't use it from jumpstarter-controller, only jmp admin, so it's not a problem really, but on the later patch we start using this from jumpstarter-controller and we can't populate empty so you will see this change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying this part!

log.V(1).Info("CA secret not found, creating empty CA ConfigMap", "secret", caSecretName)
} else if cert, ok := caSecret.Data["tls.crt"]; ok {
caCert = string(cert)
log.V(1).Info("Using CA certificate from self-signed CA secret", "secret", caSecretName)
} else {
log.V(1).Info("CA secret missing tls.crt key, creating empty CA ConfigMap", "secret", caSecretName)
}
} else {
// Self-signed disabled and no external issuer - leave empty
log.V(1).Info("Self-signed CA disabled, creating empty CA ConfigMap")
}
}

// Create the ConfigMap
labels := map[string]string{
"app": js.Name,
"app.kubernetes.io/managed-by": "jumpstarter-operator",
}

desiredConfigMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: configMapName,
Namespace: js.Namespace,
Labels: labels,
},
Data: map[string]string{
"ca.crt": caCert,
},
}

existingConfigMap := &corev1.ConfigMap{}
existingConfigMap.Name = desiredConfigMap.Name
existingConfigMap.Namespace = desiredConfigMap.Namespace

op, err := controllerutil.CreateOrUpdate(ctx, r.Client, existingConfigMap, func() error {
existingConfigMap.Labels = desiredConfigMap.Labels
existingConfigMap.Data = desiredConfigMap.Data
return controllerutil.SetControllerReference(js, existingConfigMap, r.Scheme)
})

if err != nil {
return fmt.Errorf("failed to reconcile CA ConfigMap %s: %w", configMapName, err)
}

log.Info("CA ConfigMap reconciled", "name", configMapName, "operation", op, "hasCA", caCert != "")
return nil
}

// GetCAConfigMapName returns the name of the CA certificate ConfigMap.
// The name is fixed to "jumpstarter-service-ca-cert" for discoverability by jmp admin cli.
func GetCAConfigMapName(js *operatorv1alpha1.Jumpstarter) string {
return "jumpstarter" + caConfigMapSuffix
}

// GetControllerCertSecretName returns the name of the controller TLS secret.
func GetControllerCertSecretName(js *operatorv1alpha1.Jumpstarter) string {
return js.Name + controllerCertSuffix
Expand Down
50 changes: 50 additions & 0 deletions controller/deploy/operator/test/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,36 @@ spec:
verifyTLSSecret(certManagerTestNamespace, routerCertName)
})

It("should create the CA ConfigMap with the CA certificate", func() {
By("verifying the CA ConfigMap was created with the correct CA certificate")
caConfigMapName := "jumpstarter-service-ca-cert"
caSecretName := jumpstarterName + "-ca"

Eventually(func(g Gomega) {
// Get the CA ConfigMap
cm := &corev1.ConfigMap{}
err := k8sClient.Get(ctx, types.NamespacedName{
Name: caConfigMapName,
Namespace: certManagerTestNamespace,
}, cm)
g.Expect(err).NotTo(HaveOccurred())

// Get the CA secret to compare
caSecret := &corev1.Secret{}
err = k8sClient.Get(ctx, types.NamespacedName{
Name: caSecretName,
Namespace: certManagerTestNamespace,
}, caSecret)
g.Expect(err).NotTo(HaveOccurred())

// Verify the CA ConfigMap contains the CA certificate from the secret
g.Expect(cm.Data).To(HaveKey("ca.crt"))
g.Expect(cm.Data["ca.crt"]).NotTo(BeEmpty(), "CA ConfigMap should contain the CA certificate")
g.Expect(cm.Data["ca.crt"]).To(Equal(string(caSecret.Data["tls.crt"])),
"CA ConfigMap should contain the same certificate as the CA secret")
}, 2*time.Minute).Should(Succeed())
})

It("should mount TLS certificates in controller deployment", func() {
By("waiting for controller deployment to be available with TLS mount")
controllerDeploymentName := jumpstarterName + "-controller"
Expand Down Expand Up @@ -1180,6 +1210,26 @@ spec:
verifyTLSSecret(externalIssuerTestNamespace, routerCertName)
})

It("should create an empty CA ConfigMap for external issuer without CABundle", func() {
By("verifying the CA ConfigMap was created but is empty (external issuer without CABundle)")
// Fixed name for discoverability by jmp admin cli
caConfigMapName := "jumpstarter-service-ca-cert"

Eventually(func(g Gomega) {
cm := &corev1.ConfigMap{}
err := k8sClient.Get(ctx, types.NamespacedName{
Name: caConfigMapName,
Namespace: externalIssuerTestNamespace,
}, cm)
g.Expect(err).NotTo(HaveOccurred())

// Verify the CA ConfigMap exists but ca.crt is empty (publicly trusted CA)
g.Expect(cm.Data).To(HaveKey("ca.crt"))
g.Expect(cm.Data["ca.crt"]).To(BeEmpty(),
"CA ConfigMap should be empty for external issuer without CABundle")
}, 1*time.Minute).Should(Succeed())
})

AfterAll(func() {
DeleteTestNamespace(externalIssuerTestNamespace)
_ = deleteSelfSignedClusterIssuer(clusterIssuerName)
Expand Down
28 changes: 28 additions & 0 deletions e2e/tests.bats
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,34 @@ EOF
jmp shell --selector example.com/board=oidc j power on
}

@test "legacy client config contains CA certificate and works with secure TLS" {
# This test only works with operator-based deployment, which creates the CA ConfigMap
if [ "${METHOD:-}" != "operator" ]; then
skip "CA certificate injection only available with operator deployment (METHOD=$METHOD)"
fi

wait_for_exporter

# Get the config file path from jmp (clients are saved to ~/.config/jumpstarter/clients/)
local config_file="${HOME}/.config/jumpstarter/clients/test-client-legacy.yaml"
run test -f "$config_file"
assert_success

# Check that tls.ca field exists and is not empty
run go run github.com/mikefarah/yq/v4@latest '.tls.ca' "$config_file"
assert_success
# The CA should be a non-empty base64-encoded string
refute_output ""
refute_output "null"

# Test that the client works WITHOUT JUMPSTARTER_GRPC_INSECURE set
# This proves the CA certificate is being used for TLS verification
run env -u JUMPSTARTER_GRPC_INSECURE jmp get exporters --client test-client-legacy
assert_success
# Should see the legacy exporter in the output
assert_output --partial "test-exporter-legacy"
}

@test "can operate on leases" {
wait_for_exporter

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .util import AbstractAsyncCustomObjectApi
from jumpstarter.config.client import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers
from jumpstarter.config.common import ObjectMeta
from jumpstarter.config.tls import TLSConfigV1Alpha1

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -147,6 +148,8 @@ async def get_client_config(self, name: str, allow: list[str], unsafe=False) ->
secret = await self.core_api.read_namespaced_secret(client.status.credential.name, self.namespace)
endpoint = client.status.endpoint
token = base64.b64decode(secret.data["token"]).decode("utf8")
# Get CA bundle from ConfigMap (base64-encoded)
ca_bundle = await self.get_ca_bundle()
return ClientConfigV1Alpha1(
alias=name,
metadata=ObjectMeta(
Expand All @@ -156,6 +159,7 @@ async def get_client_config(self, name: str, allow: list[str], unsafe=False) ->
endpoint=endpoint,
token=token,
drivers=ClientConfigV1Alpha1Drivers(allow=allow, unsafe=unsafe),
tls=TLSConfigV1Alpha1(ca=ca_bundle),
)

async def delete_client(self, name: str):
Expand Down
Loading
Loading