Skip to content
Open
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
8 changes: 6 additions & 2 deletions controller/api/v1alpha1/exporter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,16 @@ type ExporterStatus struct {
ExporterStatusValue string `json:"exporterStatus,omitempty"`
// StatusMessage is an optional human-readable message describing the current state
StatusMessage string `json:"statusMessage,omitempty"`
// TokenExpiresAt is the expiry time of the internal credential token.
TokenExpiresAt *metav1.Time `json:"tokenExpiresAt,omitempty"`
}

type ExporterConditionType string

const (
ExporterConditionTypeRegistered ExporterConditionType = "Registered"
ExporterConditionTypeOnline ExporterConditionType = "Online"
ExporterConditionTypeRegistered ExporterConditionType = "Registered"
ExporterConditionTypeOnline ExporterConditionType = "Online"
ExporterConditionTypeTokenExpiring ExporterConditionType = "TokenExpiring"
)

// ExporterStatus values - PascalCase for Kubernetes, converted from proto ALL_CAPS
Expand All @@ -78,6 +81,7 @@ const (
// +kubebuilder:printcolumn:name="Enabled",type="boolean",JSONPath=".spec.enabled"
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.exporterStatus"
// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.statusMessage",priority=1
// +kubebuilder:printcolumn:name="Token Expires",type="string",JSONPath=".status.tokenExpiresAt"

// Exporter is the Schema for the exporters API
type Exporter struct {
Expand Down
4 changes: 4 additions & 0 deletions controller/api/v1alpha1/zz_generated.deepcopy.go

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 @@ -25,6 +25,9 @@ spec:
name: Message
priority: 1
type: string
- jsonPath: .status.tokenExpiresAt
name: Token Expires
type: string
name: v1alpha1
schema:
openAPIV3Schema:
Expand Down Expand Up @@ -203,6 +206,11 @@ spec:
description: StatusMessage is an optional human-readable message describing
the current state
type: string
tokenExpiresAt:
description: TokenExpiresAt is the expiry time of the internal credential
token.
format: date-time
type: string
type: object
type: object
served: true
Expand Down
36 changes: 31 additions & 5 deletions controller/internal/controller/exporter_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,11 @@ func (r *ExporterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c

prevOnline := meta.IsStatusConditionTrue(exporter.Status.Conditions, string(jumpstarterdevv1alpha1.ExporterConditionTypeOnline))
prevRegistered := meta.IsStatusConditionTrue(exporter.Status.Conditions, string(jumpstarterdevv1alpha1.ExporterConditionTypeRegistered))
prevCredential := exporter.Status.Credential
prevTokenExpiring := meta.IsStatusConditionTrue(exporter.Status.Conditions, string(jumpstarterdevv1alpha1.ExporterConditionTypeTokenExpiring))

if err := r.reconcileStatusCredential(ctx, &exporter); err != nil {
tokenExpiry, err := r.reconcileStatusCredential(ctx, &exporter)
if err != nil {
return ctrl.Result{}, err
}

Expand All @@ -92,6 +95,14 @@ func (r *ExporterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return ctrl.Result{}, err
}

reconcileTokenExpiry(
&exporter.Status.Conditions,
&exporter.Status.TokenExpiresAt,
tokenExpiry,
exporter.Generation,
string(jumpstarterdevv1alpha1.ExporterConditionTypeTokenExpiring),
)

newOnline := meta.IsStatusConditionTrue(exporter.Status.Conditions, string(jumpstarterdevv1alpha1.ExporterConditionTypeOnline))
newRegistered := meta.IsStatusConditionTrue(exporter.Status.Conditions, string(jumpstarterdevv1alpha1.ExporterConditionTypeRegistered))

Expand All @@ -100,6 +111,17 @@ func (r *ExporterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
}

// Emit only after status patch succeeds.
if prevCredential == nil && exporter.Status.Credential != nil {
r.emitEventf(&exporter, corev1.EventTypeNormal, "CredentialCreated",
"Credential secret created for exporter: secret=%s", exporter.Status.Credential.Name)
}

newTokenExpiring := meta.IsStatusConditionTrue(exporter.Status.Conditions, string(jumpstarterdevv1alpha1.ExporterConditionTypeTokenExpiring))
if !prevTokenExpiring && newTokenExpiring {
r.emitEventf(&exporter, corev1.EventTypeWarning, "TokenExpiringSoon",
"Token for exporter %s is expiring soon: expires=%s", exporter.Name, tokenExpiry.UTC().Format(time.RFC3339))
}

if !prevRegistered && newRegistered {
r.emitEventf(&exporter, corev1.EventTypeNormal, "ExporterRegistered",
"Exporter registered its capabilities: deviceCount=%d", len(exporter.Status.Devices))
Expand All @@ -125,24 +147,28 @@ func (r *ExporterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
}
}

if result.RequeueAfter == 0 || result.RequeueAfter > tokenExpiryRequeueInterval {
result.RequeueAfter = tokenExpiryRequeueInterval
}

return result, nil
}

func (r *ExporterReconciler) reconcileStatusCredential(
ctx context.Context,
exporter *jumpstarterdevv1alpha1.Exporter,
) error {
secret, _, err := ensureSecret(ctx, client.ObjectKey{
) (time.Time, error) {
secret, expiry, err := ensureSecret(ctx, client.ObjectKey{
Name: exporter.Name + "-exporter",
Namespace: exporter.Namespace,
}, r.Client, r.Scheme, r.Signer, exporter.InternalSubject(), exporter)
if err != nil {
return fmt.Errorf("reconcileStatusCredential: failed to prepare credential for exporter: %w", err)
return time.Time{}, fmt.Errorf("reconcileStatusCredential: failed to prepare credential for exporter: %w", err)
}
exporter.Status.Credential = &corev1.LocalObjectReference{
Name: secret.Name,
}
return nil
return expiry, nil
}

func (r *ExporterReconciler) reconcileStatusLeaseRef(
Expand Down
121 changes: 118 additions & 3 deletions controller/internal/controller/exporter_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ package controller

import (
"context"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

Expand Down Expand Up @@ -88,12 +90,20 @@ var _ = Describe("Exporter Controller", func() {
Signer: signer,
}

_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{
res, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())
// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
// Example: If you expect a certain status condition after reconciliation, verify it here.
Expect(res.RequeueAfter).To(Equal(tokenExpiryRequeueInterval))

exporter := &jumpstarterdevv1alpha1.Exporter{}
Expect(k8sClient.Get(ctx, typeNamespacedName, exporter)).To(Succeed())
Expect(exporter.Status.TokenExpiresAt).NotTo(BeNil())

cond := meta.FindStatusCondition(exporter.Status.Conditions, string(jumpstarterdevv1alpha1.ExporterConditionTypeTokenExpiring))
Expect(cond).NotTo(BeNil())
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
Expect(cond.Reason).To(Equal("Valid"))
})
It("should reconcile a missing token secret", func() {
By("recreating the secret")
Expand Down Expand Up @@ -125,5 +135,110 @@ var _ = Describe("Exporter Controller", func() {
Name: resourceName + "-exporter",
}, secret)).To(Succeed())
})
It("should report TokenExpiring condition when token is expiring soon", func() {
By("reconciling an exporter with a near-expiry token")
signer, err := oidc.NewSignerFromSeed([]byte{}, "https://example.com", "dummy")
Expect(err).NotTo(HaveOccurred())

controllerReconciler := &ExporterReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
Signer: signer,
}

// First reconcile to create the secret
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

// Modify secret with near-expiry token (5 days remaining)
nearExpirySigner, err := oidc.NewSignerFromSeed([]byte{}, "https://example.com", "dummy")
Expect(err).NotTo(HaveOccurred())
nearExpirySigner.SetTokenLifetime(5 * 24 * time.Hour)
nearExpiryToken, err := nearExpirySigner.Token("exporter-test")
Expect(err).NotTo(HaveOccurred())

secret := &corev1.Secret{}
Expect(k8sClient.Get(ctx, types.NamespacedName{
Namespace: "default",
Name: resourceName + "-exporter",
}, secret)).To(Succeed())

secret.Data[TokenKey] = []byte(nearExpiryToken)
Expect(k8sClient.Update(ctx, secret)).To(Succeed())

// Reconcile again
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

// Verify secret token remains intact so alerts can fire
Expect(k8sClient.Get(ctx, types.NamespacedName{
Namespace: "default",
Name: resourceName + "-exporter",
}, secret)).To(Succeed())
Expect(string(secret.Data[TokenKey])).To(Equal(nearExpiryToken))

// Verify exporter status condition is True with ExpiresWithin7Days
exporter := &jumpstarterdevv1alpha1.Exporter{}
Expect(k8sClient.Get(ctx, typeNamespacedName, exporter)).To(Succeed())
Expect(exporter.Status.TokenExpiresAt).NotTo(BeNil())

cond := meta.FindStatusCondition(exporter.Status.Conditions, string(jumpstarterdevv1alpha1.ExporterConditionTypeTokenExpiring))
Expect(cond).NotTo(BeNil())
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
Expect(cond.Reason).To(Equal("ExpiresWithin7Days"))
})

It("should reconcile an invalid token secret", func() {
By("recreating an invalid secret")
signer, err := oidc.NewSignerFromSeed([]byte{}, "https://example.com", "dummy")
Expect(err).NotTo(HaveOccurred())

controllerReconciler := &ExporterReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
Signer: signer,
}

// First reconcile to create the secret
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

// Corrupt the secret
secret := &corev1.Secret{}
Expect(k8sClient.Get(ctx, types.NamespacedName{
Namespace: "default",
Name: resourceName + "-exporter",
}, secret)).To(Succeed())

secret.Data[TokenKey] = []byte("invalid-token")
Expect(k8sClient.Update(ctx, secret)).To(Succeed())

// Reconcile
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

// Verify secret was recreated with a valid token
Expect(k8sClient.Get(ctx, types.NamespacedName{
Namespace: "default",
Name: resourceName + "-exporter",
}, secret)).To(Succeed())
Expect(string(secret.Data[TokenKey])).NotTo(Equal("invalid-token"))

// Verify condition is Valid
exporter := &jumpstarterdevv1alpha1.Exporter{}
Expect(k8sClient.Get(ctx, typeNamespacedName, exporter)).To(Succeed())
cond := meta.FindStatusCondition(exporter.Status.Conditions, string(jumpstarterdevv1alpha1.ExporterConditionTypeTokenExpiring))
Expect(cond).NotTo(BeNil())
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
Expect(cond.Reason).To(Equal("Valid"))
})
})
})
Loading
Loading