diff --git a/controllers/codebase/service/chain/common.go b/controllers/codebase/service/chain/common.go index 89acf1f..466a56e 100644 --- a/controllers/codebase/service/chain/common.go +++ b/controllers/codebase/service/chain/common.go @@ -40,3 +40,34 @@ func setIntermediateSuccessFields(ctx context.Context, c client.Client, cb *code return nil } + +// updateGitStatusWithPatch updates the codebase Git status using Patch instead of Update. +// If a conflict occurs, the function returns an error, causing the reconciliation +// to requeue automatically via the controller-runtime framework. +func updateGitStatusWithPatch( + ctx context.Context, + c client.Client, + codebase *codebaseApi.Codebase, + action codebaseApi.ActionType, + gitStatus string, +) error { + // Skip update if status already matches (idempotency check) + if codebase.Status.Git == gitStatus { + return nil + } + + // Create patch based on current object state + patch := client.MergeFrom(codebase.DeepCopy()) + + // Modify the status field + codebase.Status.Git = gitStatus + + // Apply patch to status subresource + if err := c.Status().Patch(ctx, codebase, patch); err != nil { + setFailedFields(codebase, action, err.Error()) + return fmt.Errorf("failed to patch git status to %s for codebase %s: %w", + gitStatus, codebase.Name, err) + } + + return nil +} diff --git a/controllers/codebase/service/chain/common_test.go b/controllers/codebase/service/chain/common_test.go new file mode 100644 index 0000000..a2c561e --- /dev/null +++ b/controllers/codebase/service/chain/common_test.go @@ -0,0 +1,250 @@ +package chain + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + codebaseApi "github.com/epam/edp-codebase-operator/v2/api/v1" + "github.com/epam/edp-codebase-operator/v2/pkg/util" +) + +func TestUpdateGitStatusWithPatch_Success(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, codebaseApi.AddToScheme(scheme)) + + codebase := &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: fakeName, + Namespace: fakeNamespace, + }, + Status: codebaseApi.CodebaseStatus{ + Git: "", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(codebase). + WithStatusSubresource(codebase). + Build() + + err := updateGitStatusWithPatch( + context.Background(), + fakeClient, + codebase, + codebaseApi.RepositoryProvisioning, + util.ProjectPushedStatus, + ) + + assert.NoError(t, err) + assert.Equal(t, util.ProjectPushedStatus, codebase.Status.Git) + + // Verify status was actually patched in the cluster + updated := &codebaseApi.Codebase{} + err = fakeClient.Get(context.Background(), types.NamespacedName{ + Name: fakeName, + Namespace: fakeNamespace, + }, updated) + assert.NoError(t, err) + assert.Equal(t, util.ProjectPushedStatus, updated.Status.Git) +} + +func TestUpdateGitStatusWithPatch_Idempotent(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, codebaseApi.AddToScheme(scheme)) + + codebase := &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: fakeName, + Namespace: fakeNamespace, + }, + Status: codebaseApi.CodebaseStatus{ + Git: util.ProjectPushedStatus, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(codebase). + WithStatusSubresource(codebase). + Build() + + err := updateGitStatusWithPatch( + context.Background(), + fakeClient, + codebase, + codebaseApi.RepositoryProvisioning, + util.ProjectPushedStatus, + ) + + assert.NoError(t, err) + assert.Equal(t, util.ProjectPushedStatus, codebase.Status.Git) +} + +func TestUpdateGitStatusWithPatch_StatusTransition(t *testing.T) { + // Setup + scheme := runtime.NewScheme() + require.NoError(t, codebaseApi.AddToScheme(scheme)) + + codebase := &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: fakeName, + Namespace: fakeNamespace, + }, + Status: codebaseApi.CodebaseStatus{ + Git: util.ProjectPushedStatus, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(codebase). + WithStatusSubresource(codebase). + Build() + + // Execute - transition from ProjectPushedStatus to ProjectGitLabCIPushedStatus + err := updateGitStatusWithPatch( + context.Background(), + fakeClient, + codebase, + codebaseApi.RepositoryProvisioning, + util.ProjectGitLabCIPushedStatus, + ) + + // Verify + assert.NoError(t, err) + assert.Equal(t, util.ProjectGitLabCIPushedStatus, codebase.Status.Git) + + // Verify in cluster + updated := &codebaseApi.Codebase{} + err = fakeClient.Get(context.Background(), types.NamespacedName{ + Name: fakeName, + Namespace: fakeNamespace, + }, updated) + assert.NoError(t, err) + assert.Equal(t, util.ProjectGitLabCIPushedStatus, updated.Status.Git) +} + +func TestUpdateGitStatusWithPatch_PreservesOtherStatusFields(t *testing.T) { + // Setup + scheme := runtime.NewScheme() + require.NoError(t, codebaseApi.AddToScheme(scheme)) + + codebase := &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: fakeName, + Namespace: fakeNamespace, + }, + Status: codebaseApi.CodebaseStatus{ + Git: "", + WebHookRef: "webhook-123", + GitWebUrl: "https://git.example.com/repo", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(codebase). + WithStatusSubresource(codebase). + Build() + + // Execute + err := updateGitStatusWithPatch( + context.Background(), + fakeClient, + codebase, + codebaseApi.RepositoryProvisioning, + util.ProjectPushedStatus, + ) + + // Verify - only Git field changed, others preserved + assert.NoError(t, err) + assert.Equal(t, util.ProjectPushedStatus, codebase.Status.Git) + assert.Equal(t, "webhook-123", codebase.Status.WebHookRef) + assert.Equal(t, "https://git.example.com/repo", codebase.Status.GitWebUrl) + + // Verify in cluster + updated := &codebaseApi.Codebase{} + err = fakeClient.Get(context.Background(), types.NamespacedName{ + Name: fakeName, + Namespace: fakeNamespace, + }, updated) + assert.NoError(t, err) + assert.Equal(t, util.ProjectPushedStatus, updated.Status.Git) + assert.Equal(t, "webhook-123", updated.Status.WebHookRef) + assert.Equal(t, "https://git.example.com/repo", updated.Status.GitWebUrl) +} + +func TestUpdateGitStatusWithPatch_SequentialUpdates(t *testing.T) { + // Setup - This test simulates the chain handler scenario + scheme := runtime.NewScheme() + require.NoError(t, codebaseApi.AddToScheme(scheme)) + + codebase := &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: fakeName, + Namespace: fakeNamespace, + }, + Status: codebaseApi.CodebaseStatus{ + Git: "", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(codebase). + WithStatusSubresource(codebase). + Build() + + ctx := context.Background() + + // Simulate sequential updates as in chain handlers + // 1. PutProject sets ProjectPushedStatus + err := updateGitStatusWithPatch( + ctx, + fakeClient, + codebase, + codebaseApi.RepositoryProvisioning, + util.ProjectPushedStatus, + ) + assert.NoError(t, err) + assert.Equal(t, util.ProjectPushedStatus, codebase.Status.Git) + + // 2. PutGitLabCIConfig sets ProjectGitLabCIPushedStatus + err = updateGitStatusWithPatch( + ctx, + fakeClient, + codebase, + codebaseApi.RepositoryProvisioning, + util.ProjectGitLabCIPushedStatus, + ) + assert.NoError(t, err) + assert.Equal(t, util.ProjectGitLabCIPushedStatus, codebase.Status.Git) + + // 3. PutDeployConfigs sets ProjectTemplatesPushedStatus + err = updateGitStatusWithPatch( + ctx, + fakeClient, + codebase, + codebaseApi.SetupDeploymentTemplates, + util.ProjectTemplatesPushedStatus, + ) + assert.NoError(t, err) + assert.Equal(t, util.ProjectTemplatesPushedStatus, codebase.Status.Git) + + // Verify final state in cluster + updated := &codebaseApi.Codebase{} + err = fakeClient.Get(ctx, types.NamespacedName{ + Name: fakeName, + Namespace: fakeNamespace, + }, updated) + assert.NoError(t, err) + assert.Equal(t, util.ProjectTemplatesPushedStatus, updated.Status.Git) +} diff --git a/controllers/codebase/service/chain/put_deploy_configs.go b/controllers/codebase/service/chain/put_deploy_configs.go index 66f7a33..2e1f30d 100644 --- a/controllers/codebase/service/chain/put_deploy_configs.go +++ b/controllers/codebase/service/chain/put_deploy_configs.go @@ -145,9 +145,8 @@ func (h *PutDeployConfigs) tryToPushConfigs(ctx context.Context, codebase *codeb log.Info("Changes have been pushed") - codebase.Status.Git = util.ProjectTemplatesPushedStatus - if err = h.client.Status().Update(ctx, codebase); err != nil { - return fmt.Errorf("failed to set git status %s for codebase %s: %w", util.ProjectTemplatesPushedStatus, codebase.Name, err) + if err = updateGitStatusWithPatch(ctx, h.client, codebase, codebaseApi.SetupDeploymentTemplates, util.ProjectTemplatesPushedStatus); err != nil { + return err } log.Info("Config has been pushed") diff --git a/controllers/codebase/service/chain/put_gitlab_ci_config.go b/controllers/codebase/service/chain/put_gitlab_ci_config.go index f78c811..a0f21f0 100644 --- a/controllers/codebase/service/chain/put_gitlab_ci_config.go +++ b/controllers/codebase/service/chain/put_gitlab_ci_config.go @@ -58,11 +58,8 @@ func (h *PutGitLabCIConfig) ServeRequest(ctx context.Context, codebase *codebase log.Info("End pushing GitLab CI config") // Set status to mark this stage complete - codebase.Status.Git = util.ProjectGitLabCIPushedStatus - if err := h.client.Status().Update(ctx, codebase); err != nil { - setFailedFields(codebase, codebaseApi.RepositoryProvisioning, err.Error()) - return fmt.Errorf("failed to set git status %s for codebase %s: %w", - util.ProjectGitLabCIPushedStatus, codebase.Name, err) + if err := updateGitStatusWithPatch(ctx, h.client, codebase, codebaseApi.RepositoryProvisioning, util.ProjectGitLabCIPushedStatus); err != nil { + return err } log.Info("GitLab CI status has been set successfully") diff --git a/controllers/codebase/service/chain/put_project.go b/controllers/codebase/service/chain/put_project.go index 35e7ac8..7f4a38d 100644 --- a/controllers/codebase/service/chain/put_project.go +++ b/controllers/codebase/service/chain/put_project.go @@ -92,10 +92,8 @@ func (h *PutProject) ServeRequest(ctx context.Context, codebase *codebaseApi.Cod return fmt.Errorf("failed to create project: %w", err) } - codebase.Status.Git = util.ProjectPushedStatus - if err = h.client.Status().Update(ctx, codebase); err != nil { - setFailedFields(codebase, codebaseApi.RepositoryProvisioning, err.Error()) - return fmt.Errorf("failed to set git status %s for codebase %s: %w", util.ProjectPushedStatus, codebase.Name, err) + if err = updateGitStatusWithPatch(ctx, h.client, codebase, codebaseApi.RepositoryProvisioning, util.ProjectPushedStatus); err != nil { + return err } log.Info("Finish putting project")