feat(approval-expiration): approvals can now expire and notifications are sent before they do#387
feat(approval-expiration): approvals can now expire and notifications are sent before they do#387julius-malcovsky wants to merge 4 commits into
Conversation
… are sent before they do Co-authored-by: Björn Kottner <BjoernKarma@users.noreply.github.com> Co-authored-by: Ismael Garba <iagarba@users.noreply.github.com> Co-authored-by: Stefan Siber <stefan-ctrl@users.noreply.github.com> Co-authored-by: Ron Gummich <ron96g@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces an approval-expiration feature by adding an Expired approval state, a new ApprovalExpiration CRD/controller to drive expiration, and reminder-notification plumbing so expiring approvals can notify stakeholders ahead of time.
Changes:
- Add
ApprovalExpirationCRD + controller/handler to track expiration dates, trigger expiration, and send reminders. - Extend the Approval FSM/API types to support
Expiredand a system-onlyExpireaction (plus re-approval fromExpired). - Add notification placeholders + a reminder notification sender, and introduce an env-driven expiration config loader.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
approval/internal/webhook/v1/approval_webhook.go |
Adds webhook validation intended to block manual transitions to Expired. |
approval/internal/webhook/v1/approval_webhook_test.go |
Adds tests around the new “system-only expire” validation behavior. |
approval/internal/handler/util/notification.go |
Adds reminder-specific placeholders, fields, and a SendReminderNotification helper. |
approval/internal/handler/approvalexpiration/handler.go |
Implements the core expiration/reminder logic for ApprovalExpiration objects. |
approval/internal/handler/approvalexpiration/handler_test.go |
Unit tests for reminder eligibility and next-reconcile scheduling logic. |
approval/internal/handler/approval/handler.go |
Integrates expiration lifecycle management into the Approval handler and hides system-only transitions from status. |
approval/internal/handler/approval/fsm.go |
Extends FSM transitions to include Expire and allow actions from Expired. |
approval/internal/controller/suite_test.go |
Registers the new ApprovalExpirationReconciler in the controller test suite. |
approval/internal/controller/approvalexpiration_controller.go |
Adds the new controller reconciler wiring and RBAC markers. |
approval/internal/controller/approvalexpiration_controller_test.go |
Integration tests for Approval↔ApprovalExpiration lifecycle behavior. |
approval/internal/config/expiration.go |
Adds env-backed expiration/reminder configuration loading. |
approval/internal/condition/condition.go |
Adds an “Expired” condition helper. |
approval/config/rbac/role.yaml |
Grants RBAC permissions for approvalexpirations resources. |
approval/config/crd/bases/approval.cp.ei.telekom.de_approvals.yaml |
Updates the Approval CRD enum to include Expired. |
approval/config/crd/bases/approval.cp.ei.telekom.de_approvalexpirations.yaml |
Introduces the new ApprovalExpiration CRD manifest. |
approval/cmd/main.go |
Loads expiration config and registers the new controller. |
approval/api/v1/zz_generated.deepcopy.go |
Adds autogenerated deepcopy functions for ApprovalExpiration types. |
approval/api/v1/common_types.go |
Adds the Expire action constant (and related type support). |
approval/api/v1/builder/builder.go |
Treats Expired approvals as non-provisionable (denied) during build decisions. |
approval/api/v1/approvalexpiration_types.go |
Adds API types for the new ApprovalExpiration resource. |
approval/api/v1/approval_types.go |
Extends kubebuilder validation enums to include the Expired state. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // validateExpireTransition blocks manual transitions to EXPIRED state (system-only action) | ||
| func validateExpireTransition(newObj *approvalv1.Approval) error { | ||
| if newObj.Spec.State != approvalv1.ApprovalStateExpired { | ||
| return nil | ||
| } | ||
|
|
||
| // Check if this is a legitimate system transition | ||
| for _, decision := range newObj.Spec.Decisions { | ||
| if decision.Name == "System" && decision.ResultingState == approvalv1.ApprovalStateExpired { | ||
| return nil // Valid system transition | ||
| } | ||
| } | ||
|
|
||
| return apierrors.NewBadRequest("Expire action is system-only and cannot be triggered manually") |
| daysRemaining = 0 | ||
| case now.After(ae.Spec.DailyReminder.Time): | ||
| reminderType = "daily" | ||
| daysRemaining = int64(ae.Spec.Expiration.Time.Sub(now).Hours() / 24) |
There was a problem hiding this comment.
optional: this looks as something what can be in "utils" package.
ron96g
left a comment
There was a problem hiding this comment.
Looks solid. Just a few questions/open points.
Add some diagram that explains the logic in more details? Maybe inside of the README similiar to the other digrams there.
| if approvalExists { | ||
| log.V(2).Info("Approval exists") | ||
| isDenied := b.Approval.Spec.State == v1.ApprovalStateRejected || b.Approval.Spec.State == v1.ApprovalStateSuspended | ||
| isDenied := b.Approval.Spec.State == v1.ApprovalStateRejected || |
There was a problem hiding this comment.
I know that you explained it in the Review but is it really the goal to enforce this? Currently its just purely informational
| Expiration metav1.Time `json:"expiration"` | ||
|
|
||
| // WeeklyReminder is the date from which weekly reminders start | ||
| WeeklyReminder metav1.Time `json:"weeklyReminder"` |
There was a problem hiding this comment.
Can we use the reminder logic in common for this? That was implemented for graceful-client-secret-rotation?
| LastWeeksWithDailyReminder: viper.GetInt("EXPIRATION_DAILY_REMINDER_WEEKS"), | ||
| } | ||
|
|
||
| if config.ExpirationPeriodMonths <= 0 { |
There was a problem hiding this comment.
This could be done using https://github.com/go-playground/validator, however, the bigger question here is, can we integrate this into the common-config logic? The story to align config-management is still in the backlog, but can we already think about this?
| ) | ||
|
|
||
| // ExpirationConfig holds the configuration for approval expiration | ||
| type ExpirationConfig struct { |
There was a problem hiding this comment.
Is this something we want to configure via deployment config or rather via Environment? So that these values are set in the Environment CR and we read it from there instead from some configmap?
|
|
||
| // SetupWithManager sets up the controller with the Manager. | ||
| func (r *ApprovalExpirationReconciler) SetupWithManager(mgr ctrl.Manager) error { | ||
| r.Recorder = mgr.GetEventRecorderFor("approvalexpiration-controller") |
There was a problem hiding this comment.
This API is deprecated and we must migrate at some point. Maybe already use the new one for newer controllers? Dont forget about the kubebuilder:rbac annotation
|
|
||
| // filterSystemActions removes system-only actions (Expire) from the available transitions | ||
| func filterSystemActions(transitions approvalv1.AvailableTransitions) approvalv1.AvailableTransitions { | ||
| filtered := make(approvalv1.AvailableTransitions, 0, len(transitions)) |
There was a problem hiding this comment.
This could allocate +1 slots if Expire is not added
| func NewExpiredCondition() metav1.Condition { | ||
| return metav1.Condition{ | ||
| Type: "Approved", | ||
| Status: metav1.ConditionTrue, |
|
|
||
| // Check if this is a legitimate system transition | ||
| for _, decision := range newObj.Spec.Decisions { | ||
| if decision.Name == "System" && decision.ResultingState == approvalv1.ApprovalStateExpired { |
There was a problem hiding this comment.
Should "System" be a const somewhere?
| // validateExpireTransition blocks manual transitions to EXPIRED state (system-only action) | ||
| func validateExpireTransition(newObj *approvalv1.Approval) error { | ||
| if newObj.Spec.State != approvalv1.ApprovalStateExpired { | ||
| return nil | ||
| } | ||
|
|
||
| // Check if this is a legitimate system transition | ||
| for _, decision := range newObj.Spec.Decisions { | ||
| if decision.Name == "System" && decision.ResultingState == approvalv1.ApprovalStateExpired { | ||
| return nil // Valid system transition | ||
| } | ||
| } | ||
|
|
||
| return apierrors.NewBadRequest("Expire action is system-only and cannot be triggered manually") |
Co-authored-by: Björn Kottner <BjoernKarma@users.noreply.github.com> Co-authored-by: Ismael Garba <iagarba@users.noreply.github.com> Co-authored-by: Stefan Siber <stefan-ctrl@users.noreply.github.com> Co-authored-by: Ron Gummich <ron96g@users.noreply.github.com>
Notification templates will be added in a separate request