diff --git a/api/v1alpha1/function_lifecycle.go b/api/v1alpha1/function_lifecycle.go index 78341b0..ce5ce89 100644 --- a/api/v1alpha1/function_lifecycle.go +++ b/api/v1alpha1/function_lifecycle.go @@ -219,3 +219,17 @@ func (f *Function) MarkServiceNotReady(reason, messageFormat string, messageA .. ObservedGeneration: f.Generation, }) } + +// History helpers + +const MaxHistoryEntries = 20 + +func (f *Function) RecordHistoryEvent(message string) { + f.Status.History = append([]FunctionStatusHistoryEntry{{ + Time: metav1.Now(), + Message: message, + }}, f.Status.History...) + if len(f.Status.History) > MaxHistoryEntries { + f.Status.History = f.Status.History[:MaxHistoryEntries] + } +} diff --git a/api/v1alpha1/function_lifecycle_test.go b/api/v1alpha1/function_lifecycle_test.go index e913103..cb34964 100644 --- a/api/v1alpha1/function_lifecycle_test.go +++ b/api/v1alpha1/function_lifecycle_test.go @@ -1,12 +1,107 @@ package v1alpha1 import ( + "fmt" "testing" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +func TestRecordHistoryEvent(t *testing.T) { + tests := []struct { + name string + existingHistory []FunctionStatusHistoryEntry + newMessage string + expectedLen int + expectedFirst string + expectedLast string + }{ + { + name: "adds event to empty history", + existingHistory: nil, + newMessage: "Function deployed", + expectedLen: 1, + expectedFirst: "Function deployed", + expectedLast: "Function deployed", + }, + { + name: "prepends event to existing history", + existingHistory: []FunctionStatusHistoryEntry{ + {Time: metav1.Now(), Message: "Older event"}, + }, + newMessage: "Newer event", + expectedLen: 2, + expectedFirst: "Newer event", + expectedLast: "Older event", + }, + { + name: "trims oldest entries when exceeding max", + existingHistory: func() []FunctionStatusHistoryEntry { + entries := make([]FunctionStatusHistoryEntry, MaxHistoryEntries) + for i := range entries { + entries[i] = FunctionStatusHistoryEntry{ + Time: metav1.Now(), + Message: fmt.Sprintf("Event %d", i), + } + } + return entries + }(), + newMessage: "Overflow event", + expectedLen: MaxHistoryEntries, + expectedFirst: "Overflow event", + expectedLast: fmt.Sprintf("Event %d", MaxHistoryEntries-2), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &Function{} + f.Status.History = tt.existingHistory + + f.RecordHistoryEvent(tt.newMessage) + + if len(f.Status.History) != tt.expectedLen { + t.Errorf("expected %d entries, got %d", tt.expectedLen, len(f.Status.History)) + } + if f.Status.History[0].Message != tt.expectedFirst { + t.Errorf("expected first message %q, got %q", tt.expectedFirst, f.Status.History[0].Message) + } + if f.Status.History[len(f.Status.History)-1].Message != tt.expectedLast { + t.Errorf("expected last message %q, got %q", tt.expectedLast, f.Status.History[len(f.Status.History)-1].Message) + } + }) + } +} + +func TestRecordHistoryEventFIFOOrder(t *testing.T) { + f := &Function{} + + for i := 0; i < MaxHistoryEntries+5; i++ { + f.RecordHistoryEvent(fmt.Sprintf("Event %d", i)) + } + + if len(f.Status.History) != MaxHistoryEntries { + t.Fatalf("expected %d entries, got %d", MaxHistoryEntries, len(f.Status.History)) + } + + for i, entry := range f.Status.History { + expected := fmt.Sprintf("Event %d", MaxHistoryEntries+4-i) + if entry.Message != expected { + t.Errorf("entry %d: expected message %q, got %q", i, expected, entry.Message) + } + } +} + +func TestRecordHistoryEventSetsTime(t *testing.T) { + f := &Function{} + f.RecordHistoryEvent("test event") + + if f.Status.History[0].Time.IsZero() { + t.Error("expected non-zero time") + } +} + func TestCalculateReadyCondition(t *testing.T) { tests := []struct { name string diff --git a/api/v1alpha1/function_types.go b/api/v1alpha1/function_types.go index cb7af2b..9d60958 100644 --- a/api/v1alpha1/function_types.go +++ b/api/v1alpha1/function_types.go @@ -76,9 +76,15 @@ type FunctionStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty"` - Git FunctionStatusGit `json:"git,omitempty"` - Deployment FunctionStatusDeployment `json:"deployment,omitempty"` - Middleware FunctionStatusMiddleware `json:"middleware,omitempty"` + Git FunctionStatusGit `json:"git,omitempty"` + Deployment FunctionStatusDeployment `json:"deployment,omitempty"` + Middleware FunctionStatusMiddleware `json:"middleware,omitempty"` + History []FunctionStatusHistoryEntry `json:"history,omitempty"` +} + +type FunctionStatusHistoryEntry struct { + Time metav1.Time `json:"time"` + Message string `json:"message"` } type FunctionStatusGit struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 98aa827..087cf55 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -160,6 +160,13 @@ func (in *FunctionStatus) DeepCopyInto(out *FunctionStatus) { in.Git.DeepCopyInto(&out.Git) in.Deployment.DeepCopyInto(&out.Deployment) in.Middleware.DeepCopyInto(&out.Middleware) + if in.History != nil { + in, out := &in.History, &out.History + *out = make([]FunctionStatusHistoryEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionStatus. @@ -204,6 +211,22 @@ func (in *FunctionStatusGit) DeepCopy() *FunctionStatusGit { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FunctionStatusHistoryEntry) DeepCopyInto(out *FunctionStatusHistoryEntry) { + *out = *in + in.Time.DeepCopyInto(&out.Time) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionStatusHistoryEntry. +func (in *FunctionStatusHistoryEntry) DeepCopy() *FunctionStatusHistoryEntry { + if in == nil { + return nil + } + out := new(FunctionStatusHistoryEntry) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FunctionStatusMiddleware) DeepCopyInto(out *FunctionStatusMiddleware) { *out = *in diff --git a/config/crd/bases/functions.dev_functions.yaml b/config/crd/bases/functions.dev_functions.yaml index ec422b9..8c3ffa5 100644 --- a/config/crd/bases/functions.dev_functions.yaml +++ b/config/crd/bases/functions.dev_functions.yaml @@ -187,6 +187,19 @@ spec: resolvedBranch: type: string type: object + history: + items: + properties: + message: + type: string + time: + format: date-time + type: string + required: + - message + - time + type: object + type: array middleware: properties: autoUpdate: diff --git a/internal/controller/function_controller.go b/internal/controller/function_controller.go index 7841113..112e8d7 100644 --- a/internal/controller/function_controller.go +++ b/internal/controller/function_controller.go @@ -266,6 +266,8 @@ func (r *FunctionReconciler) handleMiddlewareUpdate(ctx context.Context, functio function.Status.Middleware.PendingRebuild = false function.Status.Middleware.LastRebuild = metav1.Now() + function.RecordHistoryEvent(fmt.Sprintf("Middleware updated from %q to %q", functionDescribe.Middleware.Version, latestMiddleware)) + // After successful deployment, middleware is now up-to-date function.MarkMiddlewareUpToDate() function.Status.Middleware.Available = nil // if function is on latest, we don't need to show this field diff --git a/internal/controller/function_controller_test.go b/internal/controller/function_controller_test.go index a4bf56b..4532712 100644 --- a/internal/controller/function_controller_test.go +++ b/internal/controller/function_controller_test.go @@ -354,6 +354,49 @@ var _ = Describe("Function Controller", func() { Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) }, }), + Entry("should record history event when middleware is redeployed", reconcileTestCase{ + spec: defaultSpec, + configureMocks: func(funcMock *funccli.MockManager, gitMock *git.MockManager) { + funcMock.EXPECT().Describe(mock.Anything, functionName, resourceNamespace).Return(functions.Instance{ + Middleware: functions.Middleware{ + Version: "v1.0.0", + }, + }, nil) + funcMock.EXPECT().GetLatestMiddlewareVersion(mock.Anything, mock.Anything, mock.Anything).Return("v2.0.0", nil) + funcMock.EXPECT().GetMiddlewareVersion(mock.Anything, functionName, resourceNamespace).Return("v1.0.0", nil) + funcMock.EXPECT().Deploy(mock.Anything, mock.Anything, resourceNamespace, funccli.DeployOptions{}).Return(nil) + + gitMock.EXPECT().CloneRepository(mock.Anything, "https://github.com/foo/bar", "", "my-branch", mock.Anything).Return(createTmpGitRepo(functions.Function{Name: "func-go"}), nil) + }, + statusChecks: func(status *functionsdevv1alpha1.FunctionStatus) { + messages := make([]string, len(status.History)) + for i, entry := range status.History { + messages[i] = entry.Message + } + Expect(messages).To(ContainElement(`Middleware updated from "v1.0.0" to "v2.0.0"`)) + }, + }), + Entry("should not record middleware history event when middleware is already up to date", reconcileTestCase{ + spec: defaultSpec, + configureMocks: func(funcMock *funccli.MockManager, gitMock *git.MockManager) { + funcMock.EXPECT().Describe(mock.Anything, functionName, resourceNamespace).Return(functions.Instance{ + Middleware: functions.Middleware{ + Version: "v1.0.0", + }, + }, nil) + funcMock.EXPECT().GetLatestMiddlewareVersion(mock.Anything, mock.Anything, mock.Anything).Return("v1.0.0", nil) + funcMock.EXPECT().GetMiddlewareVersion(mock.Anything, functionName, resourceNamespace).Return("v1.0.0", nil) + + gitMock.EXPECT().CloneRepository(mock.Anything, "https://github.com/foo/bar", "", "my-branch", mock.Anything).Return(createTmpGitRepo(functions.Function{Name: "func-go"}), nil) + }, + statusChecks: func(status *functionsdevv1alpha1.FunctionStatus) { + messages := make([]string, len(status.History)) + for i, entry := range status.History { + messages[i] = entry.Message + } + Expect(messages).ToNot(ContainElement(ContainSubstring("Middleware updated"))) + }, + }), Entry("should set ServiceReady condition to false with unknown reason when ready status is empty", reconcileTestCase{ spec: defaultSpec, configureMocks: func(funcMock *funccli.MockManager, gitMock *git.MockManager) {