controllers/merge_request/controller.go (322 lines of code) (raw):
package mergerequest
import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"regexp"
"time"
"github.com/go-logr/logr"
"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
gerritApi "github.com/epam/edp-gerrit-operator/v2/api/v1"
"github.com/epam/edp-gerrit-operator/v2/controllers/helper"
gerritClient "github.com/epam/edp-gerrit-operator/v2/pkg/client/gerrit"
"github.com/epam/edp-gerrit-operator/v2/pkg/client/git"
"github.com/epam/edp-gerrit-operator/v2/pkg/service/gerrit"
"github.com/epam/edp-gerrit-operator/v2/pkg/service/platform"
)
const (
finalizerName = "merge_request.gerrit.finalizer.name"
StatusNew = "NEW"
StatusAbandoned = "ABANDONED"
StatusMerged = "MERGED"
MergeArgNoFastForward = "--no-ff"
MergeArgCommitMessage = "-m"
)
type Reconcile struct {
k8sClient client.Client
service gerrit.Interface
log logr.Logger
getGitClient func(ctx context.Context, child gerrit.Child, workDir string) (GitClient, error)
getGerritClient func(ctx context.Context, child *gerritApi.GerritMergeRequest) (GerritClient, error)
gitWorkDir string
}
type GitClient interface {
Clone(projectName string) (projectPath string, err error)
Merge(projectName, sourceBranch, targetBranch string, options ...string) error
Push(projectName string, remote string, refSpecs ...string) (pushOutput string, retErr error)
GenerateChangeID() (string, error)
SetProjectUser(projectName string, user *git.User) error
CheckoutBranch(projectName, branch string) error
Commit(projectName, message string, files []string, user *git.User) error
SetFileContents(projectName, filePath, contents string) error
RemoveFile(projectName, filePath string) (bool, error)
}
type GerritClient interface {
ChangeAbandon(changeID string) error
ChangeGet(changeID string) (*gerritClient.Change, error)
}
type MRConfigMapFile struct {
Path string `json:"path"`
Contents *string `json:"contents"`
}
func NewReconcile(k8sClient client.Client, log logr.Logger,
opts ...OptionFunc,
) helper.Controller {
r := &Reconcile{
k8sClient: k8sClient,
log: log,
}
for i := range opts {
opts[i](r)
}
return r
}
type OptionFunc func(r *Reconcile)
func PrepareWorkDirectoryOption(gitWorkDirectory string) (OptionFunc, error) {
if err := os.RemoveAll(gitWorkDirectory); err != nil {
return nil, errors.Wrap(err, "unable to clean git work dir")
}
return func(r *Reconcile) {
r.gitWorkDir = gitWorkDirectory
}, nil
}
func PrepareGerritServiceOption(k8sClient client.Client, platformType string, scheme *runtime.Scheme) (OptionFunc, error) {
ps, err := platform.NewService(platformType, scheme)
if err != nil {
return nil, errors.Wrap(err, "unable to create platform service")
}
gerritService := gerrit.NewComponentService(ps, k8sClient, scheme)
return func(r *Reconcile) {
r.service = gerritService
r.getGitClient = func(ctx context.Context, child gerrit.Child, workDir string) (GitClient, error) {
gitClient, err := gerritService.GetGitClient(ctx, child, workDir)
if err != nil {
return nil, fmt.Errorf("failed to create git client: %w", err)
}
return gitClient, nil
}
r.getGerritClient = func(ctx context.Context, instance *gerritApi.GerritMergeRequest) (GerritClient, error) {
gerritClientInst, err := helper.GetGerritClient(ctx, r.k8sClient, instance, instance.OwnerName(), r.service)
if err != nil {
return nil, fmt.Errorf("failed to create gerrit client: %w", err)
}
return gerritClientInst, nil
}
}, nil
}
func (r *Reconcile) SetupWithManager(mgr ctrl.Manager) error {
pred := predicate.Funcs{
UpdateFunc: isSpecUpdated,
}
err := ctrl.NewControllerManagedBy(mgr).
For(&gerritApi.GerritMergeRequest{}, builder.WithPredicates(pred)).
Complete(r)
if err != nil {
return fmt.Errorf("failed to setup GerritMergeRequest controller: %w", err)
}
return nil
}
func isSpecUpdated(e event.UpdateEvent) bool {
oo, ok := e.ObjectOld.(*gerritApi.GerritMergeRequest)
if !ok {
return false
}
no, ok := e.ObjectNew.(*gerritApi.GerritMergeRequest)
if !ok {
return false
}
return !reflect.DeepEqual(oo.Spec, no.Spec) ||
(oo.GetDeletionTimestamp().IsZero() && !no.GetDeletionTimestamp().IsZero())
}
//+kubebuilder:rbac:groups=v2.edp.epam.com,namespace=placeholder,resources=gerritmergerequests,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=v2.edp.epam.com,namespace=placeholder,resources=gerritmergerequests/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=v2.edp.epam.com,namespace=placeholder,resources=gerritmergerequests/finalizers,verbs=update
func (r *Reconcile) Reconcile(ctx context.Context, request reconcile.Request) (result reconcile.Result, resError error) {
reqLogger := r.log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name)
reqLogger.V(2).Info("Reconciling GerritMergeRequest has been started")
var instance gerritApi.GerritMergeRequest
if err := r.k8sClient.Get(ctx, request.NamespacedName, &instance); err != nil {
if k8sErrors.IsNotFound(err) {
reqLogger.Info("instance not found")
return
}
return reconcile.Result{}, errors.Wrap(err, "unable to get GerritMergeRequest instance")
}
if requeue, err := r.tryReconcile(ctx, &instance); err != nil {
instance.Status.Value = err.Error()
result.RequeueAfter = time.Second * helper.DefaultRequeueTime
reqLogger.Error(err, "an error has occurred while handling GerritMergeRequest", "name",
request.Name)
} else if requeue {
result.RequeueAfter = time.Second * helper.DefaultRequeueTime
}
if err := r.k8sClient.Status().Update(ctx, &instance); err != nil {
resError = err
}
reqLogger.Info("Reconciling done")
return
}
func (r *Reconcile) tryReconcile(ctx context.Context, instance *gerritApi.GerritMergeRequest) (bool, error) {
requeue := false
if instance.Status.ChangeID == "" {
if instance.Spec.SourceBranch == "" && instance.Spec.ChangesConfigMap == "" {
return false, errors.New("sourceBranch or changesConfigMap must be specified")
}
status, err := r.createChange(ctx, instance)
if err != nil {
return false, errors.Wrap(err, "unable to create change")
}
instance.Status = *status
requeue = true
} else {
status, err := r.getChangeStatus(ctx, instance)
if err != nil {
return false, errors.Wrap(err, "unable to get change status")
}
instance.Status.Value = status
requeue = status == StatusNew
}
if err := helper.TryToDelete(ctx, r.k8sClient, instance, finalizerName,
r.makeDeletionFunc(ctx, instance)); err != nil {
return false, errors.Wrap(err, "unable to delete resource")
}
return requeue, nil
}
func (r *Reconcile) createChange(ctx context.Context,
instance *gerritApi.GerritMergeRequest,
) (status *gerritApi.GerritMergeRequestStatus, retErr error) {
// init git client
gitClient, err := r.getGitClient(ctx, instance, r.gitWorkDir)
if err != nil {
return nil, errors.Wrap(err, "unable to init git client")
}
// clone project
projectPath, err := gitClient.Clone(instance.Spec.ProjectName)
if err != nil {
return nil, errors.Wrap(err, "unable to clone repo")
}
// clear cloned project
defer func() {
if remErr := os.RemoveAll(projectPath); remErr != nil {
retErr = remErr
}
}()
// generate change id for commit or merge
changeID, err := gitClient.GenerateChangeID()
if err != nil {
return nil, errors.Wrap(err, "unable to generate change id")
}
// perform merge or commit files from config map
if instance.Spec.SourceBranch != "" {
err = mergeBranches(instance, gitClient, changeID)
if err != nil {
return nil, errors.Wrap(err, "unable to perform merge")
}
} else {
err = r.commitFiles(ctx, instance, gitClient, changeID)
if err != nil {
return nil, errors.Wrap(err, "unable to commit files")
}
}
// push changes for review
refSpec := fmt.Sprintf("HEAD:refs/for/%s", instance.TargetBranch())
pushMessage, err := gitClient.Push(instance.Spec.ProjectName, "origin", refSpec)
if err != nil {
return nil, errors.Wrap(err, "unable to push repo")
}
return &gerritApi.GerritMergeRequestStatus{
ChangeID: changeID,
ChangeURL: extractMrURL(pushMessage),
Value: StatusNew,
}, nil
}
func (r *Reconcile) commitFiles(ctx context.Context, instance *gerritApi.GerritMergeRequest, gitClient GitClient, changeID string) error {
var cMap corev1.ConfigMap
if err := r.k8sClient.Get(ctx, types.NamespacedName{
Namespace: instance.Namespace,
Name: instance.Spec.ChangesConfigMap,
}, &cMap); err != nil {
return errors.Wrap(err, "unable to get files config map")
}
if err := gitClient.CheckoutBranch(instance.Spec.ProjectName, instance.TargetBranch()); err != nil {
return errors.Wrap(err, "unable to checkout branch")
}
addFiles := make([]string, 0, len(cMap.Data))
for _, mrContents := range cMap.Data {
var mrFile MRConfigMapFile
if err := json.Unmarshal([]byte(mrContents), &mrFile); err != nil {
return errors.Wrap(err, "unable to decode file")
}
fileChanged := true
if mrFile.Contents != nil {
if err := gitClient.SetFileContents(instance.Spec.ProjectName, mrFile.Path, *mrFile.Contents); err != nil {
return errors.Wrap(err, "unable to set file contents")
}
} else {
var err error
fileChanged, err = gitClient.RemoveFile(instance.Spec.ProjectName, mrFile.Path)
if err != nil {
return errors.Wrap(err, "unable to remove file")
}
}
if fileChanged {
addFiles = append(addFiles, mrFile.Path)
}
}
gitUser := &git.User{Name: instance.Spec.AuthorName, Email: instance.Spec.AuthorEmail}
message := commitMessage(instance.CommitMessage(), changeID)
if err := gitClient.Commit(instance.Spec.ProjectName, message, addFiles, gitUser); err != nil {
return errors.Wrap(err, "unable to commit changes")
}
return nil
}
func mergeBranches(instance *gerritApi.GerritMergeRequest, gitClient GitClient, changeID string) error {
projectName := instance.Spec.ProjectName
gitUser := &git.User{Name: instance.Spec.AuthorName, Email: instance.Spec.AuthorEmail}
if err := gitClient.SetProjectUser(projectName, gitUser); err != nil {
return errors.Wrap(err, "unable to set project author")
}
mergeArguments := []string{
MergeArgNoFastForward,
MergeArgCommitMessage,
commitMessage(instance.CommitMessage(), changeID),
}
if len(instance.Spec.AdditionalArguments) > 0 {
mergeArguments = append(mergeArguments, instance.Spec.AdditionalArguments...)
}
sourceBranch := fmt.Sprintf("origin/%s", instance.Spec.SourceBranch)
if err := gitClient.Merge(projectName, sourceBranch, instance.TargetBranch(), mergeArguments...); err != nil {
return errors.Wrap(err, "unable to merge branches")
}
return nil
}
func commitMessage(commitMessage, changeID string) string {
return fmt.Sprintf("%s\n\nChange-Id: %s", commitMessage, changeID)
}
func (r *Reconcile) getChangeStatus(ctx context.Context, instance *gerritApi.GerritMergeRequest) (string, error) {
gClient, err := r.getGerritClient(ctx, instance)
if err != nil {
return "", errors.Wrap(err, "unable to get gerrit client")
}
change, err := gClient.ChangeGet(instance.Status.ChangeID)
if err != nil {
return "", errors.Wrap(err, "unable to get change id")
}
return change.Status, nil
}
func extractMrURL(pushMessage string) string {
return regexp.MustCompile(
`https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)`).
FindString(pushMessage)
}
func (r *Reconcile) makeDeletionFunc(ctx context.Context, instance *gerritApi.GerritMergeRequest) func() error {
return func() error {
gClient, err := r.getGerritClient(ctx, instance)
if err != nil {
return errors.Wrap(err, "unable to get gerrit client")
}
change, err := gClient.ChangeGet(instance.Status.ChangeID)
if err != nil {
return errors.Wrap(err, "unable to get change id")
}
if change.Status == StatusNew {
if err := gClient.ChangeAbandon(instance.Status.ChangeID); err != nil {
return errors.Wrap(err, "unable to abandon change")
}
}
return nil
}
}