diff --git a/controllers/codebase/service/chain/put_project.go b/controllers/codebase/service/chain/put_project.go index df960b07..7bdaf242 100644 --- a/controllers/codebase/service/chain/put_project.go +++ b/controllers/codebase/service/chain/put_project.go @@ -54,7 +54,6 @@ func NewPutProject( } } -// ServeRequest is a method to put project into git repository. // TODO: Refactor this method to smaller methods. Currently it is too big and complex. func (h *PutProject) ServeRequest(ctx context.Context, codebase *codebaseApi.Codebase) error { log := ctrl.LoggerFrom(ctx).WithValues("projectID", codebase.Spec.GetProjectID()) @@ -75,6 +74,17 @@ func (h *PutProject) ServeRequest(ctx context.Context, codebase *codebaseApi.Cod return h.handleError(codebase, err, "failed to get git repository context") } + adopted, err := h.adoptPushedProject(ctx, codebase, repoContext) + if err != nil { + return h.handleError(codebase, err, "failed to adopt already pushed project") + } + + if adopted { + log.Info("Finish putting project (adopted previously pushed state)") + + return nil + } + err = h.initialProjectProvisioning(ctx, codebase, repoContext) if err != nil { return h.handleError( @@ -124,7 +134,6 @@ func (*PutProject) skip(ctx context.Context, codebase *codebaseApi.Codebase) boo return false } -// handleError sets failed fields on codebase and returns formatted error. func (*PutProject) handleError(codebase *codebaseApi.Codebase, err error, message string) error { setFailedFields(codebase, codebaseApi.RepositoryProvisioning, err.Error()) return fmt.Errorf("%s: %w", message, err) @@ -146,7 +155,40 @@ func (h *PutProject) createProject( } } - err := h.pushProject(ctx, codebase.Spec.GetProjectID(), repoContext) + // The remote branch, not local state, is the durable record of a push: a + // retry after any later failure (default-branch call, status patch, + // operator crash) regenerates history, and pushing it would silently + // replace the remote branch. Record intent only when the remote default + // branch is verified absent, so that on retry "in progress + branch + // present" can only mean our own push landed. + remoteBranchAbsent, err := h.remoteDefaultBranchAbsent(ctx, codebase, repoContext) + if err != nil { + return err + } + + if !remoteBranchAbsent { + // go-git cannot verify fast-forward when the remote's current commit + // is absent from the freshly regenerated local history, so this push + // would silently replace the remote branch. Refuse instead of + // destroying history that this codebase provably did not just push. + return fmt.Errorf( + "remote repository already contains default branch %s with history not pushed by this provisioning; "+ + "refusing to overwrite it - remove the remote branch or onboard the repository with the import strategy", + codebase.Spec.DefaultBranch, + ) + } + + if err = updateGitStatusWithPatch( + ctx, + h.k8sClient, + codebase, + codebaseApi.RepositoryProvisioning, + util.ProjectPushInProgressStatus, + ); err != nil { + return err + } + + err = h.pushProject(ctx, codebase.Spec.GetProjectID(), repoContext) if err != nil { return err } @@ -159,6 +201,91 @@ func (h *PutProject) createProject( return nil } +// remoteDefaultBranchAbsent reports whether the codebase default branch does +// not exist on the remote project (an empty or missing repository counts as +// absent). Transport failures propagate as errors: guessing "absent" on a +// network blip would regenerate history against a remote that may already +// hold the previous push. +func (h *PutProject) remoteDefaultBranchAbsent( + ctx context.Context, + codebase *codebaseApi.Codebase, + repoContext *GitRepositoryContext, +) (bool, error) { + gitProvider := h.gitProviderFactory( + gitproviderv2.NewConfigFromGitServerAndSecret( + repoContext.GitServer, + repoContext.GitServerSecret, + ), + ) + + repoURL := util.GetProjectGitUrl(repoContext.GitServer, repoContext.GitServerSecret, codebase.Spec.GetProjectID()) + + _, err := gitProvider.ResolveRemoteReference(ctx, repoURL, codebase.Spec.DefaultBranch) + if err == nil { + return false, nil + } + + if errors.Is(err, gitproviderv2.ErrReferenceNotFound) { + return true, nil + } + + return false, fmt.Errorf("failed to check remote default branch: %w", err) +} + +// adoptPushedProject resumes provisioning wedged between a successful push and +// the final status patch. ProjectPushInProgressStatus is set only after the +// remote default branch was verified absent, so finding it present now proves +// the interrupted push landed: skip regeneration and re-run only the +// idempotent default-branch setup. A still-absent branch means the push never +// completed and full provisioning must run again. +func (h *PutProject) adoptPushedProject( + ctx context.Context, + codebase *codebaseApi.Codebase, + repoContext *GitRepositoryContext, +) (bool, error) { + if codebase.Status.Git != util.ProjectPushInProgressStatus { + return false, nil + } + + log := ctrl.LoggerFrom(ctx) + log.Info("Push was in progress, checking whether it landed on the remote") + + absent, err := h.remoteDefaultBranchAbsent(ctx, codebase, repoContext) + if err != nil { + return false, err + } + + if absent { + log.Info("Interrupted push never landed, re-running full provisioning") + + return false, nil + } + + log.Info("Interrupted push landed on the remote, adopting it") + + if err = h.setDefaultBranch( + ctx, + repoContext.GitServer, + codebase, + repoContext.Token, + repoContext.PrivateSSHKey, + ); err != nil { + return false, err + } + + if err = updateGitStatusWithPatch( + ctx, + h.k8sClient, + codebase, + codebaseApi.RepositoryProvisioning, + util.ProjectPushedStatus, + ); err != nil { + return false, err + } + + return true, nil +} + func (h *PutProject) replaceDefaultBranch( ctx context.Context, g gitproviderv2.Git, @@ -550,6 +677,13 @@ func (h *PutProject) emptyProjectProvisioning( log.Info("Initialing empty git repository") + // A workdir surviving a partial earlier attempt would make Init fail with + // "repository already exists"; regenerating is safe because this path only + // runs when nothing reached the remote yet. + if err := os.RemoveAll(filepath.Join(repoContext.WorkDir, ".git")); err != nil { + return fmt.Errorf("failed to remove stale .git folder: %w", err) + } + if err := h.gitProviderNoAuth.Init(ctx, repoContext.WorkDir); err != nil { return fmt.Errorf("failed to create empty git repository: %w", err) } diff --git a/controllers/codebase/service/chain/put_project_test.go b/controllers/codebase/service/chain/put_project_test.go index 3b63614d..05f8c7bd 100644 --- a/controllers/codebase/service/chain/put_project_test.go +++ b/controllers/codebase/service/chain/put_project_test.go @@ -202,6 +202,8 @@ func TestPutProject_ServeRequest(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything).Return(nil) @@ -273,6 +275,8 @@ func TestPutProject_ServeRequest(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything).Return(nil) @@ -346,6 +350,8 @@ func TestPutProject_ServeRequest(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything).Return(nil) @@ -417,6 +423,8 @@ func TestPutProject_ServeRequest(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() return func(config gitproviderv2.Config) gitproviderv2.Git { @@ -483,6 +491,8 @@ func TestPutProject_ServeRequest(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() return func(config gitproviderv2.Config) gitproviderv2.Git { @@ -551,6 +561,8 @@ func TestPutProject_ServeRequest(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything). @@ -622,6 +634,8 @@ func TestPutProject_ServeRequest(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything).Return(nil) @@ -694,6 +708,8 @@ func TestPutProject_ServeRequest(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything).Return(nil) @@ -887,6 +903,8 @@ func TestPutProject_ServeRequest(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() // Strict expectation: the stray init branch must be removed exactly once. mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Once() mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) @@ -960,6 +978,8 @@ func TestPutProject_ServeRequest(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("master", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything).Return(nil) @@ -987,6 +1007,225 @@ func TestPutProject_ServeRequest(t *testing.T) { require.Equal(t, util.ProjectPushedStatus, status.Git) }, }, + { + name: "adopts already pushed project when push was in progress", + codebase: &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: defaultNs, + }, + Spec: codebaseApi.CodebaseSpec{ + Strategy: codebaseApi.Create, + GitServer: "gitlab", + GitUrlPath: "/test-app", + DefaultBranch: "main", + EmptyProject: true, + }, + Status: codebaseApi.CodebaseStatus{ + Git: util.ProjectPushInProgressStatus, + }, + }, + objects: []client.Object{ + &codebaseApi.GitServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gitlab", + Namespace: defaultNs, + }, + Spec: codebaseApi.GitServerSpec{ + GitProvider: codebaseApi.GitProviderGitlab, + GitHost: "gitlab.example.com", + GitUser: "edp-ci", + NameSshKeySecret: "gitlab-access-token", + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gitlab-access-token", + Namespace: defaultNs, + }, + Data: map[string][]byte{ + "token": []byte("fake-token"), + }, + }, + }, + gitProviderFactory: func(t *testing.T) gitproviderv2.GitProviderFactory { + // Only the remote probe may run: any Init/Commit/Push call on + // this strict mock means history was regenerated and fails the test. + mock := gitmocks.NewMockGit(t) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, "main"). + Return("91eb10daf05eff87f611d4d71d25e42c5abfd711", nil).Once() + + return func(config gitproviderv2.Config) gitproviderv2.Git { + return mock + } + }, + gitProvider: func( + t *testing.T, + ) func(gitServer *codebaseApi.GitServer, token string) (gitprovider.GitProjectProvider, error) { + mock := mocks.NewMockGitProjectProvider(t) + mock.EXPECT().SetDefaultBranch(testify.Anything, testify.Anything, testify.Anything, "test-app", "main"). + Return(nil).Once() + + return func(gitServer *codebaseApi.GitServer, token string) (gitprovider.GitProjectProvider, error) { + return mock, nil + } + }, + wantErr: require.NoError, + wantStatus: func(t *testing.T, status codebaseApi.CodebaseStatus) { + require.Equal(t, util.ProjectPushedStatus, status.Git) + }, + }, + { + name: "re-provisions in full when the in-progress push never landed", + codebase: &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: defaultNs, + }, + Spec: codebaseApi.CodebaseSpec{ + Strategy: codebaseApi.Create, + GitServer: "gitlab", + GitUrlPath: "/test-app", + DefaultBranch: "main", + EmptyProject: true, + }, + Status: codebaseApi.CodebaseStatus{ + Git: util.ProjectPushInProgressStatus, + }, + }, + objects: []client.Object{ + &codebaseApi.GitServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gitlab", + Namespace: defaultNs, + }, + Spec: codebaseApi.GitServerSpec{ + GitProvider: codebaseApi.GitProviderGitlab, + GitHost: "gitlab.example.com", + GitUser: "edp-ci", + NameSshKeySecret: "gitlab-access-token", + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gitlab-access-token", + Namespace: defaultNs, + }, + Data: map[string][]byte{ + "token": []byte("fake-token"), + }, + }, + }, + gitProviderFactory: func(t *testing.T) gitproviderv2.GitProviderFactory { + mock := gitmocks.NewMockGit(t) + // Probed twice: the adopt check and the pre-push check. + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, "main"). + Return("", gitproviderv2.ErrReferenceNotFound).Twice() + mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) + mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) + mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Once() + mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) + mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything).Return(nil) + + return func(config gitproviderv2.Config) gitproviderv2.Git { + return mock + } + }, + gitProvider: func( + t *testing.T, + ) func(gitServer *codebaseApi.GitServer, token string) (gitprovider.GitProjectProvider, error) { + mock := mocks.NewMockGitProjectProvider(t) + mock.EXPECT().ProjectExists(testify.Anything, testify.Anything, testify.Anything, "test-app"). + Return(false, nil) + mock.EXPECT().CreateProject(testify.Anything, testify.Anything, testify.Anything, "test-app", testify.Anything). + Return(nil) + mock.EXPECT().SetDefaultBranch(testify.Anything, testify.Anything, testify.Anything, "test-app", "main"). + Return(nil) + + return func(gitServer *codebaseApi.GitServer, token string) (gitprovider.GitProjectProvider, error) { + return mock, nil + } + }, + wantErr: require.NoError, + wantStatus: func(t *testing.T, status codebaseApi.CodebaseStatus) { + require.Equal(t, util.ProjectPushedStatus, status.Git) + }, + }, + { + name: "does not checkpoint or adopt a pre-existing foreign default branch", + codebase: &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: defaultNs, + }, + Spec: codebaseApi.CodebaseSpec{ + Strategy: codebaseApi.Create, + GitServer: "gitlab", + GitUrlPath: "/test-app", + DefaultBranch: "main", + EmptyProject: true, + }, + }, + objects: []client.Object{ + &codebaseApi.GitServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gitlab", + Namespace: defaultNs, + }, + Spec: codebaseApi.GitServerSpec{ + GitProvider: codebaseApi.GitProviderGitlab, + GitHost: "gitlab.example.com", + GitUser: "edp-ci", + NameSshKeySecret: "gitlab-access-token", + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gitlab-access-token", + Namespace: defaultNs, + }, + Data: map[string][]byte{ + "token": []byte("fake-token"), + }, + }, + }, + gitProviderFactory: func(t *testing.T) gitproviderv2.GitProviderFactory { + mock := gitmocks.NewMockGit(t) + mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) + mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) + mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Once() + // The remote already has a foreign default branch: no checkpoint + // may be written and no push may run - the strict mock fails the + // test on any AddRemoteLink/Push call. + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, "main"). + Return("f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0", nil).Once() + + return func(config gitproviderv2.Config) gitproviderv2.Git { + return mock + } + }, + gitProvider: func( + t *testing.T, + ) func(gitServer *codebaseApi.GitServer, token string) (gitprovider.GitProjectProvider, error) { + mock := mocks.NewMockGitProjectProvider(t) + mock.EXPECT().ProjectExists(testify.Anything, testify.Anything, testify.Anything, "test-app"). + Return(true, nil) + + return func(gitServer *codebaseApi.GitServer, token string) (gitprovider.GitProjectProvider, error) { + return mock, nil + } + }, + wantErr: func(t require.TestingT, err error, _ ...any) { + require.Error(t, err) + require.Contains(t, err.Error(), "refusing to overwrite") + }, + wantStatus: func(t *testing.T, status codebaseApi.CodebaseStatus) { + require.NotEqual(t, util.ProjectPushInProgressStatus, status.Git, + "a foreign branch must never arm the adopt checkpoint") + }, + }, } for _, tt := range tests { @@ -1098,6 +1337,8 @@ func TestPutProject_ServeRequest_Gerrit(t *testing.T) { mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() return func(config gitproviderv2.Config) gitproviderv2.Git { @@ -1143,6 +1384,8 @@ func TestPutProject_ServeRequest_Gerrit(t *testing.T) { mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() return func(config gitproviderv2.Config) gitproviderv2.Git { @@ -1188,6 +1431,8 @@ func TestPutProject_ServeRequest_Gerrit(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything).Return(nil) @@ -1233,6 +1478,8 @@ func TestPutProject_ServeRequest_Gerrit(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() return func(config gitproviderv2.Config) gitproviderv2.Git { @@ -1273,6 +1520,8 @@ func TestPutProject_ServeRequest_Gerrit(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() return func(config gitproviderv2.Config) gitproviderv2.Git { @@ -1315,6 +1564,8 @@ func TestPutProject_ServeRequest_Gerrit(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything). @@ -1360,6 +1611,8 @@ func TestPutProject_ServeRequest_Gerrit(t *testing.T) { mock.EXPECT().Init(testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Commit(testify.Anything, testify.Anything, "Initial commit", testify.Anything).Return(nil) mock.EXPECT().GetCurrentBranchName(testify.Anything, testify.Anything).Return("main", nil) + mock.EXPECT().ResolveRemoteReference(testify.Anything, testify.Anything, testify.Anything). + Return("", gitproviderv2.ErrReferenceNotFound).Maybe() mock.EXPECT().RemoveBranch(testify.Anything, testify.Anything, "master").Return(nil).Maybe() mock.EXPECT().AddRemoteLink(testify.Anything, testify.Anything, testify.Anything).Return(nil) mock.EXPECT().Push(testify.Anything, testify.Anything, testify.Anything, testify.Anything).Return(nil) diff --git a/pkg/git/transport.go b/pkg/git/transport.go index 3243b38a..40528cd2 100644 --- a/pkg/git/transport.go +++ b/pkg/git/transport.go @@ -169,6 +169,12 @@ func (p *GitProvider) advertisedReferences(ctx context.Context, repoURL string) if err != nil { _ = session.Close() + // An empty or absent repository cannot resolve any reference; callers + // distinguishing "not found" from transport failures rely on this. + if errors.Is(err, transport.ErrEmptyRemoteRepository) || errors.Is(err, transport.ErrRepositoryNotFound) { + return nil, nil, fmt.Errorf("remote repository is empty or missing: %w", ErrReferenceNotFound) + } + return nil, nil, fmt.Errorf("failed to get advertised references: %w", err) } diff --git a/pkg/git/transport_test.go b/pkg/git/transport_test.go index 4effc820..d64e5782 100644 --- a/pkg/git/transport_test.go +++ b/pkg/git/transport_test.go @@ -410,6 +410,17 @@ func TestResolveAdvertisedRef(t *testing.T) { } } +// An empty repository must resolve to "not found", not a transport failure: +// provisioning probes freshly created projects before the first push. +func TestGitProvider_ResolveRemoteReference_EmptyRepo(t *testing.T) { + s := emptyUploadPackServer(t) + gp := NewGitProvider(Config{Username: "user", Token: "pass"}) + + _, err := gp.ResolveRemoteReference(context.Background(), s.URL, "main") + + require.ErrorIs(t, err, ErrReferenceNotFound) +} + func TestResolveAdvertisedRef_NoHead(t *testing.T) { _, err := resolveAdvertisedRef(packp.NewAdvRefs(), "") diff --git a/pkg/util/consts.go b/pkg/util/consts.go index a6557544..6839a917 100644 --- a/pkg/util/consts.go +++ b/pkg/util/consts.go @@ -41,6 +41,12 @@ const ( ProjectPushedStatus = "pushed" ProjectGitLabCIPushedStatus = "gitlab_ci_pushed" ProjectTemplatesPushedStatus = "templates_pushed" + // ProjectPushInProgressStatus marks that the operator verified the remote + // default branch was absent and is about to push the initial history. On + // retry, this status plus a now-present remote default branch proves the + // push landed and provisioning must adopt it: pushing regenerated history + // would silently replace the remote branch. + ProjectPushInProgressStatus = "push_in_progress" GithubDomain = "https://github.com/epmd-edp"