diff --git a/Tiltfile b/Tiltfile index 473b9b6..65fcebb 100644 --- a/Tiltfile +++ b/Tiltfile @@ -39,7 +39,7 @@ local_resource( "main.go", "go.mod", "go.sum", - "api", + "apis", "controllers", "pkg", ], diff --git a/apis/mpas/v1alpha1/condition_types.go b/apis/mpas/v1alpha1/condition_types.go new file mode 100644 index 0000000..d945039 --- /dev/null +++ b/apis/mpas/v1alpha1/condition_types.go @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2022 SAP SE or an SAP affiliate company and Open Component Model contributors. +// +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +const ( + // RepositoryCreateFailedReason is used when we fail to create a Repository. + RepositoryCreateFailedReason = "RepositoryCreateFailed" +) diff --git a/apis/mpas/v1alpha1/repository_types.go b/apis/mpas/v1alpha1/repository_types.go index 3ec46c7..1580779 100644 --- a/apis/mpas/v1alpha1/repository_types.go +++ b/apis/mpas/v1alpha1/repository_types.go @@ -22,16 +22,24 @@ type Credentials struct { // RepositorySpec defines the desired state of Repository type RepositorySpec struct { - Provider string `json:"provider"` - Owner string `json:"owner"` - Repository string `json:"repository"` - Credentials Credentials `json:"credentials"` - Interval metav1.Duration `json:"interval"` + Provider string `json:"provider"` + Owner string `json:"owner"` + RepositoryName string `json:"repositoryName"` + Credentials Credentials `json:"credentials"` + //+optional + Interval metav1.Duration `json:"interval,omitempty"` + //+optional + //+kubebuilder:default:=internal + Visibility string `json:"visibility,omitempty"` + //+kubebuilder:default:=true + IsOrganization bool `json:"isOrganization,omitempty"` + //+optional + Domain string `json:"domain,omitempty"` //+optional Maintainers []string `json:"maintainers,omitempty"` //+optional - //+kubebuilder:default=true; + //+kubebuilder:default:=true AutomaticPullRequestCreation bool `json:"automaticPullRequestCreation,omitempty"` } diff --git a/config/crd/bases/mpas.ocm.software_repositories.yaml b/config/crd/bases/mpas.ocm.software_repositories.yaml index b46a6b4..336cec3 100644 --- a/config/crd/bases/mpas.ocm.software_repositories.yaml +++ b/config/crd/bases/mpas.ocm.software_repositories.yaml @@ -36,8 +36,7 @@ spec: description: RepositorySpec defines the desired state of Repository properties: automaticPullRequestCreation: - default: - - true + default: true type: boolean credentials: description: Credentials contains ways of authenticating the creation @@ -55,8 +54,13 @@ spec: required: - secretRef type: object + domain: + type: string interval: type: string + isOrganization: + default: true + type: boolean maintainers: items: type: string @@ -65,14 +69,16 @@ spec: type: string provider: type: string - repository: + repositoryName: + type: string + visibility: + default: internal type: string required: - credentials - - interval - owner - provider - - repository + - repositoryName type: object status: description: RepositoryStatus defines the observed state of Repository diff --git a/config/samples/mpas_v1alpha1_repository.yaml b/config/samples/mpas_v1alpha1_repository.yaml index c3d5f91..7f72388 100644 --- a/config/samples/mpas_v1alpha1_repository.yaml +++ b/config/samples/mpas_v1alpha1_repository.yaml @@ -1,12 +1,12 @@ apiVersion: mpas.ocm.software/v1alpha1 kind: Repository metadata: - labels: - app.kubernetes.io/name: repository - app.kubernetes.io/instance: repository-sample - app.kubernetes.io/part-of: git-controller - app.kubernetes.io/managed-by: kustomize - app.kubernetes.io/created-by: git-controller name: repository-sample spec: - # TODO(user): Add fields here + credentials: + secretRef: + name: github-creds + interval: 10m + owner: Skarlso + provider: github + repositoryName: new-repository-1 diff --git a/controllers/mpas/repository_controller.go b/controllers/mpas/repository_controller.go index 9d61a28..38a6b6e 100644 --- a/controllers/mpas/repository_controller.go +++ b/controllers/mpas/repository_controller.go @@ -16,16 +16,20 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" 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/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" mpasv1alpha1 "github.com/open-component-model/git-controller/apis/mpas/v1alpha1" + "github.com/open-component-model/git-controller/pkg/providers" ) // RepositoryReconciler reconciles a Repository object type RepositoryReconciler struct { client.Client - Scheme *runtime.Scheme + Scheme *runtime.Scheme + Provider providers.Provider } //+kubebuilder:rbac:groups=mpas.ocm.software,resources=repositories,verbs=get;list;watch;create;update;patch;delete @@ -41,7 +45,6 @@ func (r *RepositoryReconciler) Reconcile(ctx context.Context, req ctrl.Request) ) logger := log.FromContext(ctx).WithName("repository") - logger.V(4).Info("entering repository loop...") obj := &mpasv1alpha1.Repository{} @@ -94,10 +97,10 @@ func (r *RepositoryReconciler) Reconcile(ctx context.Context, req ctrl.Request) // If not reconciling or stalled than mark Ready=True if !conditions.IsReconciling(obj) && !conditions.IsStalled(obj) && - retErr == nil && - result.RequeueAfter == obj.GetRequeueAfter() { + retErr == nil { conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, "Reconciliation success") } + // Set status observed generation option if the component is stalled or ready. if conditions.IsStalled(obj) || conditions.IsReady(obj) { obj.Status.ObservedGeneration = obj.Generation @@ -109,6 +112,11 @@ func (r *RepositoryReconciler) Reconcile(ctx context.Context, req ctrl.Request) } }() + // Remove any stale Ready condition, most likely False, set above. Its value + // is derived from the overall result of the reconciliation in the deferred + // block at the very end. + conditions.Delete(obj, meta.ReadyCondition) + result, retErr = r.reconcile(ctx, obj) return result, retErr } @@ -116,10 +124,15 @@ func (r *RepositoryReconciler) Reconcile(ctx context.Context, req ctrl.Request) // SetupWithManager sets up the controller with the Manager. func (r *RepositoryReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&mpasv1alpha1.Repository{}). + For(&mpasv1alpha1.Repository{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Complete(r) } func (r *RepositoryReconciler) reconcile(ctx context.Context, obj *mpasv1alpha1.Repository) (ctrl.Result, error) { + if err := r.Provider.CreateRepository(ctx, *obj); err != nil { + conditions.MarkFalse(obj, meta.ReadyCondition, mpasv1alpha1.RepositoryCreateFailedReason, err.Error()) + return ctrl.Result{}, fmt.Errorf("failed to create repository: %w", err) + } + return ctrl.Result{}, nil } diff --git a/main.go b/main.go index b9e6098..882d85d 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "os" "github.com/open-component-model/git-controller/pkg/gogit" + "github.com/open-component-model/git-controller/pkg/providers/github" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -30,7 +31,6 @@ import ( var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") - ociAgent = "git-controller/v1alpha1" ) func init() { @@ -100,9 +100,12 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Sync") os.Exit(1) } + + githubProvider := github.NewClient(mgr.GetClient(), nil) if err = (&mpascontrollers.RepositoryReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Provider: githubProvider, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Repository") os.Exit(1) diff --git a/pkg/providers/github/github.go b/pkg/providers/github/github.go index 7b757c5..6bd177e 100644 --- a/pkg/providers/github/github.go +++ b/pkg/providers/github/github.go @@ -5,26 +5,142 @@ import ( "fmt" "github.com/fluxcd/go-git-providers/github" + "github.com/fluxcd/go-git-providers/gitprovider" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + mpasv1alpha1 "github.com/open-component-model/git-controller/apis/mpas/v1alpha1" "github.com/open-component-model/git-controller/pkg/providers" ) +const ( + tokenKey = "token" + providerType = "github" + defaultDomain = "github.com" +) + +// Client github. type Client struct { - // TODO: Figure out how to get this. - BaseURL string + client client.Client + next providers.Provider +} + +// TODO: Use this instead and somehow abstract the two clients. +type RepositoryOpts struct { + Owner string + Domain string + Visibility gitprovider.RepositoryVisibility } -func NewClient() *Client { - return &Client{} +// NewClient creates a new GitHub client. +func NewClient(client client.Client, next providers.Provider) *Client { + return &Client{ + client: client, + next: next, + } } var _ providers.Provider = &Client{} -func (c *Client) CreateRepository(ctx context.Context, owner, repo string) error { - _, err := github.NewClient() +func (c *Client) CreateRepository(ctx context.Context, obj mpasv1alpha1.Repository) error { + if obj.Spec.Provider != providerType { + if c.next == nil { + return fmt.Errorf("can't handle provider type '%s' and no next provider is configured", obj.Spec.Provider) + } + + return c.next.CreateRepository(ctx, obj) + } + + authenticationOption, err := c.constructAuthenticationOption(ctx, obj) + if err != nil { + return err + } + + gc, err := github.NewClient(authenticationOption) if err != nil { return fmt.Errorf("failed to create github client: %w", err) } + visibility := gitprovider.RepositoryVisibility(obj.Spec.Visibility) + + if err := gitprovider.ValidateRepositoryVisibility(visibility); err != nil { + return fmt.Errorf("failed to validate visibility: %w", err) + } + + domain := defaultDomain + if obj.Spec.Domain != "" { + domain = obj.Spec.Domain + } + + if obj.Spec.IsOrganization { + return c.createOrganizationRepository(ctx, gc, domain, visibility, obj.Spec) + } + + return c.createUserRepository(ctx, gc, domain, visibility, obj.Spec) +} + +// constructAuthenticationOption will take the object and construct an authentication option. +// For now, only token secret is supported, this will be extended in the future. +func (c *Client) constructAuthenticationOption(ctx context.Context, obj mpasv1alpha1.Repository) (gitprovider.ClientOption, error) { + secret := &v1.Secret{} + if err := c.client.Get(ctx, types.NamespacedName{ + Name: obj.Spec.Credentials.SecretRef.Name, + Namespace: obj.Namespace, + }, secret); err != nil { + return nil, fmt.Errorf("failed to get secret: %w", err) + } + + token, ok := secret.Data[tokenKey] + if !ok { + return nil, fmt.Errorf("token '%s' not found in secret", tokenKey) + } + + return gitprovider.WithOAuth2Token(string(token)), nil +} + +func (c *Client) createOrganizationRepository(ctx context.Context, gc gitprovider.Client, domain string, visibility gitprovider.RepositoryVisibility, spec mpasv1alpha1.RepositorySpec) error { + logger := log.FromContext(ctx) + + repo, err := gc.OrgRepositories().Create(ctx, gitprovider.OrgRepositoryRef{ + OrganizationRef: gitprovider.OrganizationRef{ + Domain: domain, + Organization: spec.Owner, + }, + RepositoryName: spec.RepositoryName, + }, gitprovider.RepositoryInfo{ + DefaultBranch: gitprovider.StringVar("main"), + Visibility: &visibility, + }) + if err != nil { + return fmt.Errorf("failed to create repository: %w", err) + } + + logger.Info("organization repository successfully created", "name", repo.Repository().String()) + + return nil +} + +func (c *Client) createUserRepository(ctx context.Context, gc gitprovider.Client, domain string, visibility gitprovider.RepositoryVisibility, spec mpasv1alpha1.RepositorySpec) error { + logger := log.FromContext(ctx) + + repo, err := gc.UserRepositories().Create(ctx, gitprovider.UserRepositoryRef{ + UserRef: gitprovider.UserRef{ + Domain: domain, + UserLogin: spec.Owner, + }, + RepositoryName: spec.RepositoryName, + }, gitprovider.RepositoryInfo{ + DefaultBranch: gitprovider.StringVar("main"), + Visibility: &visibility, + }) + if err != nil { + return fmt.Errorf("failed to create repository: %w", err) + } + + logger.Info("user repository successfully created", "name", repo.Repository().String()) + return nil } diff --git a/pkg/providers/providers.go b/pkg/providers/providers.go index d85e230..367b1ae 100644 --- a/pkg/providers/providers.go +++ b/pkg/providers/providers.go @@ -1,8 +1,13 @@ package providers -import "context" +import ( + "context" + mpasv1alpha1 "github.com/open-component-model/git-controller/apis/mpas/v1alpha1" +) + +// Provider adds the ability to create repositories and pull requests. type Provider interface { - CreateRepository(ctx context.Context, owner, repo string) error + CreateRepository(ctx context.Context, spec mpasv1alpha1.Repository) error CreatePullRequest(ctx context.Context, owner, repo, title, branch, description string) error }