diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b70b8ba8..fd0b89d5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -26,6 +26,7 @@ jobs: - name: Run tests env: GITHUB_TOKEN: ${{ secrets.GITPROVIDER_BOT_TOKEN }} + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} run: make test - name: Upload coverage to Codecov uses: codecov/codecov-action@v1 diff --git a/gitlab/auth.go b/gitlab/auth.go new file mode 100644 index 00000000..8daec2e1 --- /dev/null +++ b/gitlab/auth.go @@ -0,0 +1,254 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "fmt" + "net/http" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/fluxcd/go-git-providers/gitprovider/cache" + gogitlab "github.com/xanzy/go-gitlab" + "golang.org/x/oauth2" +) + +const ( + // DefaultDomain specifies the default domain used as the backend. + DefaultDomain = "gitlab.com" +) + +// ClientOption is the interface to implement for passing options to NewClient. +// The clientOptions struct is private to force usage of the With... functions. +type ClientOption interface { + // ApplyToGitlabClientOptions applies set fields of this object into target. + ApplyToGitlabClientOptions(target *clientOptions) error +} + +// clientOptions is the struct that tracks data about what options have been set. +type clientOptions struct { + // clientOptions shares all the common options + gitprovider.CommonClientOptions + + // AuthTransport is a ChainableRoundTripperFunc adding authentication credentials to the transport chain. + AuthTransport gitprovider.ChainableRoundTripperFunc + + // EnableConditionalRequests will be set if conditional requests should be used. + EnableConditionalRequests *bool +} + +// ApplyToGitlabClientOptions implements ClientOption, and applies the set fields of opts +// into target. If both opts and target has the same specific field set, ErrInvalidClientOptions is returned. +func (opts *clientOptions) ApplyToGitlabClientOptions(target *clientOptions) error { + // Apply common values, if any + if err := opts.CommonClientOptions.ApplyToCommonClientOptions(&target.CommonClientOptions); err != nil { + return err + } + + if opts.AuthTransport != nil { + // Make sure the user didn't specify the AuthTransport twice + if target.AuthTransport != nil { + return fmt.Errorf("option AuthTransport already configured: %w", gitprovider.ErrInvalidClientOptions) + } + target.AuthTransport = opts.AuthTransport + } + + if opts.EnableConditionalRequests != nil { + // Make sure the user didn't specify the EnableConditionalRequests twice + if target.EnableConditionalRequests != nil { + return fmt.Errorf("option EnableConditionalRequests already configured: %w", gitprovider.ErrInvalidClientOptions) + } + target.EnableConditionalRequests = opts.EnableConditionalRequests + } + return nil +} + +// getTransportChain builds the full chain of transports (from left to right, +// as per gitprovider.BuildClientFromTransportChain) of the form described in NewClient. +func (opts *clientOptions) getTransportChain() (chain []gitprovider.ChainableRoundTripperFunc) { + if opts.PostChainTransportHook != nil { + chain = append(chain, opts.PostChainTransportHook) + } + if opts.AuthTransport != nil { + chain = append(chain, opts.AuthTransport) + } + if opts.EnableConditionalRequests != nil && *opts.EnableConditionalRequests { + // TODO: Provide some kind of debug logging if/when the httpcache is used + // One can see if the request hit the cache using: resp.Header[httpcache.XFromCache] + chain = append(chain, cache.NewHTTPCacheTransport) + } + if opts.PreChainTransportHook != nil { + chain = append(chain, opts.PreChainTransportHook) + } + return +} + +// buildCommonOption is a helper for returning a ClientOption out of a common option field. +func buildCommonOption(opt gitprovider.CommonClientOptions) *clientOptions { + return &clientOptions{CommonClientOptions: opt} +} + +// errorOption implements ClientOption, and just wraps an error which is immediately returned. +// This struct can be used through the optionError function, in order to make makeOptions fail +// if there are invalid options given to the With... functions. +type errorOption struct { + err error +} + +// ApplyToGitlabClientOptions implements ClientOption, but just returns the internal error. +func (e *errorOption) ApplyToGitlabClientOptions(*clientOptions) error { return e.err } + +// optionError is a constructor for errorOption. +func optionError(err error) ClientOption { + return &errorOption{err} +} + +// +// Common options +// + +// WithDomain initializes a Client for a custom GitLab instance of the given domain. +// Only host and port information should be present in domain. domain must not be an empty string. +func WithDomain(domain string) ClientOption { + return buildCommonOption(gitprovider.CommonClientOptions{Domain: &domain}) +} + +// WithDestructiveAPICalls tells the client whether it's allowed to do dangerous and possibly destructive +// actions, like e.g. deleting a repository. +func WithDestructiveAPICalls(destructiveActions bool) ClientOption { + return buildCommonOption(gitprovider.CommonClientOptions{EnableDestructiveAPICalls: &destructiveActions}) +} + +// WithPreChainTransportHook registers a ChainableRoundTripperFunc "before" the cache and authentication +// transports in the chain. For more information, see NewClient, and gitprovider.CommonClientOptions.PreChainTransportHook. +func WithPreChainTransportHook(preRoundTripperFunc gitprovider.ChainableRoundTripperFunc) ClientOption { + // Don't allow an empty value + if preRoundTripperFunc == nil { + return optionError(fmt.Errorf("preRoundTripperFunc cannot be nil: %w", gitprovider.ErrInvalidClientOptions)) + } + + return buildCommonOption(gitprovider.CommonClientOptions{PreChainTransportHook: preRoundTripperFunc}) +} + +// WithPostChainTransportHook registers a ChainableRoundTripperFunc "after" the cache and authentication +// transports in the chain. For more information, see NewClient, and gitprovider.CommonClientOptions.WithPostChainTransportHook. +func WithPostChainTransportHook(postRoundTripperFunc gitprovider.ChainableRoundTripperFunc) ClientOption { + // Don't allow an empty value + if postRoundTripperFunc == nil { + return optionError(fmt.Errorf("postRoundTripperFunc cannot be nil: %w", gitprovider.ErrInvalidClientOptions)) + } + + return buildCommonOption(gitprovider.CommonClientOptions{PostChainTransportHook: postRoundTripperFunc}) +} + +// WithOAuth2Token initializes a Client which authenticates with GitLab through an OAuth2 token. +// oauth2Token must not be an empty string. +func WithOAuth2Token(oauth2Token string) ClientOption { + // Don't allow an empty value + if len(oauth2Token) == 0 { + return optionError(fmt.Errorf("oauth2Token cannot be empty: %w", gitprovider.ErrInvalidClientOptions)) + } + + return &clientOptions{AuthTransport: oauth2Transport(oauth2Token)} +} + +func oauth2Transport(oauth2Token string) gitprovider.ChainableRoundTripperFunc { + return func(in http.RoundTripper) http.RoundTripper { + // Create a TokenSource of the given access token + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: oauth2Token}) + // Create a Transport, with "in" as the underlying transport, and the given TokenSource + return &oauth2.Transport{ + Base: in, + Source: oauth2.ReuseTokenSource(nil, ts), + } + } +} + +// WithConditionalRequests instructs the client to use Conditional Requests to GitLab. +// See: https://gitlab.com/gitlab-org/gitlab-foss/-/issues/26926, and +// https://docs.gitlab.com/ee/development/polling.html for more info. +func WithConditionalRequests(conditionalRequests bool) ClientOption { + return &clientOptions{EnableConditionalRequests: &conditionalRequests} +} + +// makeOptions assembles a clientOptions struct from ClientOption mutator functions. +func makeOptions(opts ...ClientOption) (*clientOptions, error) { + o := &clientOptions{} + for _, opt := range opts { + if err := opt.ApplyToGitlabClientOptions(o); err != nil { + return nil, err + } + } + return o, nil +} + +// NewClient creates a new gitlab.Client instance for GitLab API endpoints. +func NewClient(token string, tokenType string, optFns ...ClientOption) (gitprovider.Client, error) { + var gl *gogitlab.Client + var domain, sshDomain string + + // Complete the options struct + opts, err := makeOptions(optFns...) + if err != nil { + return nil, err + } + + // Create a *http.Client using the transport chain + httpClient, err := gitprovider.BuildClientFromTransportChain(opts.getTransportChain()) + if err != nil { + return nil, err + } + + if tokenType == "oauth2" { + if opts.Domain == nil || *opts.Domain == DefaultDomain { + // No domain set or the default gitlab.com used + domain = DefaultDomain + gl, err = gogitlab.NewOAuthClient(token, gogitlab.WithHTTPClient(httpClient)) + if err != nil { + return nil, err + } + } else { + domain = *opts.Domain + gl, err = gogitlab.NewOAuthClient(token, gogitlab.WithHTTPClient(httpClient), gogitlab.WithBaseURL(domain)) + if err != nil { + return nil, err + } + } + } else { + if opts.Domain == nil || *opts.Domain == DefaultDomain { + // No domain set or the default gitlab.com used + domain = DefaultDomain + gl, err = gogitlab.NewClient(token, gogitlab.WithHTTPClient(httpClient)) + if err != nil { + return nil, err + } + } else { + domain = *opts.Domain + gl, err = gogitlab.NewClient(token, gogitlab.WithHTTPClient(httpClient), gogitlab.WithBaseURL(domain)) + if err != nil { + return nil, err + } + } + } + + // By default, turn destructive actions off. But allow overrides. + destructiveActions := false + if opts.EnableDestructiveAPICalls != nil { + destructiveActions = *opts.EnableDestructiveAPICalls + } + + return newClient(gl, domain, sshDomain, destructiveActions), nil +} diff --git a/gitlab/auth_test.go b/gitlab/auth_test.go new file mode 100644 index 00000000..361d319b --- /dev/null +++ b/gitlab/auth_test.go @@ -0,0 +1,198 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "net/http" + "reflect" + "testing" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/fluxcd/go-git-providers/gitprovider/cache" + "github.com/fluxcd/go-git-providers/validation" +) + +func dummyRoundTripper1(http.RoundTripper) http.RoundTripper { return nil } +func dummyRoundTripper2(http.RoundTripper) http.RoundTripper { return nil } +func dummyRoundTripper3(http.RoundTripper) http.RoundTripper { return nil } + +func roundTrippersEqual(a, b gitprovider.ChainableRoundTripperFunc) bool { + if a == nil && b == nil { + return true + } else if (a != nil && b == nil) || (a == nil && b != nil) { + return false + } + // Note that this comparison relies on "undefined behavior" in the Go language spec, see: + // https://stackoverflow.com/questions/9643205/how-do-i-compare-two-functions-for-pointer-equality-in-the-latest-go-weekly + return reflect.ValueOf(a).Pointer() == reflect.ValueOf(b).Pointer() +} + +func Test_clientOptions_getTransportChain(t *testing.T) { + tests := []struct { + name string + preChain gitprovider.ChainableRoundTripperFunc + postChain gitprovider.ChainableRoundTripperFunc + auth gitprovider.ChainableRoundTripperFunc + cache bool + wantChain []gitprovider.ChainableRoundTripperFunc + }{ + { + name: "all roundtrippers", + preChain: dummyRoundTripper1, + postChain: dummyRoundTripper2, + auth: dummyRoundTripper3, + cache: true, + // expect: "post chain" <-> "auth" <-> "cache" <-> "pre chain" + wantChain: []gitprovider.ChainableRoundTripperFunc{ + dummyRoundTripper2, + dummyRoundTripper3, + cache.NewHTTPCacheTransport, + dummyRoundTripper1, + }, + }, + { + name: "only pre + auth", + preChain: dummyRoundTripper1, + auth: dummyRoundTripper2, + // expect: "auth" <-> "pre chain" + wantChain: []gitprovider.ChainableRoundTripperFunc{ + dummyRoundTripper2, + dummyRoundTripper1, + }, + }, + { + name: "only cache + auth", + cache: true, + auth: dummyRoundTripper1, + // expect: "auth" <-> "cache" + wantChain: []gitprovider.ChainableRoundTripperFunc{ + dummyRoundTripper1, + cache.NewHTTPCacheTransport, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &clientOptions{ + CommonClientOptions: gitprovider.CommonClientOptions{ + PreChainTransportHook: tt.preChain, + PostChainTransportHook: tt.postChain, + }, + AuthTransport: tt.auth, + } + gotChain := opts.getTransportChain() + for i := range tt.wantChain { + if !roundTrippersEqual(tt.wantChain[i], gotChain[i]) { + t.Errorf("clientOptions.getTransportChain() = %v, want %v", gotChain, tt.wantChain) + } + break + } + }) + } +} + +func Test_makeOptions(t *testing.T) { + tests := []struct { + name string + opts []ClientOption + want *clientOptions + expectedErrs []error + }{ + { + name: "no options", + want: &clientOptions{}, + }, + { + name: "WithDomain", + opts: []ClientOption{WithDomain("foo")}, + want: buildCommonOption(gitprovider.CommonClientOptions{Domain: gitprovider.StringVar("foo")}), + }, + { + name: "WithDomain, empty", + opts: []ClientOption{WithDomain("")}, + expectedErrs: []error{gitprovider.ErrInvalidClientOptions}, + }, + { + name: "WithDestructiveAPICalls", + opts: []ClientOption{WithDestructiveAPICalls(true)}, + want: buildCommonOption(gitprovider.CommonClientOptions{EnableDestructiveAPICalls: gitprovider.BoolVar(true)}), + }, + { + name: "WithPreChainTransportHook", + opts: []ClientOption{WithPreChainTransportHook(dummyRoundTripper1)}, + want: buildCommonOption(gitprovider.CommonClientOptions{PreChainTransportHook: dummyRoundTripper1}), + }, + { + name: "WithPreChainTransportHook, nil", + opts: []ClientOption{WithPreChainTransportHook(nil)}, + expectedErrs: []error{gitprovider.ErrInvalidClientOptions}, + }, + { + name: "WithPostChainTransportHook", + opts: []ClientOption{WithPostChainTransportHook(dummyRoundTripper2)}, + want: buildCommonOption(gitprovider.CommonClientOptions{PostChainTransportHook: dummyRoundTripper2}), + }, + { + name: "WithPostChainTransportHook, nil", + opts: []ClientOption{WithPostChainTransportHook(nil)}, + expectedErrs: []error{gitprovider.ErrInvalidClientOptions}, + }, + { + name: "WithOAuth2Token", + opts: []ClientOption{WithOAuth2Token("foo")}, + want: &clientOptions{AuthTransport: oauth2Transport("foo")}, + }, + { + name: "WithOAuth2Token, empty", + opts: []ClientOption{WithOAuth2Token("")}, + expectedErrs: []error{gitprovider.ErrInvalidClientOptions}, + }, + { + name: "WithConditionalRequests", + opts: []ClientOption{WithConditionalRequests(true)}, + want: &clientOptions{EnableConditionalRequests: gitprovider.BoolVar(true)}, + }, + { + name: "WithConditionalRequests, exclusive", + opts: []ClientOption{WithConditionalRequests(true), WithConditionalRequests(false)}, + expectedErrs: []error{gitprovider.ErrInvalidClientOptions}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := makeOptions(tt.opts...) + validation.TestExpectErrors(t, "makeOptions", err, tt.expectedErrs...) + if tt.want == nil { + return + } + if !roundTrippersEqual(got.AuthTransport, tt.want.AuthTransport) || + !roundTrippersEqual(got.PostChainTransportHook, tt.want.PostChainTransportHook) || + !roundTrippersEqual(got.PreChainTransportHook, tt.want.PreChainTransportHook) { + t.Errorf("makeOptions() = %v, want %v", got, tt.want) + } + got.AuthTransport = nil + got.PostChainTransportHook = nil + got.PreChainTransportHook = nil + tt.want.AuthTransport = nil + tt.want.PostChainTransportHook = nil + tt.want.PreChainTransportHook = nil + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("makeOptions() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/gitlab/client.go b/gitlab/client.go new file mode 100644 index 00000000..852bae30 --- /dev/null +++ b/gitlab/client.go @@ -0,0 +1,104 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/xanzy/go-gitlab" +) + +// ProviderID is the provider ID for GitLab. +const ProviderID = gitprovider.ProviderID("gitlab") + +func newClient(c *gitlab.Client, domain string, sshDomain string, destructiveActions bool) *Client { + glClient := &gitlabClientImpl{c, destructiveActions} + ctx := &clientContext{glClient, domain, sshDomain, destructiveActions} + return &Client{ + clientContext: ctx, + orgs: &OrganizationsClient{ + clientContext: ctx, + }, + orgRepos: &OrgRepositoriesClient{ + clientContext: ctx, + }, + userRepos: &UserRepositoriesClient{ + clientContext: ctx, + }, + } +} + +type clientContext struct { + c gitlabClient + domain string + sshDomain string + destructiveActions bool +} + +// Client implements the gitprovider.Client interface. +var _ gitprovider.Client = &Client{} + +// Client is an interface that allows talking to a Git provider. +type Client struct { + *clientContext + + orgs *OrganizationsClient + orgRepos *OrgRepositoriesClient + userRepos *UserRepositoriesClient +} + +// SupportedDomain returns the domain endpoint for this client, e.g. "gitlab.com" or +// "my-custom-git-server.com:6443". This allows a higher-level user to know what Client to use for +// what endpoints. +// This field is set at client creation time, and can't be changed. +func (c *Client) SupportedDomain() string { + return c.domain +} + +// SupportedSSHDomain returns the ssh domain endpoint for this client, e.g. "gitlab.com" or +// "ssh.my-custom-git-server.com:6443". This allows a higher-level user to know what Client to use for +// what endpoints. +// This field is set at client creation time, and can't be changed. +func (c *Client) SupportedSSHDomain() string { + return c.sshDomain +} + +// ProviderID returns the provider ID "gitlab". +// This field is set at client creation time, and can't be changed. +func (c *Client) ProviderID() gitprovider.ProviderID { + return ProviderID +} + +// Raw returns the Go GitLab client (github.com/xanzy *Client) +// used under the hood for accessing GitLab. +func (c *Client) Raw() interface{} { + return c.c.Client() +} + +// Organizations returns the OrganizationsClient handling sets of organizations. +func (c *Client) Organizations() gitprovider.OrganizationsClient { + return c.orgs +} + +// OrgRepositories returns the OrgRepositoriesClient handling sets of repositories in an organization. +func (c *Client) OrgRepositories() gitprovider.OrgRepositoriesClient { + return c.orgRepos +} + +// UserRepositories returns the UserRepositoriesClient handling sets of repositories for a user. +func (c *Client) UserRepositories() gitprovider.UserRepositoriesClient { + return c.userRepos +} diff --git a/gitlab/client_organization_teams.go b/gitlab/client_organization_teams.go new file mode 100644 index 00000000..644e79cc --- /dev/null +++ b/gitlab/client_organization_teams.go @@ -0,0 +1,104 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "context" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/xanzy/go-gitlab" +) + +// TeamsClient implements the gitprovider.TeamsClient interface. +var _ gitprovider.TeamsClient = &TeamsClient{} + +// TeamsClient handles teams organization-wide. +type TeamsClient struct { + *clientContext + ref gitprovider.OrganizationRef +} + +// Get a team within the specific organization. +// +// teamName may include slashes, to point to e.g. subgroups in GitLab. +// teamName must not be an empty string. +// +// ErrNotFound is returned if the resource does not exist. +func (c *TeamsClient) Get(ctx context.Context, teamName string) (gitprovider.Team, error) { + apiObjs, err := c.c.ListGroupMembers(ctx, c.ref.Organization) + if err != nil { + return nil, err + } + + // Collect a list of the members' names. Login is validated to be non-nil in ListOrgTeamMembers. + logins := make([]string, 0, len(apiObjs)) + for _, apiObj := range apiObjs { + // Login is validated to be non-nil in ListOrgTeamMembers + logins = append(logins, apiObj.Username) + } + + return &team{ + users: apiObjs, + info: gitprovider.TeamInfo{ + Name: teamName, + Members: logins, + }, + ref: c.ref, + }, nil +} + +// List all teams (recursively, in terms of subgroups) within the specific organization. +// +// List returns all available organizations, using multiple paginated requests if needed. +func (c *TeamsClient) List(ctx context.Context) ([]gitprovider.Team, error) { + subgroups, err := c.c.ListSubgroups(ctx, c.ref.Organization) + if err != nil { + return nil, err + } + + teams := make([]gitprovider.Team, 0, len(subgroups)) + for _, subgroup := range subgroups { + team, err := c.Get(ctx, subgroup.Name) + if err != nil { + return nil, err + } + + teams = append(teams, team) + } + + return teams, nil +} + +var _ gitprovider.Team = &team{} + +type team struct { + users []*gitlab.GroupMember + info gitprovider.TeamInfo + ref gitprovider.OrganizationRef +} + +func (t *team) Get() gitprovider.TeamInfo { + return t.info +} + +func (t *team) APIObject() interface{} { + return t.users +} + +func (t *team) Organization() gitprovider.OrganizationRef { + return t.ref +} diff --git a/gitlab/client_organizations.go b/gitlab/client_organizations.go new file mode 100644 index 00000000..c39c08a2 --- /dev/null +++ b/gitlab/client_organizations.go @@ -0,0 +1,89 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "context" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +// OrganizationsClient implements the gitprovider.OrganizationsClient interface. +var _ gitprovider.OrganizationsClient = &OrganizationsClient{} + +// OrganizationsClient operates on the groups the user has access to. +type OrganizationsClient struct { + *clientContext +} + +// Get a specific group the user has access to. +// This can refer to a sub-group in GitLab. +// +// ErrNotFound is returned if the resource does not exist. +func (c *OrganizationsClient) Get(ctx context.Context, ref gitprovider.OrganizationRef) (gitprovider.Organization, error) { + // GET /groups/{group} + apiObj, err := c.c.GetGroup(ctx, ref.Organization) + if err != nil { + return nil, err + } + + return newOrganization(c.clientContext, apiObj, ref), nil +} + +// List all groups the specific user has access to. +// +// List returns all available groups, using multiple paginated requests if needed. +func (c *OrganizationsClient) List(ctx context.Context) ([]gitprovider.Organization, error) { + // GET /groups + apiObjs, err := c.c.ListGroups(ctx) + if err != nil { + return nil, err + } + + groups := make([]gitprovider.Organization, 0, len(apiObjs)) + for _, apiObj := range apiObjs { + ref := gitprovider.OrganizationRef{ + Domain: apiObj.WebURL, + Organization: apiObj.FullName, + } + groups = append(groups, newOrganization(c.clientContext, apiObj, ref)) + } + + return groups, nil +} + +// Children returns the immediate child-organizations for the specific OrganizationRef o. +// The OrganizationRef may point to any existing sub-organization. +// +// Children returns all available organizations, using multiple paginated requests if needed. +func (c *OrganizationsClient) Children(ctx context.Context, ref gitprovider.OrganizationRef) ([]gitprovider.Organization, error) { + apiObjs, err := c.c.ListSubgroups(ctx, ref.Organization) + if err != nil { + return nil, err + } + + subgroups := make([]gitprovider.Organization, 0, len(apiObjs)) + for _, apiObj := range apiObjs { + ref := gitprovider.OrganizationRef{ + Domain: apiObj.WebURL, + Organization: apiObj.FullName, + } + subgroups = append(subgroups, newOrganization(c.clientContext, apiObj, ref)) + } + + return subgroups, nil +} diff --git a/gitlab/client_repositories_org.go b/gitlab/client_repositories_org.go new file mode 100644 index 00000000..12767046 --- /dev/null +++ b/gitlab/client_repositories_org.go @@ -0,0 +1,159 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "context" + "errors" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/xanzy/go-gitlab" +) + +// OrgRepositoriesClient implements the gitprovider.OrgRepositoriesClient interface. +var _ gitprovider.OrgRepositoriesClient = &OrgRepositoriesClient{} + +// OrgRepositoriesClient operates on repositories the user has access to. +type OrgRepositoriesClient struct { + *clientContext +} + +// Get returns the repository at the given path. +// +// ErrNotFound is returned if the resource does not exist. +func (c *OrgRepositoriesClient) Get(ctx context.Context, ref gitprovider.OrgRepositoryRef) (gitprovider.OrgRepository, error) { + // Make sure the OrgRepositoryRef is valid + if err := validateOrgRepositoryRef(ref, c.domain); err != nil { + return nil, err + } + // GET /groups/{group}/projects + apiObj, err := c.c.GetGroupProject(ctx, ref.OrganizationRef.Organization, ref.RepositoryName) + if err != nil { + return nil, err + } + return newGroupProject(c.clientContext, apiObj, ref), nil +} + +// List all repositories in the given organization. +// +// List returns all available repositories, using multiple paginated requests if needed. +func (c *OrgRepositoriesClient) List(ctx context.Context, ref gitprovider.OrganizationRef) ([]gitprovider.OrgRepository, error) { + // Make sure the OrganizationRef is valid + if err := validateOrganizationRef(ref, c.domain); err != nil { + return nil, err + } + + // GET /orgs/{org}/repos + apiObjs, err := c.c.ListGroupProjects(ctx, ref.Organization) + if err != nil { + return nil, err + } + + // Traverse the list, and return a list of OrgRepository objects + repos := make([]gitprovider.OrgRepository, 0, len(apiObjs)) + for _, apiObj := range apiObjs { + // apiObj is already validated at ListOrgRepos + repos = append(repos, newGroupProject(c.clientContext, apiObj, gitprovider.OrgRepositoryRef{ + OrganizationRef: ref, + RepositoryName: apiObj.Name, + })) + } + return repos, nil +} + +// Create creates a repository for the given organization, with the data and options. +// +// ErrAlreadyExists will be returned if the resource already exists. +func (c *OrgRepositoriesClient) Create(ctx context.Context, ref gitprovider.OrgRepositoryRef, req gitprovider.RepositoryInfo, opts ...gitprovider.RepositoryCreateOption) (gitprovider.OrgRepository, error) { + // Make sure the RepositoryRef is valid + if err := validateOrgRepositoryRef(ref, c.domain); err != nil { + return nil, err + } + + apiObj, err := createProject(ctx, c.c, ref, ref.Organization, req, opts...) + if err != nil { + return nil, err + } + return newGroupProject(c.clientContext, apiObj, ref), nil +} + +// Reconcile makes sure the given desired state (req) becomes the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be updated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +func (c *OrgRepositoriesClient) Reconcile(ctx context.Context, ref gitprovider.OrgRepositoryRef, req gitprovider.RepositoryInfo, opts ...gitprovider.RepositoryReconcileOption) (gitprovider.OrgRepository, bool, error) { + // First thing, validate and default the request to ensure a valid and fully-populated object + // (to minimize any possible diffs between desired and actual state) + if err := gitprovider.ValidateAndDefaultInfo(&req); err != nil { + return nil, false, err + } + + actual, err := c.Get(ctx, ref) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + resp, err := c.Create(ctx, ref, req, toCreateOpts(opts...)...) + return resp, true, err + } + + // Unexpected path, Get should succeed or return NotFound + return nil, false, err + } + actionTaken, err := reconcileRepository(ctx, actual, req) + return actual, actionTaken, err +} + +//nolint +func createProject(ctx context.Context, c gitlabClient, ref gitprovider.RepositoryRef, groupName string, req gitprovider.RepositoryInfo, opts ...gitprovider.RepositoryCreateOption) (*gitlab.Project, error) { + // First thing, validate and default the request to ensure a valid and fully-populated object + // (to minimize any possible diffs between desired and actual state) + if err := gitprovider.ValidateAndDefaultInfo(&req); err != nil { + return nil, err + } + + // Convert to the API object and apply the options + data := repositoryToAPI(&req, ref) + if len(groupName) > 0 { + data.Namespace = &gitlab.ProjectNamespace{ + Name: groupName, + } + } + return c.CreateProject(ctx, &data) +} + +func reconcileRepository(ctx context.Context, actual gitprovider.UserRepository, req gitprovider.RepositoryInfo) (bool, error) { + // If the desired matches the actual state, just return the actual state + if req.Equals(actual.Get()) { + return false, nil + } + // Populate the desired state to the current-actual object + if err := actual.Set(req); err != nil { + return false, err + } + // Apply the desired state by running Update + return true, actual.Update(ctx) +} + +func toCreateOpts(opts ...gitprovider.RepositoryReconcileOption) []gitprovider.RepositoryCreateOption { + // Convert RepositoryReconcileOption => RepositoryCreateOption + createOpts := make([]gitprovider.RepositoryCreateOption, 0, len(opts)) + for _, opt := range opts { + createOpts = append(createOpts, opt) + } + return createOpts +} diff --git a/gitlab/client_repositories_user.go b/gitlab/client_repositories_user.go new file mode 100644 index 00000000..3a643961 --- /dev/null +++ b/gitlab/client_repositories_user.go @@ -0,0 +1,123 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "context" + "errors" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +// UserRepositoriesClient implements the gitprovider.UserRepositoriesClient interface. +var _ gitprovider.UserRepositoriesClient = &UserRepositoriesClient{} + +// UserRepositoriesClient operates on repositories the user has access to. +type UserRepositoriesClient struct { + *clientContext +} + +// Get returns the repository at the given path. +// +// ErrNotFound is returned if the resource does not exist. +func (c *UserRepositoriesClient) Get(ctx context.Context, ref gitprovider.UserRepositoryRef) (gitprovider.UserRepository, error) { + // Make sure the UserRepositoryRef is valid + if err := validateUserRepositoryRef(ref, c.domain); err != nil { + return nil, err + } + // GET /repos/{owner}/{repo} + apiObj, err := c.c.GetUserProject(ctx, getRepoPath(ref)) + if err != nil { + return nil, err + } + return newUserProject(c.clientContext, apiObj, ref), nil +} + +// List all repositories in the given organization. +// +// List returns all available repositories, using multiple paginated requests if needed. +func (c *UserRepositoriesClient) List(ctx context.Context, ref gitprovider.UserRef) ([]gitprovider.UserRepository, error) { + // Make sure the UserRef is valid + if err := validateUserRef(ref, c.domain); err != nil { + return nil, err + } + + // GET /users/{username}/repos + apiObjs, err := c.c.ListUserProjects(ctx, ref.UserLogin) + if err != nil { + return nil, err + } + + // Traverse the list, and return a list of UserRepository objects + repos := make([]gitprovider.UserRepository, 0, len(apiObjs)) + for _, apiObj := range apiObjs { + // apiObj is already validated at ListUserRepos + repos = append(repos, newUserProject(c.clientContext, apiObj, gitprovider.UserRepositoryRef{ + UserRef: ref, + RepositoryName: apiObj.Name, + })) + } + return repos, nil +} + +// Create creates a repository for the given organization, with the data and options +// +// ErrAlreadyExists will be returned if the resource already exists. +func (c *UserRepositoriesClient) Create(ctx context.Context, + ref gitprovider.UserRepositoryRef, + req gitprovider.RepositoryInfo, + opts ...gitprovider.RepositoryCreateOption, +) (gitprovider.UserRepository, error) { + // Make sure the RepositoryRef is valid + if err := validateUserRepositoryRef(ref, c.domain); err != nil { + return nil, err + } + + apiObj, err := createProject(ctx, c.c, ref, "", req, opts...) + if err != nil { + return nil, err + } + return newUserProject(c.clientContext, apiObj, ref), nil +} + +// Reconcile makes sure the given desired state (req) becomes the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be updated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +func (c *UserRepositoriesClient) Reconcile(ctx context.Context, ref gitprovider.UserRepositoryRef, req gitprovider.RepositoryInfo, opts ...gitprovider.RepositoryReconcileOption) (gitprovider.UserRepository, bool, error) { + // First thing, validate and default the request to ensure a valid and fully-populated object + // (to minimize any possible diffs between desired and actual state) + if err := gitprovider.ValidateAndDefaultInfo(&req); err != nil { + return nil, false, err + } + + actual, err := c.Get(ctx, ref) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + resp, err := c.Create(ctx, ref, req, toCreateOpts(opts...)...) + return resp, true, err + } + + // Unexpected path, Get should succeed or return NotFound + return nil, false, err + } + + actionTaken, err := reconcileRepository(ctx, actual, req) + return actual, actionTaken, err +} diff --git a/gitlab/client_repository_deploykey.go b/gitlab/client_repository_deploykey.go new file mode 100644 index 00000000..524258ad --- /dev/null +++ b/gitlab/client_repository_deploykey.go @@ -0,0 +1,149 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "context" + "errors" + "fmt" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/xanzy/go-gitlab" +) + +// DeployKeyClient implements the gitprovider.DeployKeyClient interface. +var _ gitprovider.DeployKeyClient = &DeployKeyClient{} + +// DeployKeyClient operates on the access deploy key list for a specific repository. +type DeployKeyClient struct { + *clientContext + ref gitprovider.RepositoryRef +} + +// Get returns the repository at the given path. +// +// ErrNotFound is returned if the resource does not exist. +func (c *DeployKeyClient) Get(ctx context.Context, deployKeyName string) (gitprovider.DeployKey, error) { + return c.get(ctx, deployKeyName) +} + +func (c *DeployKeyClient) get(ctx context.Context, deployKeyName string) (*deployKey, error) { + deployKeys, err := c.list(ctx) + if err != nil { + return nil, err + } + // Loop through deploy keys once we find one with the right name + for _, dk := range deployKeys { + if dk.k.Title == deployKeyName { + return dk, nil + } + } + return nil, gitprovider.ErrNotFound +} + +// List lists all repository deploy keys of the given deploy key type. +// +// List returns all available repository deploy keys for the given type, +// using multiple paginated requests if needed. +func (c *DeployKeyClient) List(ctx context.Context) ([]gitprovider.DeployKey, error) { + dks, err := c.list(ctx) + if err != nil { + return nil, err + } + // Cast to the generic []gitprovider.DeployKey + keys := make([]gitprovider.DeployKey, 0, len(dks)) + for _, dk := range dks { + keys = append(keys, dk) + } + return keys, nil +} + +func (c *DeployKeyClient) list(ctx context.Context) ([]*deployKey, error) { + // GET /repos/{owner}/{repo}/keys + apiObjs, err := c.c.ListKeys(ctx, getRepoPath(c.ref)) + if err != nil { + return nil, err + } + + // Map the api object to our DeployKey type + keys := make([]*deployKey, 0, len(apiObjs)) + for _, apiObj := range apiObjs { + // apiObj is already validated at ListKeys + keys = append(keys, newDeployKey(c, apiObj)) + } + + return keys, nil +} + +// Create creates a deploy key with the given specifications. +// +// ErrAlreadyExists will be returned if the resource already exists. +func (c *DeployKeyClient) Create(ctx context.Context, req gitprovider.DeployKeyInfo) (gitprovider.DeployKey, error) { + apiObj, err := createDeployKey(ctx, c.c, c.ref, req) + if err != nil { + return nil, err + } + return newDeployKey(c, apiObj), nil +} + +// Reconcile makes sure the given desired state (req) becomes the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be deleted and recreated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +func (c *DeployKeyClient) Reconcile(ctx context.Context, req gitprovider.DeployKeyInfo) (gitprovider.DeployKey, bool, error) { + // First thing, validate and default the request to ensure a valid and fully-populated object + // (to minimize any possible diffs between desired and actual state) + if err := gitprovider.ValidateAndDefaultInfo(&req); err != nil { + return nil, false, err + } + + // Get the key with the desired name + actual, err := c.Get(ctx, req.Name) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + resp, err := c.Create(ctx, req) + return resp, true, err + } + + // Unexpected path, Get should succeed or return NotFound + return nil, false, err + } + + // If the desired matches the actual state, just return the actual state + if req.Equals(actual.Get()) { + return actual, false, nil + } + + // Populate the desired state to the current-actual object + if err := actual.Set(req); err != nil { + return actual, false, err + } + // Apply the desired state by running Update + return actual, true, actual.Update(ctx) +} + +func createDeployKey(ctx context.Context, c gitlabClient, ref gitprovider.RepositoryRef, req gitprovider.DeployKeyInfo) (*gitlab.DeployKey, error) { + // First thing, validate and default the request to ensure a valid and fully-populated object + // (to minimize any possible diffs between desired and actual state) + if err := gitprovider.ValidateAndDefaultInfo(&req); err != nil { + return nil, err + } + + return c.CreateKey(ctx, fmt.Sprintf("%s/%s", ref.GetIdentity(), ref.GetRepository()), deployKeyToAPI(&req)) +} diff --git a/gitlab/client_repository_teamaccess.go b/gitlab/client_repository_teamaccess.go new file mode 100644 index 00000000..9a3ff455 --- /dev/null +++ b/gitlab/client_repository_teamaccess.go @@ -0,0 +1,164 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "context" + "errors" + "strings" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +// TeamAccessClient implements the gitprovider.TeamAccessClient interface. +var _ gitprovider.TeamAccessClient = &TeamAccessClient{} + +// TeamAccessClient operates on the teams list for a specific repository. +type TeamAccessClient struct { + *clientContext + ref gitprovider.RepositoryRef +} + +// Get a team within the specific organization. +// +// name may include slashes, but must not be an empty string. +// Teams are sub-groups in GitLab. +// +// ErrNotFound is returned if the resource does not exist. +func (c *TeamAccessClient) Get(ctx context.Context, teamName string) (gitprovider.TeamAccess, error) { + // Get the project ID of the team, required for when team is a subgroup + teamObj, err := c.c.GetGroup(ctx, teamName) + if err != nil { + return nil, err + } + + project, err := c.c.GetGroupProject(ctx, c.ref.GetIdentity(), c.ref.GetRepository()) + if err != nil { + return nil, err + } + + for _, group := range project.SharedWithGroups { + if group.GroupID == teamObj.ID { + gitProviderPermission, err := getGitProviderPermission(group.GroupAccessLevel) + if err != nil { + return nil, err + } + + return newTeamAccess(c, gitprovider.TeamAccessInfo{ + Name: teamName, + Permission: gitProviderPermission, + }), nil + } + } + if err != nil { + return nil, err + } + return nil, gitprovider.ErrNotFound +} + +// List lists the team access control list for this repository. +// +// List returns all available team access lists, using multiple paginated requests if needed. +func (c *TeamAccessClient) List(ctx context.Context) ([]gitprovider.TeamAccess, error) { + // List all teams, using pagination. This does not contain information about the members + project, err := c.c.GetUserProject(ctx, getRepoPath(c.ref)) + if err != nil { + return nil, err + } + + result := []gitprovider.TeamAccess{} + for _, group := range project.SharedWithGroups { + gitProviderPermission, err := getGitProviderPermission(group.GroupAccessLevel) + if err != nil { + return nil, err + } + fullGroupObj, err := c.c.GetGroup(ctx, group.GroupID) + if err != nil { + return nil, err + } + // Append group by its full name with white spaces trimmed, so that it can be found in the reconciliation + result = append(result, newTeamAccess(c, gitprovider.TeamAccessInfo{ + Name: strings.Replace(fullGroupObj.FullName, " ", "", -1), + Permission: gitProviderPermission, + })) + } + + return result, nil +} + +// Create adds a given team to the repo's team access control list. +// +// ErrAlreadyExists will be returned if the resource already exists. +func (c *TeamAccessClient) Create(ctx context.Context, req gitprovider.TeamAccessInfo) (gitprovider.TeamAccess, error) { + // First thing, validate and default the request to ensure a valid and fully-populated object + // (to minimize any possible diffs between desired and actual state) + if err := gitprovider.ValidateAndDefaultInfo(&req); err != nil { + return nil, err + } + group, err := c.c.GetGroup(ctx, req.Name) + if err != nil { + return nil, err + } + + gitlabPermission, err := getGitlabPermission(*req.Permission) + if err != nil { + return nil, err + } + if err := c.c.ShareProject(ctx, getRepoPath(c.ref), group.ID, gitlabPermission); err != nil { + return nil, err + } + + return newTeamAccess(c, req), nil +} + +// Reconcile makes sure the given desired state (req) becomes the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be deleted and recreated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +func (c *TeamAccessClient) Reconcile(ctx context.Context, + req gitprovider.TeamAccessInfo, +) (gitprovider.TeamAccess, bool, error) { + // First thing, validate and default the request to ensure a valid and fully-populated object + // (to minimize any possible diffs between desired and actual state) + if err := gitprovider.ValidateAndDefaultInfo(&req); err != nil { + return nil, false, err + } + + actual, err := c.Get(ctx, req.Name) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + resp, err := c.Create(ctx, req) + return resp, true, err + } + + // Unexpected path, Get should succeed or return NotFound + return nil, false, err + } + + // If the desired matches the actual state, just return the actual state + if req.Equals(actual.Get()) { + return actual, false, nil + } + + // Populate the desired state to the current-actual object + if err := actual.Set(req); err != nil { + return actual, false, err + } + return actual, true, actual.Update(ctx) +} diff --git a/gitlab/doc.go b/gitlab/doc.go deleted file mode 100644 index d7df82ab..00000000 --- a/gitlab/doc.go +++ /dev/null @@ -1,22 +0,0 @@ -/* -Copyright 2020 The Flux CD contributors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package gitlab - -import ( - // TODO: Dummy import until we have the implementation ready. - _ "github.com/xanzy/go-gitlab" -) diff --git a/gitlab/example_organization_test.go b/gitlab/example_organization_test.go new file mode 100644 index 00000000..b4251b48 --- /dev/null +++ b/gitlab/example_organization_test.go @@ -0,0 +1,42 @@ +package gitlab_test + +import ( + "context" + "fmt" + "log" + "os" + "testing" + + "github.com/fluxcd/go-git-providers/gitlab" + "github.com/fluxcd/go-git-providers/gitprovider" + gogitlab "github.com/xanzy/go-gitlab" +) + +// checkErr is used for examples in this repository. +func checkErr(err error) { + if err != nil { + log.Fatal(err) + } +} + +func TestExampleOrganizationsClient_Get(t *testing.T) { + // Create a new client + ctx := context.Background() + c, err := gitlab.NewClient(os.Getenv("GITLAB_TOKEN"), "") + checkErr(err) + + // Get public information about the fluxcd organization + org, err := c.Organizations().Get(ctx, gitprovider.OrganizationRef{ + Domain: gitlab.DefaultDomain, + Organization: "fluxcd-testing-public", + }) + checkErr(err) + + // Use .Get() to aquire a high-level gitprovider.OrganizationInfo struct + orgInfo := org.Get() + // Cast the internal object to a *gogithub.Organization to access custom data + internalOrg := org.APIObject().(*gogitlab.Group) + + fmt.Printf("Name: %s. Location: %s.", *orgInfo.Name, internalOrg.Path) + // Output: Name: Flux project. Location: CNCF sandbox. +} diff --git a/gitlab/example_repository_test.go b/gitlab/example_repository_test.go new file mode 100644 index 00000000..bb7f2f4e --- /dev/null +++ b/gitlab/example_repository_test.go @@ -0,0 +1,34 @@ +package gitlab_test + +import ( + "context" + "fmt" + "os" + + "github.com/fluxcd/go-git-providers/gitlab" + "github.com/fluxcd/go-git-providers/gitprovider" + gogitlab "github.com/xanzy/go-gitlab" +) + +func ExampleOrgRepositoriesClient_Get() { + // Create a new client + ctx := context.Background() + c, err := gitlab.NewClient(os.Getenv("GITLAB_TOKEN"), "") + checkErr(err) + + // Parse the URL into an OrgRepositoryRef + ref, err := gitprovider.ParseOrgRepositoryURL("https://gitlab.com/gitlab-org/gitlab-foss") + checkErr(err) + + // Get public information about the flux repository. + repo, err := c.OrgRepositories().Get(ctx, *ref) + checkErr(err) + + // Use .Get() to aquire a high-level gitprovider.OrganizationInfo struct + repoInfo := repo.Get() + // Cast the internal object to a *gogithub.Repository to access custom data + internalRepo := repo.APIObject().(*gogitlab.Project) + + fmt.Printf("Description: %s. Homepage: %s", *repoInfo.Description, internalRepo.HTTPURLToRepo) + // Output: Description: GitLab FOSS is a read-only mirror of GitLab, with all proprietary code removed. This project was previously used to host GitLab Community Edition, but all development has now moved to https://gitlab.com/gitlab-org/gitlab.. Homepage: https://gitlab.com/gitlab-org/gitlab-foss.git +} diff --git a/gitlab/gitlabclient.go b/gitlab/gitlabclient.go new file mode 100644 index 00000000..3d638088 --- /dev/null +++ b/gitlab/gitlabclient.go @@ -0,0 +1,382 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "context" + "fmt" + "strings" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/xanzy/go-gitlab" +) + +// gitlabClientImpl is a wrapper around *github.Client, which implements higher-level methods, +// operating on the go-github structs. Pagination is implemented for all List* methods, all returned +// objects are validated, and HTTP errors are handled/wrapped using handleHTTPError. +// This interface is also fakeable, in order to unit-test the client. +type gitlabClient interface { + // Client returns the underlying *github.Client + Client() *gitlab.Client + + // Group methods + + // GetGroup is a wrapper for "GET /groups/{group}". + // This function HTTP error wrapping, and validates the server result. + GetGroup(ctx context.Context, groupID interface{}) (*gitlab.Group, error) + // ListGroups is a wrapper for "GET /groups". + // This function handles pagination, HTTP error wrapping, and validates the server result. + ListGroups(ctx context.Context) ([]*gitlab.Group, error) + // ListSubgroups is a wrapper for "GET /groups/{group}/subgroups". + // This function handles pagination, HTTP error wrapping, and validates the server result. + ListSubgroups(ctx context.Context, groupName string) ([]*gitlab.Group, error) + // ListGroupMembers is a wrapper for "GET /groups/{group}/members". + // This function handles pagination, HTTP error wrapping, and validates the server result. + ListGroupMembers(ctx context.Context, groupName string) ([]*gitlab.GroupMember, error) + + // Project methods + + // GetProject is a wrapper for "GET /projects/{project}". + // This function handles HTTP error wrapping, and validates the server result. + GetGroupProject(ctx context.Context, groupName string, projectName string) (*gitlab.Project, error) + // ListGroupProjects is a wrapper for "GET /groups/{group}/projects". + // This function handles pagination, HTTP error wrapping, and validates the server result. + ListGroupProjects(ctx context.Context, groupName string) ([]*gitlab.Project, error) + // GetProject is a wrapper for "GET /projects/{project}". + // This function handles HTTP error wrapping, and validates the server result. + GetUserProject(ctx context.Context, projectName string) (*gitlab.Project, error) + // ListUserProjects is a wrapper for "GET /users/{username}/projects". + // This function handles pagination, HTTP error wrapping, and validates the server result. + ListUserProjects(ctx context.Context, username string) ([]*gitlab.Project, error) + // ListProjectUsers is a wrapper for "GET /projects/{project}/users". + // This function handles pagination, HTTP error wrapping, and validates the server result. + ListProjectUsers(ctx context.Context, projectName string) ([]*gitlab.ProjectUser, error) + // CreateProject is a wrapper for "POST /projects" + // This function handles HTTP error wrapping, and validates the server result. + CreateProject(ctx context.Context, req *gitlab.Project) (*gitlab.Project, error) + // UpdateProject is a wrapper for "PUT /projects/{project}". + // This function handles HTTP error wrapping, and validates the server result. + UpdateProject(ctx context.Context, req *gitlab.Project) (*gitlab.Project, error) + // DeleteProject is a wrapper for "DELETE /projects/{project}". + // This function handles HTTP error wrapping. + // DANGEROUS COMMAND: In order to use this, you must set destructiveActions to true. + DeleteProject(ctx context.Context, projectName string) error + + // Deploy key methods + + // ListKeys is a wrapper for "GET /projects/{project}/deploy_keys". + // This function handles pagination, HTTP error wrapping, and validates the server result. + ListKeys(ctx context.Context, projectName string) ([]*gitlab.DeployKey, error) + // CreateProjectKey is a wrapper for "POST /projects/{project}/deploy_keys". + // This function handles HTTP error wrapping, and validates the server result. + CreateKey(ctx context.Context, projectName string, req *gitlab.DeployKey) (*gitlab.DeployKey, error) + // DeleteKey is a wrapper for "DELETE /projects/{project}/deploy_keys/{key_id}". + // This function handles HTTP error wrapping. + DeleteKey(ctx context.Context, projectName string, keyID int) error + + // Team related methods + + // ShareGroup is a wrapper for "" + // This function handles HTTP error wrapping, and validates the server result. + ShareProject(ctx context.Context, projectName string, groupID, groupAccess int) error + // UnshareProject is a wrapper for "" + // This function handles HTTP error wrapping, and validates the server result. + UnshareProject(ctx context.Context, projectName string, groupID int) error +} + +// gitlabClientImpl is a wrapper around *gitlab.Client, which implements higher-level methods, +// operating on the go-gitlab structs. See the gitlabClient interface for method documentation. +// Pagination is implemented for all List* methods, all returned +// objects are validated, and HTTP errors are handled/wrapped using handleHTTPError. +type gitlabClientImpl struct { + c *gitlab.Client + destructiveActions bool +} + +// gitlabClientImpl implements gitlabClient. +var _ gitlabClient = &gitlabClientImpl{} + +func (c *gitlabClientImpl) Client() *gitlab.Client { + return c.c +} + +func (c *gitlabClientImpl) GetGroup(ctx context.Context, groupID interface{}) (*gitlab.Group, error) { + apiObj, _, err := c.c.Groups.GetGroup(groupID, gitlab.WithContext(ctx)) + if err != nil { + return nil, err + } + // Validate the API object + if err := validateGroupAPI(apiObj); err != nil { + return nil, err + } + return apiObj, nil +} + +func (c *gitlabClientImpl) ListGroups(ctx context.Context) ([]*gitlab.Group, error) { + apiObjs := []*gitlab.Group{} + opts := &gitlab.ListGroupsOptions{} + err := allGroupPages(opts, func() (*gitlab.Response, error) { + // GET /groups + pageObjs, resp, listErr := c.c.Groups.ListGroups(opts, gitlab.WithContext(ctx)) + apiObjs = append(apiObjs, pageObjs...) + return resp, listErr + }) + if err != nil { + return nil, err + } + // Validate the API objects + for _, apiObj := range apiObjs { + if err := validateGroupAPI(apiObj); err != nil { + return nil, err + } + } + return apiObjs, nil +} + +func (c *gitlabClientImpl) ListSubgroups(ctx context.Context, groupName string) ([]*gitlab.Group, error) { + var apiObjs []*gitlab.Group + opts := &gitlab.ListSubgroupsOptions{} + err := allSubgroupPages(opts, func() (*gitlab.Response, error) { + // GET /groups + pageObjs, resp, listErr := c.c.Groups.ListSubgroups(groupName, opts, gitlab.WithContext(ctx)) + apiObjs = append(apiObjs, pageObjs...) + return resp, listErr + }) + if err != nil { + return nil, err + } + // Validate the API objects + for _, apiObj := range apiObjs { + if err := validateGroupAPI(apiObj); err != nil { + return nil, err + } + } + return apiObjs, nil +} + +func (c *gitlabClientImpl) GetGroupProject(ctx context.Context, groupName string, projectName string) (*gitlab.Project, error) { + opts := &gitlab.GetProjectOptions{} + apiObj, _, err := c.c.Projects.GetProject(fmt.Sprintf("%s/%s", strings.ToLower(groupName), projectName), opts, gitlab.WithContext(ctx)) + return validateProjectAPIResp(apiObj, err) +} + +func (c *gitlabClientImpl) ListGroupProjects(ctx context.Context, groupName string) ([]*gitlab.Project, error) { + var apiObjs []*gitlab.Project + opts := &gitlab.ListGroupProjectsOptions{} + err := allGroupProjectPages(opts, func() (*gitlab.Response, error) { + pageObjs, resp, listErr := c.c.Groups.ListGroupProjects(groupName, opts, gitlab.WithContext(ctx)) + apiObjs = append(apiObjs, pageObjs...) + return resp, listErr + }) + if err != nil { + return nil, err + } + return validateProjectObjects(apiObjs) +} + +func validateProjectObjects(apiObjs []*gitlab.Project) ([]*gitlab.Project, error) { + for _, apiObj := range apiObjs { + // Make sure apiObj is valid + if err := validateProjectAPI(apiObj); err != nil { + return nil, err + } + } + return apiObjs, nil +} + +func (c *gitlabClientImpl) ListGroupMembers(ctx context.Context, groupName string) ([]*gitlab.GroupMember, error) { + var apiObjs []*gitlab.GroupMember + opts := &gitlab.ListGroupMembersOptions{} + err := allGroupMemberPages(opts, func() (*gitlab.Response, error) { + // GET /groups/{group}/members + pageObjs, resp, listErr := c.c.Groups.ListGroupMembers(groupName, opts, gitlab.WithContext(ctx)) + apiObjs = append(apiObjs, pageObjs...) + return resp, listErr + }) + if err != nil { + return nil, err + } + return apiObjs, nil +} + +func (c *gitlabClientImpl) GetUserProject(ctx context.Context, projectName string) (*gitlab.Project, error) { + opts := &gitlab.GetProjectOptions{} + apiObj, _, err := c.c.Projects.GetProject(projectName, opts, gitlab.WithContext(ctx)) + return validateProjectAPIResp(apiObj, err) +} + +func validateProjectAPIResp(apiObj *gitlab.Project, err error) (*gitlab.Project, error) { + // If the response contained an error, return + if err != nil { + return nil, handleHTTPError(err) + } + // Make sure apiObj is valid + if err := validateProjectAPI(apiObj); err != nil { + return nil, err + } + return apiObj, nil +} + +func (c *gitlabClientImpl) ListProjects(ctx context.Context) ([]*gitlab.Project, error) { + var apiObjs []*gitlab.Project + opts := &gitlab.ListProjectsOptions{} + err := allProjectPages(opts, func() (*gitlab.Response, error) { + // GET /projects + pageObjs, resp, listErr := c.c.Projects.ListProjects(opts, gitlab.WithContext(ctx)) + apiObjs = append(apiObjs, pageObjs...) + return resp, listErr + }) + if err != nil { + return nil, err + } + return apiObjs, nil +} + +func (c *gitlabClientImpl) ListProjectUsers(ctx context.Context, projectName string) ([]*gitlab.ProjectUser, error) { + var apiObjs []*gitlab.ProjectUser + opts := &gitlab.ListProjectUserOptions{} + err := allProjectUserPages(opts, func() (*gitlab.Response, error) { + // GET /projects/{project}/users + pageObjs, resp, listErr := c.c.Projects.ListProjectsUsers(projectName, opts, gitlab.WithContext(ctx)) + apiObjs = append(apiObjs, pageObjs...) + return resp, listErr + }) + if err != nil { + return nil, err + } + return apiObjs, nil +} + +func (c *gitlabClientImpl) ListUserProjects(ctx context.Context, username string) ([]*gitlab.Project, error) { + var apiObjs []*gitlab.Project + opts := &gitlab.ListProjectsOptions{} + err := allProjectPages(opts, func() (*gitlab.Response, error) { + // GET /projects/{project}/users + pageObjs, resp, listErr := c.c.Projects.ListUserProjects(username, opts, gitlab.WithContext(ctx)) + apiObjs = append(apiObjs, pageObjs...) + return resp, listErr + }) + if err != nil { + return nil, err + } + return apiObjs, nil +} + +func (c *gitlabClientImpl) CreateProject(ctx context.Context, req *gitlab.Project) (*gitlab.Project, error) { + var namespaceID int + // If the project doesn't belong to a user set its namespace ID + if req.Namespace != nil && req.Namespace.Kind != "user" { + group, err := c.GetGroup(ctx, req.Namespace.Name) + if err != nil { + return nil, err + } + namespaceID = group.ID + } + opts := &gitlab.CreateProjectOptions{ + Name: &req.Name, + DefaultBranch: &req.DefaultBranch, + Description: &req.Description, + Visibility: &req.Visibility, + } + if namespaceID != 0 { + opts.NamespaceID = &namespaceID + } + + apiObj, _, err := c.c.Projects.CreateProject(opts, gitlab.WithContext(ctx)) + return validateProjectAPIResp(apiObj, err) +} + +func (c *gitlabClientImpl) UpdateProject(ctx context.Context, req *gitlab.Project) (*gitlab.Project, error) { + opts := &gitlab.EditProjectOptions{ + Name: &req.Name, + Description: &req.Description, + Visibility: &req.Visibility, + } + apiObj, _, err := c.c.Projects.EditProject(req.ID, opts, gitlab.WithContext(ctx)) + return validateProjectAPIResp(apiObj, err) +} + +func (c *gitlabClientImpl) DeleteProject(ctx context.Context, projectName string) error { + // Don't allow deleting repositories if the user didn't explicitly allow dangerous API calls. + if !c.destructiveActions { + return fmt.Errorf("cannot delete repository: %w", gitprovider.ErrDestructiveCallDisallowed) + } + // DELETE /projects/{project} + _, err := c.c.Projects.DeleteProject(projectName, gitlab.WithContext(ctx)) + return err +} + +func (c *gitlabClientImpl) ListKeys(ctx context.Context, projectName string) ([]*gitlab.DeployKey, error) { + apiObjs := []*gitlab.DeployKey{} + opts := &gitlab.ListProjectDeployKeysOptions{} + err := allDeployKeyPages(opts, func() (*gitlab.Response, error) { + // GET /projects/{project}/deploy_keys + pageObjs, resp, listErr := c.c.DeployKeys.ListProjectDeployKeys(projectName, opts) + apiObjs = append(apiObjs, pageObjs...) + return resp, listErr + }) + if err != nil { + return nil, err + } + + for _, apiObj := range apiObjs { + if err := validateDeployKeyAPI(apiObj); err != nil { + return nil, err + } + } + return apiObjs, nil +} + +func (c *gitlabClientImpl) CreateKey(ctx context.Context, projectName string, req *gitlab.DeployKey) (*gitlab.DeployKey, error) { + opts := &gitlab.AddDeployKeyOptions{ + Title: &req.Title, + Key: &req.Key, + CanPush: req.CanPush, + } + // POST /projects/{project}/deploy_keys + apiObj, _, err := c.c.DeployKeys.AddDeployKey(projectName, opts) + if err != nil { + return nil, handleHTTPError(err) + } + if err := validateDeployKeyAPI(apiObj); err != nil { + return nil, err + } + return apiObj, nil +} + +func (c *gitlabClientImpl) DeleteKey(ctx context.Context, projectName string, keyID int) error { + // DELETE /projects/{project}/deploy_keys + _, err := c.c.DeployKeys.DeleteDeployKey(projectName, keyID) + return handleHTTPError(err) +} + +func (c *gitlabClientImpl) ShareProject(ctx context.Context, projectName string, groupIDObj, groupAccessObj int) error { + groupAccess := gitlab.AccessLevel(gitlab.AccessLevelValue(groupAccessObj)) + groupID := &groupIDObj + opt := &gitlab.ShareWithGroupOptions{ + GroupID: groupID, + GroupAccess: groupAccess, + } + + _, err := c.c.Projects.ShareProjectWithGroup(projectName, opt) + return handleHTTPError(err) +} + +func (c *gitlabClientImpl) UnshareProject(ctx context.Context, projectName string, groupID int) error { + _, err := c.c.Projects.DeleteSharedProjectFromGroup(projectName, groupID) + return handleHTTPError(err) +} diff --git a/gitlab/integration_test.go b/gitlab/integration_test.go new file mode 100644 index 00000000..4cd7b774 --- /dev/null +++ b/gitlab/integration_test.go @@ -0,0 +1,701 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + "math/rand" + "net/http" + "os" + "sync" + "testing" + "time" + + "github.com/gregjones/httpcache" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/xanzy/go-gitlab" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +const ( + ghTokenFile = "/tmp/gitlab-token" + + // Include scheme if custom, e.g.: + // gitlabDomain = "https://gitlab.acme.org/" + // gitlabDomain = "https://gitlab.dev.wkp.weave.works" + gitlabDomain = "gitlab.com" + + defaultDescription = "Foo description" + defaultBranch = "master" +) + +var ( + // customTransportImpl is a shared instance of a customTransport, allowing counting of cache hits. + customTransportImpl *customTransport +) + +func init() { + // Call testing.Init() prior to tests.NewParams(), as otherwise -test.* will not be recognised. See also: https://golang.org/doc/go1.13#testing + testing.Init() + rand.Seed(time.Now().UnixNano()) +} + +func TestProvider(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "GitLab Provider Suite") +} + +func customTransportFactory(transport http.RoundTripper) http.RoundTripper { + if customTransportImpl != nil { + panic("didn't expect this function to be called twice") + } + customTransportImpl = &customTransport{ + transport: transport, + countCacheHits: false, + cacheHits: 0, + mux: &sync.Mutex{}, + } + return customTransportImpl +} + +type customTransport struct { + transport http.RoundTripper + countCacheHits bool + cacheHits int + mux *sync.Mutex +} + +func (t *customTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.mux.Lock() + defer t.mux.Unlock() + + resp, err := t.transport.RoundTrip(req) + // If we should count, count all cache hits whenever found + if t.countCacheHits { + if _, ok := resp.Header[httpcache.XFromCache]; ok { + t.cacheHits++ + } + } + return resp, err +} + +func (t *customTransport) resetCounter() { + t.mux.Lock() + defer t.mux.Unlock() + + t.cacheHits = 0 +} + +func (t *customTransport) setCounter(state bool) { + t.mux.Lock() + defer t.mux.Unlock() + + t.countCacheHits = state +} + +func (t *customTransport) getCacheHits() int { + t.mux.Lock() + defer t.mux.Unlock() + + return t.cacheHits +} + +func (t *customTransport) countCacheHitsForFunc(fn func()) int { + t.setCounter(true) + t.resetCounter() + fn() + t.setCounter(false) + return t.getCacheHits() +} + +var _ = Describe("GitLab Provider", func() { + var ( + ctx context.Context = context.Background() + c gitprovider.Client + + // Should exist in environment + testOrgName string = "fluxcd-testing" + testSubgroupName string = "fluxcd-testing-sub-group" + testTeamName string = "fluxcd-testing-2" + testUserName string = "fluxcd-gitprovider-bot" + + // placeholders, will be randomized and created. + testSharedOrgRepoName string = "testsharedorgrepo" + testOrgRepoName string = "testorgrepo" + testRepoName string = "testrepo" + ) + + BeforeSuite(func() { + gitlabToken := os.Getenv("GITLAB_TOKEN") + if len(gitlabToken) == 0 { + b, err := ioutil.ReadFile(ghTokenFile) + if token := string(b); err == nil && len(token) != 0 { + gitlabToken = token + } else { + Fail("couldn't acquire GITLAB_TOKEN env variable") + } + } + + if orgName := os.Getenv("GIT_PROVIDER_ORGANIZATION"); len(orgName) != 0 { + testOrgName = orgName + } + + var err error + c, err = NewClient( + gitlabToken, "", + WithDomain(gitlabDomain), + WithDestructiveAPICalls(true), + WithConditionalRequests(true), + WithPreChainTransportHook(customTransportFactory), + ) + Expect(err).ToNot(HaveOccurred()) + }) + + validateOrgRepo := func(repo gitprovider.OrgRepository, expectedRepoRef gitprovider.RepositoryRef) { + info := repo.Get() + // Expect certain fields to be set + Expect(repo.Repository()).To(Equal(expectedRepoRef)) + Expect(*info.Description).To(Equal(defaultDescription)) + Expect(*info.Visibility).To(Equal(gitprovider.RepositoryVisibilityPrivate)) + Expect(*info.DefaultBranch).To(Equal(masterBranchName)) + // Expect high-level fields to match their underlying data + internal := repo.APIObject().(*gitlab.Project) + Expect(repo.Repository().GetRepository()).To(Equal(internal.Name)) + Expect(repo.Repository().GetIdentity()).To(Equal(testOrgName)) + Expect(*info.Description).To(Equal(internal.Description)) + Expect(string(*info.Visibility)).To(Equal(string(internal.Visibility))) + Expect(*info.DefaultBranch).To(Equal(internal.DefaultBranch)) + } + + validateUserRepo := func(repo gitprovider.UserRepository, expectedRepoRef gitprovider.RepositoryRef) { + info := repo.Get() + // Expect certain fields to be set + Expect(repo.Repository()).To(Equal(expectedRepoRef)) + Expect(*info.Description).To(Equal(defaultDescription)) + Expect(*info.Visibility).To(Equal(gitprovider.RepositoryVisibilityPrivate)) + Expect(*info.DefaultBranch).To(Equal(masterBranchName)) + // Expect high-level fields to match their underlying data + internal := repo.APIObject().(*gitlab.Project) + Expect(repo.Repository().GetRepository()).To(Equal(internal.Name)) + Expect(repo.Repository().GetIdentity()).To(Equal(testUserName)) + Expect(*info.Description).To(Equal(internal.Description)) + Expect(string(*info.Visibility)).To(Equal(string(internal.Visibility))) + Expect(*info.DefaultBranch).To(Equal(internal.DefaultBranch)) + } + + It("should list the available organizations the user has access to", func() { + // Get a list of all organizations the user is part of + orgs, err := c.Organizations().List(ctx) + Expect(err).ToNot(HaveOccurred()) + + // Make sure we find the expected one given as testOrgName + var listedOrg, getOrg gitprovider.Organization + for _, org := range orgs { + if org.Organization().Organization == testOrgName { + listedOrg = org + break + } + } + Expect(listedOrg).ToNot(BeNil()) + + hits := customTransportImpl.countCacheHitsForFunc(func() { + // Do a GET call for that organization + getOrg, err = c.Organizations().Get(ctx, listedOrg.Organization()) + Expect(err).ToNot(HaveOccurred()) + }) + // don't expect any cache hit, as we didn't request this before + Expect(hits).To(Equal(0)) + + // Expect that the organization's info is the same regardless of method + Expect(getOrg.Organization()).To(Equal(listedOrg.Organization())) + + Expect(listedOrg.Get().Name).ToNot(BeNil()) + Expect(listedOrg.Get().Description).ToNot(BeNil()) + // We expect the name and description to be populated + // in the GET call. Note: This requires the user to set up + // the given organization with a name and description in the UI :) + Expect(getOrg.Get().Name).ToNot(BeNil()) + Expect(getOrg.Get().Description).ToNot(BeNil()) + // Expect Name and Description to match their underlying data + internal := getOrg.APIObject().(*gitlab.Group) + derefOrgName := *getOrg.Get().Name + Expect(derefOrgName).To(Equal(internal.Name)) + derefOrgDescription := *getOrg.Get().Description + Expect(derefOrgDescription).To(Equal(internal.Description)) + + // Expect that when we do the same request a second time, it will hit the cache + hits = customTransportImpl.countCacheHitsForFunc(func() { + getOrg2, err := c.Organizations().Get(ctx, listedOrg.Organization()) + Expect(err).ToNot(HaveOccurred()) + Expect(getOrg2).ToNot(BeNil()) + }) + Expect(hits).To(Equal(1)) + }) + + It("should not fail when .Children is called", func() { + _, err := c.Organizations().Children(ctx, gitprovider.OrganizationRef{ + Domain: gitlabDomain, + Organization: testOrgName, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should be possible to create a group project", func() { + // First, check what repositories are available + repos, err := c.OrgRepositories().List(ctx, newOrgRef(testOrgName)) + Expect(err).ToNot(HaveOccurred()) + + // Generate a repository name which doesn't exist already + testOrgRepoName = fmt.Sprintf("test-org-repo-%03d", rand.Intn(1000)) + for findOrgRepo(repos, testOrgRepoName) != nil { + testOrgRepoName = fmt.Sprintf("test-org-repo-%03d", rand.Intn(1000)) + } + + // We know that a repo with this name doesn't exist in the organization, let's verify we get an + // ErrNotFound + repoRef := newOrgRepoRef(testOrgName, testOrgRepoName) + sshURL := repoRef.GetCloneURL(gitprovider.TransportTypeSSH) + Expect(sshURL).NotTo(Equal("")) + _, err = c.OrgRepositories().Get(ctx, repoRef) + Expect(errors.Is(err, gitprovider.ErrNotFound)).To(BeTrue()) + + // Create a new repo + repo, err := c.OrgRepositories().Create(ctx, repoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + // Default visibility is private, no need to set this at least now + //Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }, &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + LicenseTemplate: gitprovider.LicenseTemplateVar(gitprovider.LicenseTemplateApache2), + }) + Expect(err).ToNot(HaveOccurred()) + + validateOrgRepo(repo, repoRef) + + getRepo, err := c.OrgRepositories().Get(ctx, repoRef) + Expect(err).ToNot(HaveOccurred()) + // Expect the two responses (one from POST and one from GET to have equal "spec") + getSpec := newGitlabProjectSpec(getRepo.APIObject().(*gitlab.Project)) + postSpec := newGitlabProjectSpec(repo.APIObject().(*gitlab.Project)) + Expect(getSpec.Equals(postSpec)).To(BeTrue()) + }) + + It("should error at creation time if the org repo already does exist", func() { + repoRef := newOrgRepoRef(testOrgName, testOrgRepoName) + _, err := c.OrgRepositories().Create(ctx, repoRef, gitprovider.RepositoryInfo{}) + Expect(errors.Is(err, gitprovider.ErrAlreadyExists)).To(BeTrue()) + }) + + It("should update if the org repo already exists when reconciling", func() { + repoRef := newOrgRepoRef(testOrgName, testOrgRepoName) + // No-op reconcile + resp, actionTaken, err := c.OrgRepositories().Reconcile(ctx, repoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + DefaultBranch: gitprovider.StringVar(defaultBranch), + Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeFalse()) + // no-op set & reconcile + Expect(resp.Set(resp.Get())).ToNot(HaveOccurred()) + actionTaken, err = resp.Reconcile(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeFalse()) + + // Update reconcile + newDesc := "New description" + req := resp.Get() + req.Description = gitprovider.StringVar(newDesc) + Expect(resp.Set(req)).ToNot(HaveOccurred()) + actionTaken, err = resp.Reconcile(ctx) + // Expect the update to succeed, and modify the state + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeTrue()) + Expect(*resp.Get().Description).To(Equal(newDesc)) + + // Delete the repository and later re-create + Expect(resp.Delete(ctx)).ToNot(HaveOccurred()) + + // Reconcile and create + newRepo, actionTaken, err := c.OrgRepositories().Reconcile(ctx, repoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + }, &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + LicenseTemplate: gitprovider.LicenseTemplateVar(gitprovider.LicenseTemplateMIT), + }) + // Expect the create to succeed, and have modified the state. Also validate the newRepo data + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeTrue()) + validateOrgRepo(newRepo, repoRef) + }) + + It("should update teams with access and permissions when reconciling", func() { + + // Get the test organization + orgRef := newOrgRef(testOrgName) + testOrg, err := c.Organizations().Get(ctx, orgRef) + Expect(err).ToNot(HaveOccurred()) + + // List all the teams with access to the org + // There should be 1 existing subgroup already + teams, err := testOrg.Teams().List(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(len(teams)).To(Equal(1), "The 1 team wasn't there...") + + // First, check what repositories are available + repos, err := c.OrgRepositories().List(ctx, newOrgRef(testOrgName)) + Expect(err).ToNot(HaveOccurred()) + + // Generate an org repo name which doesn't exist already + testSharedOrgRepoName = fmt.Sprintf("test-shared-org-repo-%03d", rand.Intn(1000)) + for findOrgRepo(repos, testSharedOrgRepoName) != nil { + testSharedOrgRepoName = fmt.Sprintf("test-shared-org-repo-%03d", rand.Intn(1000)) + } + + // We know that a repo with this name doesn't exist in the organization, let's verify we get an + // ErrNotFound + repoRef := newOrgRepoRef(testOrgName, testSharedOrgRepoName) + _, err = c.OrgRepositories().Get(ctx, repoRef) + Expect(errors.Is(err, gitprovider.ErrNotFound)).To(BeTrue()) + + // Create a new repo + repo, err := c.OrgRepositories().Create(ctx, repoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + // Default visibility is private, no need to set this at least now + //Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }, &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + LicenseTemplate: gitprovider.LicenseTemplateVar(gitprovider.LicenseTemplateApache2), + }) + Expect(err).ToNot(HaveOccurred()) + + validateOrgRepo(repo, repoRef) + + // No groups should have access to the repo at this point + projectTeams, err := repo.TeamAccess().List(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(len(projectTeams)).To(Equal(0)) + + // Add a team to the project + permission := gitprovider.RepositoryPermissionMaintain + _, err = repo.TeamAccess().Create(ctx, gitprovider.TeamAccessInfo{ + Name: testTeamName, + Permission: &permission, + }) + Expect(err).ToNot(HaveOccurred()) + + // List all the teams with access to the project + // Only + projectTeams, err = repo.TeamAccess().List(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(len(projectTeams)).To(Equal(1), "Project teams didn't equal 1") + firstTeam := projectTeams[0] + Expect(firstTeam.Get().Name).To(Equal(testTeamName)) + + // Update the permission level and update + ta, err := repo.TeamAccess().Get(ctx, testTeamName) + Expect(err).ToNot(HaveOccurred()) + + // Set permission level to Push and call Reconcile + pushPermission := gitprovider.RepositoryPermissionPush + pushTeamAccess := ta + pushTeamAccessInfo := pushTeamAccess.Get() + pushTeamAccessInfo.Permission = &pushPermission + ta.Set(pushTeamAccessInfo) + actionTaken, err := ta.Reconcile(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(Equal(true)) + + // Get the team access info again and verify it has been updated + updatedTA, err := repo.TeamAccess().Get(ctx, testTeamName) + Expect(err).ToNot(HaveOccurred()) + Expect(*updatedTA.Get().Permission).To(Equal(gitprovider.RepositoryPermissionPush)) + + // What happens if a group project is shared with a subgroup + _, err = repo.TeamAccess().Create(ctx, gitprovider.TeamAccessInfo{ + Name: fmt.Sprintf("%s/%s", testOrgName, testSubgroupName), + Permission: &pushPermission, + }) + Expect(err).ToNot(HaveOccurred()) + + // See that the subgroup is listed + projectTeams, err = repo.TeamAccess().List(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(len(projectTeams)).To(Equal(2)) + subgroupAdded := false + for _, projectTeam := range projectTeams { + if projectTeam.Get().Name == fmt.Sprintf("%s/%s", testOrgName, testSubgroupName) { + subgroupAdded = true + break + } + } + Expect(subgroupAdded).To(Equal(true)) + + // Assert that reconciling on subgroups works + teamInfo := gitprovider.TeamAccessInfo{ + Name: testOrgName + "/" + testSubgroupName, + Permission: &pushPermission, + } + + ta, actionTaken, err = repo.TeamAccess().Reconcile(ctx, teamInfo) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(Equal(false)) + }) + + It("should create, delete and reconcile deploy keys", func() { + testDeployKeyName := "test-deploy-key" + repoRef := newOrgRepoRef(testOrgName, testSharedOrgRepoName) + + orgRepo, err := c.OrgRepositories().Get(ctx, repoRef) + Expect(err).ToNot(HaveOccurred()) + + // List keys should return 0 + keys, err := orgRepo.DeployKeys().List(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(len(keys)).To(Equal(0)) + + readOnly := false + testDeployKeyInfo := gitprovider.DeployKeyInfo{ + Name: testDeployKeyName, + Key: []byte("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDLbHBknQQaasdl2/O9DfgizMyUh/lhYwXk9GrBY9Ow9fFHy+lIiRBiS8H4rjvP2YkECrWWSbcevTKe+yk7PsU98RZiPL9S2+d2ENo3uQ2Rp6xnKY+XtvJnSvpLnABz/mGDPgvcLxXvPj2rAGu35u08DZ1WufU7hzgiWuLM3TH/albVcadw5ZflOAXalMmUhinB9m/O71DWyaP33pIqZBGCc8IBMcUHOL72NkNcpatXvCALltApJVUPZIvQUnrmUOglQMklaCeeWn6B269UI9kH9TjhGbbIvHpPZ7Ky9RTklZTeINLZW5Yql/leA/vJGcIipyXQkDPs7RSwtpmp5kat dinos@dinos-desktop"), + ReadOnly: &readOnly, + } + _, err = orgRepo.DeployKeys().Create(ctx, testDeployKeyInfo) + Expect(err).ToNot(HaveOccurred()) + + // List keys should now return 1 + keys, err = orgRepo.DeployKeys().List(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(len(keys)).To(Equal(1)) + + // Getting the key directly should return the same object + getKey, err := orgRepo.DeployKeys().Get(ctx, testDeployKeyName) + Expect(err).ToNot(HaveOccurred()) + + Expect(getKey.Get().Key).To(Equal(testDeployKeyInfo.Key)) + Expect(getKey.Get().Name).To(Equal(testDeployKeyInfo.Name)) + + Expect(getKey.Set(getKey.Get())).ToNot(HaveOccurred()) + actionTaken, err := getKey.Reconcile(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeFalse()) + + // Reconcile creates a new key if the title and key is different + title := "new-title" + req := getKey.Get() + req.Name = title + req.Key = []byte("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCasdHV91pRmqTaWPJnvZmvTPZPpHmIYocY1kmYFeOOL6/ofdvYb1sxNwsccOJEeLGJjp6FGe4BWNQSqDUCeO3EVU8A7ZTnd9eizB8nYDGoACbmG2GfMmtAdxKfsPE/lNRzAOFmHAHrzOnL6zk5SMPe0Y2poW1Z5w+If5r62WwfqG2/ujUA7BU3Vf/arFIYJvXvuEOJMP3QbezWL0b22Wmedu8esKrOYcak80I6Ti8qiof8ly1JZa58ezHJVvcEWZGSKU4G53jmDz7ky4GGb9DRo+LqOaU1qetdJX1GiCRNnhvz8DsxGcL77BJPE7HPBct44lN1TZCeIOG00Hai4bDp dinos@dinos-desktop") + Expect(getKey.Set(req)).ToNot(HaveOccurred()) + actionTaken, err = getKey.Reconcile(ctx) + // Expect the update to succeed, and modify the state + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeTrue()) + + getKey, err = orgRepo.DeployKeys().Get(ctx, title) + Expect(err).ToNot(HaveOccurred()) + Expect(getKey.Get().Name).To(Equal(title)) + + // Delete the keys + keys, err = orgRepo.DeployKeys().List(ctx) + Expect(err).ToNot(HaveOccurred()) + for _, key := range keys { + err = key.Delete(ctx) + Expect(err).ToNot(HaveOccurred()) + } + }) + + It("should be possible to create a user project", func() { + // First, check what repositories are available + repos, err := c.UserRepositories().List(ctx, newUserRef(testUserName)) + Expect(err).ToNot(HaveOccurred()) + + // Generate a repository name which doesn't exist already + testRepoName = fmt.Sprintf("test-repo-%03d", rand.Intn(1000)) + for findUserRepo(repos, testRepoName) != nil { + testRepoName = fmt.Sprintf("test-repo-%03d", rand.Intn(1000)) + } + + // We know that a repo with this name doesn't exist in the organization, let's verify we get an + // ErrNotFound + repoRef := newUserRepoRef(testUserName, testRepoName) + _, err = c.UserRepositories().Get(ctx, repoRef) + Expect(errors.Is(err, gitprovider.ErrNotFound)).To(BeTrue()) + + // Create a new repo + repo, err := c.UserRepositories().Create(ctx, repoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + // Default visibility is private, no need to set this at least now + //Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }, &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + LicenseTemplate: gitprovider.LicenseTemplateVar(gitprovider.LicenseTemplateApache2), + }) + Expect(err).ToNot(HaveOccurred()) + + validateUserRepo(repo, repoRef) + + getRepo, err := c.UserRepositories().Get(ctx, repoRef) + Expect(err).ToNot(HaveOccurred()) + // Expect the two responses (one from POST and one from GET to have equal "spec") + getSpec := newGitlabProjectSpec(getRepo.APIObject().(*gitlab.Project)) + postSpec := newGitlabProjectSpec(repo.APIObject().(*gitlab.Project)) + Expect(getSpec.Equals(postSpec)).To(BeTrue()) + }) + + It("should error at creation time if the user repo already does exist", func() { + repoRef := newUserRepoRef(testUserName, testRepoName) + _, err := c.UserRepositories().Create(ctx, repoRef, gitprovider.RepositoryInfo{}) + Expect(errors.Is(err, gitprovider.ErrAlreadyExists)).To(BeTrue()) + }) + + It("should update if the user repo already exists when reconciling", func() { + repoRef := newUserRepoRef(testUserName, testRepoName) + // No-op reconcile + resp, actionTaken, err := c.UserRepositories().Reconcile(ctx, repoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + DefaultBranch: gitprovider.StringVar(defaultBranch), + Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeFalse()) + // no-op set & reconcile + Expect(resp.Set(resp.Get())).ToNot(HaveOccurred()) + actionTaken, err = resp.Reconcile(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeFalse()) + + // Update reconcile + newDesc := "New description" + req := resp.Get() + req.Description = gitprovider.StringVar(newDesc) + Expect(resp.Set(req)).ToNot(HaveOccurred()) + actionTaken, err = resp.Reconcile(ctx) + // Expect the update to succeed, and modify the state + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeTrue()) + Expect(*resp.Get().Description).To(Equal(newDesc)) + + // Delete the repository and later re-create + Expect(resp.Delete(ctx)).ToNot(HaveOccurred()) + + time.Sleep(10 * time.Second) + // Reconcile and create + newRepo, actionTaken, err := c.UserRepositories().Reconcile(ctx, repoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + }, &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + LicenseTemplate: gitprovider.LicenseTemplateVar(gitprovider.LicenseTemplateMIT), + }) + // Expect the create to succeed, and have modified the state. Also validate the newRepo data + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeTrue()) + validateUserRepo(newRepo, repoRef) + }) + + AfterSuite(func() { + // Don't do anything more if c wasn't created + if c == nil { + return + } + // Delete the test repo used + fmt.Println("Deleting the user repo: ", testRepoName) + repoRef := newUserRepoRef(testUserName, testRepoName) + repo, err := c.UserRepositories().Get(ctx, repoRef) + if errors.Is(err, gitprovider.ErrNotFound) { + return + } + Expect(err).ToNot(HaveOccurred()) + Expect(repo.Delete(ctx)).ToNot(HaveOccurred()) + + // Delete the test org repo used + fmt.Println("Deleting the org repo: ", testOrgRepoName) + orgRepoRef := newOrgRepoRef(testOrgName, testOrgRepoName) + repo, err = c.OrgRepositories().Get(ctx, orgRepoRef) + if errors.Is(err, gitprovider.ErrNotFound) { + return + } + Expect(err).ToNot(HaveOccurred()) + Expect(repo.Delete(ctx)).ToNot(HaveOccurred()) + + // Delete the test shared org repo used + fmt.Println("Deleting the shared org repo: ", testSharedOrgRepoName) + sharedOrgRepoRef := newOrgRepoRef(testOrgName, testSharedOrgRepoName) + repo, err = c.OrgRepositories().Get(ctx, sharedOrgRepoRef) + if errors.Is(err, gitprovider.ErrNotFound) { + return + } + Expect(err).ToNot(HaveOccurred()) + Expect(repo.Delete(ctx)).ToNot(HaveOccurred()) + }) +}) + +func newOrgRef(organizationName string) gitprovider.OrganizationRef { + return gitprovider.OrganizationRef{ + Domain: gitlabDomain, + Organization: organizationName, + } +} + +func newOrgRepoRef(organizationName, repoName string) gitprovider.OrgRepositoryRef { + return gitprovider.OrgRepositoryRef{ + OrganizationRef: newOrgRef(organizationName), + RepositoryName: repoName, + } +} + +func newUserRef(userLogin string) gitprovider.UserRef { + return gitprovider.UserRef{ + Domain: gitlabDomain, + UserLogin: userLogin, + } +} + +func newUserRepoRef(userLogin, repoName string) gitprovider.UserRepositoryRef { + return gitprovider.UserRepositoryRef{ + UserRef: newUserRef(userLogin), + RepositoryName: repoName, + } +} + +func findOrgRepo(repos []gitprovider.OrgRepository, name string) gitprovider.OrgRepository { + if name == "" { + return nil + } + for _, repo := range repos { + if repo.Repository().GetRepository() == name { + return repo + } + } + return nil +} + +func findUserRepo(repos []gitprovider.UserRepository, name string) gitprovider.UserRepository { + if name == "" { + return nil + } + for _, repo := range repos { + if repo.Repository().GetRepository() == name { + return repo + } + } + return nil +} diff --git a/gitlab/resource_deploykey.go b/gitlab/resource_deploykey.go new file mode 100644 index 00000000..6dc1a4bc --- /dev/null +++ b/gitlab/resource_deploykey.go @@ -0,0 +1,198 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "context" + "errors" + "fmt" + "reflect" + + "github.com/xanzy/go-gitlab" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/fluxcd/go-git-providers/validation" +) + +func newDeployKey(c *DeployKeyClient, key *gitlab.DeployKey) *deployKey { + return &deployKey{ + k: *key, + c: c, + canpush: key.CanPush, + } +} + +var _ gitprovider.DeployKey = &deployKey{} + +type deployKey struct { + k gitlab.DeployKey + c *DeployKeyClient + canpush *bool +} + +func (dk *deployKey) Get() gitprovider.DeployKeyInfo { + return deployKeyFromAPI(&dk.k) +} + +func (dk *deployKey) Set(info gitprovider.DeployKeyInfo) error { + if err := info.ValidateInfo(); err != nil { + return err + } + deployKeyInfoToAPIObj(&info, &dk.k) + return nil +} + +func (dk *deployKey) APIObject() interface{} { + return &dk.k +} + +func (dk *deployKey) Repository() gitprovider.RepositoryRef { + return dk.c.ref +} + +// Update will apply the desired state in this object to the server. +// Only set fields will be respected (i.e. PATCH behaviour). +// In order to apply changes to this object, use the .Set({Resource}Info) error +// function, or cast .APIObject() to a pointer to the provider-specific type +// and set custom fields there. +// +// ErrNotFound is returned if the resource does not exist. +// +// The internal API object will be overridden with the received server data. +func (dk *deployKey) Update(ctx context.Context) error { + // Delete the old key and recreate + if err := dk.Delete(ctx); err != nil { + return err + } + return dk.createIntoSelf(ctx) +} + +// Delete deletes a deploy key from the repository. +// +// ErrNotFound is returned if the resource does not exist. +func (dk *deployKey) Delete(ctx context.Context) error { + // We can use the same DeployKey ID that we got from the GET calls. Make sure it's non-nil. + // This _should never_ happen, but just check for it anyways to avoid panicing. + if dk.k.ID == 0 { + return fmt.Errorf("didn't expect ID to be 0: %w", gitprovider.ErrUnexpectedEvent) + } + + return dk.c.c.DeleteKey(ctx, getRepoPath(dk.c.ref), dk.k.ID) +} + +// Reconcile makes sure the desired state in this object (called "req" here) becomes +// the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be updated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +// +// The internal API object will be overridden with the received server data if actionTaken == true. +func (dk *deployKey) Reconcile(ctx context.Context) (bool, error) { + actual, err := dk.c.get(ctx, dk.k.Title) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + return true, dk.createIntoSelf(ctx) + } + + // Unexpected path, Get should succeed or return NotFound + return false, err + } + + // Use wrappers here to extract the "spec" part of the object for comparison + desiredSpec := newGitlabKeySpec(&dk.k) + actualSpec := newGitlabKeySpec(&actual.k) + + // If the desired matches the actual state, do nothing + if desiredSpec.Equals(actualSpec) { + return false, nil + } + // If desired and actual state mis-match, update + return true, dk.Update(ctx) +} + +func (dk *deployKey) createIntoSelf(ctx context.Context) error { + // POST /repos/{owner}/{repo}/keys + apiObj, err := dk.c.c.CreateKey(ctx, getRepoPath(dk.c.ref), &dk.k) + if err != nil { + return err + } + dk.k = *apiObj + return nil +} + +func validateDeployKeyAPI(apiObj *gitlab.DeployKey) error { + return validateAPIObject("GitLab.Key", func(validator validation.Validator) { + if apiObj.Title == "" { + validator.Required("Title") + } + if apiObj.Key == "" { + validator.Required("Key") + } + }) +} + +func deployKeyFromAPI(apiObj *gitlab.DeployKey) gitprovider.DeployKeyInfo { + return gitprovider.DeployKeyInfo{ + Name: apiObj.Title, + Key: []byte(apiObj.Key), + } +} + +func deployKeyToAPI(info *gitprovider.DeployKeyInfo) *gitlab.DeployKey { + k := &gitlab.DeployKey{} + deployKeyInfoToAPIObj(info, k) + return k +} + +func deployKeyInfoToAPIObj(info *gitprovider.DeployKeyInfo, apiObj *gitlab.DeployKey) { + // Required fields, we assume info is validated, and hence these are set + apiObj.Title = info.Name + apiObj.Key = string(info.Key) + // optional fields + derefedBool := false + if info.ReadOnly != nil { + if *info.ReadOnly { + apiObj.CanPush = &derefedBool + } else { + derefedBool = true + apiObj.CanPush = &derefedBool + } + } +} + +// This function copies over the fields that are part of create request of a deploy +// i.e. the desired spec of the deploy key. This allows us to separate "spec" from "status" fields. +func newGitlabKeySpec(key *gitlab.DeployKey) *gitlabKeySpec { + return &gitlabKeySpec{ + &gitlab.DeployKey{ + // Create-specific parameters + Title: key.Title, + Key: key.Key, + CanPush: key.CanPush, + }, + } +} + +type gitlabKeySpec struct { + *gitlab.DeployKey +} + +func (s *gitlabKeySpec) Equals(other *gitlabKeySpec) bool { + return reflect.DeepEqual(s, other) +} diff --git a/gitlab/resource_organization.go b/gitlab/resource_organization.go new file mode 100644 index 00000000..c4ca5428 --- /dev/null +++ b/gitlab/resource_organization.go @@ -0,0 +1,80 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "github.com/xanzy/go-gitlab" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/fluxcd/go-git-providers/validation" +) + +func newOrganization(ctx *clientContext, apiObj *gitlab.Group, ref gitprovider.OrganizationRef) *organization { + return &organization{ + clientContext: ctx, + g: *apiObj, + ref: ref, + teams: &TeamsClient{ + clientContext: ctx, + ref: ref, + }, + } +} + +var _ gitprovider.Organization = &organization{} + +type organization struct { + *clientContext + + g gitlab.Group + ref gitprovider.OrganizationRef + + teams *TeamsClient +} + +func (o *organization) Get() gitprovider.OrganizationInfo { + return organizationFromAPI(&o.g) +} + +func (o *organization) APIObject() interface{} { + return &o.g +} + +func (o *organization) Organization() gitprovider.OrganizationRef { + return o.ref +} + +func (o *organization) Teams() gitprovider.TeamsClient { + return o.teams +} + +func organizationFromAPI(apiObj *gitlab.Group) gitprovider.OrganizationInfo { + return gitprovider.OrganizationInfo{ + Name: &apiObj.Name, + Description: &apiObj.Description, + } +} + +// validateOrganizationAPI validates the apiObj received from the server, to make sure that it is +// valid for our use. +func validateGroupAPI(apiObj *gitlab.Group) error { + return validateAPIObject("GitLab.Group", func(validator validation.Validator) { + if apiObj.Path == "" { + validator.Required("Path") + } + }) +} diff --git a/gitlab/resource_repository.go b/gitlab/resource_repository.go new file mode 100644 index 00000000..45a40c4e --- /dev/null +++ b/gitlab/resource_repository.go @@ -0,0 +1,251 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "context" + "errors" + + "github.com/google/go-cmp/cmp" + gogitlab "github.com/xanzy/go-gitlab" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +func newUserProject(ctx *clientContext, apiObj *gogitlab.Project, ref gitprovider.RepositoryRef) *userProject { + return &userProject{ + clientContext: ctx, + p: *apiObj, + ref: ref, + deployKeys: &DeployKeyClient{ + clientContext: ctx, + ref: ref, + }, + } +} + +var _ gitprovider.UserRepository = &userProject{} + +type userProject struct { + *clientContext + + p gogitlab.Project + ref gitprovider.RepositoryRef + + deployKeys *DeployKeyClient +} + +func (p *userProject) Get() gitprovider.RepositoryInfo { + return repositoryFromAPI(&p.p) +} + +func (p *userProject) Set(info gitprovider.RepositoryInfo) error { + if err := info.ValidateInfo(); err != nil { + return err + } + repositoryInfoToAPIObj(&info, &p.p) + return nil +} + +func (p *userProject) APIObject() interface{} { + return &p.p +} + +func (p *userProject) Repository() gitprovider.RepositoryRef { + return p.ref +} + +func (p *userProject) DeployKeys() gitprovider.DeployKeyClient { + return p.deployKeys +} + +// The internal API object will be overridden with the received server data. +func (p *userProject) Update(ctx context.Context) error { + // PATCH /repos/{owner}/{repo} + apiObj, err := p.c.UpdateProject(ctx, &p.p) + if err != nil { + return err + } + p.p = *apiObj + return nil +} + +// Reconcile makes sure the desired state in this object (called "req" here) becomes +// the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be updated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +// +// The internal API object will be overridden with the received server data if actionTaken == true. +func (p *userProject) Reconcile(ctx context.Context) (bool, error) { + apiObj, err := p.c.GetUserProject(ctx, getRepoPath(p.ref)) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + // orgName := "" + // if orgRef, ok := p.ref.(gitprovider.OrgRepositoryRef); ok { + // orgName = orgRef.Organization + // } + project, err := p.c.CreateProject(ctx, &p.p) + if err != nil { + return true, err + } + p.p = *project + return true, nil + } + + return false, err + } + + // Use wrappers here to extract the "spec" part of the object for comparison + desiredSpec := newGitlabProjectSpec(&p.p) + actualSpec := newGitlabProjectSpec(apiObj) + + // If desired state already is the actual state, do nothing + if desiredSpec.Equals(actualSpec) { + return false, nil + } + // Otherwise, make the desired state the actual state + return true, p.Update(ctx) +} + +// Delete deletes the current resource irreversibly. +// +// ErrNotFound is returned if the resource doesn't exist anymore. +func (p *userProject) Delete(ctx context.Context) error { + return p.c.DeleteProject(ctx, getRepoPath(p.ref)) +} + +func newGroupProject(ctx *clientContext, apiObj *gogitlab.Project, ref gitprovider.RepositoryRef) *orgRepository { + return &orgRepository{ + userProject: *newUserProject(ctx, apiObj, ref), + teamAccess: &TeamAccessClient{ + clientContext: ctx, + ref: ref, + }, + } +} + +var _ gitprovider.OrgRepository = &orgRepository{} + +type orgRepository struct { + userProject + + teamAccess *TeamAccessClient +} + +func (r *orgRepository) TeamAccess() gitprovider.TeamAccessClient { + return r.teamAccess +} + +// Reconcile makes sure the desired state in this object (called "req" here) becomes +// the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be updated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +// +// The internal API object will be overridden with the received server data if actionTaken == true. +func (r *orgRepository) Reconcile(ctx context.Context) (bool, error) { + apiObj, err := r.c.GetGroupProject(ctx, r.ref.GetIdentity(), r.ref.GetRepository()) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + project, err := r.c.CreateProject(ctx, &r.p) + if err != nil { + return true, err + } + r.p = *project + return true, nil + } + + return false, err + } + + // Use wrappers here to extract the "spec" part of the object for comparison + desiredSpec := newGitlabProjectSpec(&r.p) + actualSpec := newGitlabProjectSpec(apiObj) + + // If desired state already is the actual state, do nothing + if desiredSpec.Equals(actualSpec) { + return false, nil + } + // Otherwise, make the desired state the actual state + return true, r.Update(ctx) +} + +func repositoryFromAPI(apiObj *gogitlab.Project) gitprovider.RepositoryInfo { + repo := gitprovider.RepositoryInfo{ + Description: &apiObj.Description, + DefaultBranch: &apiObj.DefaultBranch, + } + repo.Visibility = gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibility(apiObj.Visibility)) + return repo +} + +func repositoryToAPI(repo *gitprovider.RepositoryInfo, ref gitprovider.RepositoryRef) gogitlab.Project { + apiObj := gogitlab.Project{ + Name: *gitprovider.StringVar(ref.GetRepository()), + } + repositoryInfoToAPIObj(repo, &apiObj) + return apiObj +} + +func repositoryInfoToAPIObj(repo *gitprovider.RepositoryInfo, apiObj *gogitlab.Project) { + if repo.Description != nil { + apiObj.Description = *repo.Description + } + if repo.DefaultBranch != nil { + apiObj.DefaultBranch = *repo.DefaultBranch + } + if repo.Visibility != nil { + apiObj.Visibility = gitlabVisibilityMap[*repo.Visibility] + } +} + +// This function copies over the fields that are part of create/update requests of a project +// i.e. the desired spec of the repository. This allows us to separate "spec" from "status" fields. +func newGitlabProjectSpec(project *gogitlab.Project) *gitlabProjectSpec { + return &gitlabProjectSpec{ + &gogitlab.Project{ + // Generic + Name: project.Name, + Namespace: project.Namespace, + Description: project.Description, + Visibility: project.Visibility, + + // Update-specific parameters + DefaultBranch: project.DefaultBranch, + }, + } +} + +type gitlabProjectSpec struct { + *gogitlab.Project +} + +func (s *gitlabProjectSpec) Equals(other *gitlabProjectSpec) bool { + return cmp.Equal(s, other) +} + +//nolint +var gitlabVisibilityMap = map[gitprovider.RepositoryVisibility]gogitlab.VisibilityValue{ + gitprovider.RepositoryVisibilityInternal: gogitlab.InternalVisibility, + gitprovider.RepositoryVisibilityPrivate: gogitlab.PrivateVisibility, + gitprovider.RepositoryVisibilityPublic: gogitlab.PublicVisibility, +} diff --git a/gitlab/resource_teamaccess.go b/gitlab/resource_teamaccess.go new file mode 100644 index 00000000..45ed2532 --- /dev/null +++ b/gitlab/resource_teamaccess.go @@ -0,0 +1,149 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "context" + "errors" + "strings" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +func newTeamAccess(c *TeamAccessClient, ta gitprovider.TeamAccessInfo) *teamAccess { + return &teamAccess{ + ta: ta, + c: c, + } +} + +var _ gitprovider.TeamAccess = &teamAccess{} + +type teamAccess struct { + ta gitprovider.TeamAccessInfo + c *TeamAccessClient +} + +func (ta *teamAccess) Get() gitprovider.TeamAccessInfo { + return ta.ta +} + +func (ta *teamAccess) Set(info gitprovider.TeamAccessInfo) error { + if err := info.ValidateInfo(); err != nil { + return err + } + ta.ta = info + return nil +} + +func (ta *teamAccess) APIObject() interface{} { + return nil +} + +func (ta *teamAccess) Repository() gitprovider.RepositoryRef { + return ta.c.ref +} + +func (ta *teamAccess) Delete(ctx context.Context) error { + group, err := ta.c.c.GetGroup(ctx, ta.ta.Name) + if err != nil { + return err + } + return ta.c.c.UnshareProject(ctx, getRepoPath(ta.c.ref), group.ID) +} + +func (ta *teamAccess) Update(ctx context.Context) error { + resp, err := ta.c.Create(ctx, ta.Get()) + if err != nil { + if strings.Contains(err.Error(), alreadySharedWithGroup) { + group, err := ta.c.c.GetGroup(ctx, ta.Get().Name) + if err != nil { + return err + } + err = ta.c.c.UnshareProject(ctx, getRepoPath(ta.c.ref), group.ID) + if err != nil { + return err + } + err = ta.Update(ctx) + if err != nil { + return err + } + return nil + } + return err + } + return ta.Set(resp.Get()) +} + +// Reconcile makes sure the given desired state (req) becomes the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be deleted and recreated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +func (ta *teamAccess) Reconcile(ctx context.Context) (bool, error) { + req := ta.Get() + actual, err := ta.c.Get(ctx, req.Name) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + resp, err := ta.c.Create(ctx, req) + if err != nil { + return true, err + } + return true, ta.Set(resp.Get()) + } + + // Unexpected path, Get should succeed or return NotFound + return false, err + } + + // If the desired matches the actual state, just return the actual state + if req.Equals(actual.Get()) { + return false, nil + } + + return true, ta.Update(ctx) +} + +//nolint +var permissionPriority = map[int]gitprovider.RepositoryPermission{ + 10: gitprovider.RepositoryPermissionPull, + 20: gitprovider.RepositoryPermissionTriage, + 30: gitprovider.RepositoryPermissionPush, + 40: gitprovider.RepositoryPermissionMaintain, + 50: gitprovider.RepositoryPermissionAdmin, +} + +func getGitProviderPermission(permissionLevel int) (*gitprovider.RepositoryPermission, error) { + var permissionObj gitprovider.RepositoryPermission + var ok bool + + if permissionObj, ok = permissionPriority[permissionLevel]; !ok { + return nil, gitprovider.ErrInvalidPermissionLevel + } + permission := &permissionObj + return permission, nil +} + +func getGitlabPermission(permission gitprovider.RepositoryPermission) (int, error) { + for k, v := range permissionPriority { + if v == permission { + return k, nil + } + } + return 0, gitprovider.ErrInvalidPermissionLevel +} diff --git a/gitlab/resource_teamaccess_test.go b/gitlab/resource_teamaccess_test.go new file mode 100644 index 00000000..486f4257 --- /dev/null +++ b/gitlab/resource_teamaccess_test.go @@ -0,0 +1,93 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "reflect" + "testing" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +func Test_getGitProviderPermission(t *testing.T) { + tests := []struct { + name string + permission int + want *gitprovider.RepositoryPermission + }{ + { + name: "pull", + permission: 10, + want: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionPull), + }, + { + name: "push", + permission: 30, + want: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionPush), + }, + { + name: "admin", + permission: 50, + want: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionAdmin), + }, + { + name: "false data", + permission: -1, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPermission, _ := getGitProviderPermission(tt.permission) + if !reflect.DeepEqual(gotPermission, tt.want) { + t.Errorf("getPermissionFromMap() = %v, want %v", gotPermission, tt.want) + } + }) + } +} + +func Test_getGitlabPermission(t *testing.T) { + tests := []struct { + name string + permission *gitprovider.RepositoryPermission + want int + }{ + { + name: "pull", + permission: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionPull), + want: 10, + }, + { + name: "push", + permission: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionPush), + want: 30, + }, + { + name: "admin", + permission: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionAdmin), + want: 50, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPermission, _ := getGitlabPermission(*tt.permission) + if !reflect.DeepEqual(gotPermission, tt.want) { + t.Errorf("getPermissionFromMap() = %v, want %v", gotPermission, tt.want) + } + }) + } +} diff --git a/gitlab/util.go b/gitlab/util.go new file mode 100644 index 00000000..78b89127 --- /dev/null +++ b/gitlab/util.go @@ -0,0 +1,242 @@ +package gitlab + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/fluxcd/go-git-providers/validation" + "github.com/xanzy/go-gitlab" +) + +const ( + alreadyExistsMagicString = "name: [has already been taken]" + alreadySharedWithGroup = "already shared with this group" + masterBranchName = "master" +) + +func getRepoPath(ref gitprovider.RepositoryRef) string { + return fmt.Sprintf("%s/%s", ref.GetIdentity(), ref.GetRepository()) +} + +// allPages runs fn for each page, expecting a HTTP request to be made and returned during that call. +// allPages expects that the data is saved in fn to an outer variable. +// allPages calls fn as many times as needed to get all pages, and modifies opts for each call. +// There is no need to wrap the resulting error in handleHTTPError(err), as that's already done. +func allGroupPages(opts *gitlab.ListGroupsOptions, fn func() (*gitlab.Response, error)) error { + for { + resp, err := fn() + if err != nil { + return handleHTTPError(err) + } + if resp.NextPage == 0 { + return nil + } + opts.Page = resp.NextPage + } +} + +func allSubgroupPages(opts *gitlab.ListSubgroupsOptions, fn func() (*gitlab.Response, error)) error { + for { + resp, err := fn() + if err != nil { + return err + } + if resp.NextPage == 0 { + return nil + } + opts.Page = resp.NextPage + } +} + +func allGroupProjectPages(opts *gitlab.ListGroupProjectsOptions, fn func() (*gitlab.Response, error)) error { + for { + resp, err := fn() + if err != nil { + return err + } + if resp.NextPage == 0 { + return nil + } + opts.Page = resp.NextPage + } +} + +func allGroupMemberPages(opts *gitlab.ListGroupMembersOptions, fn func() (*gitlab.Response, error)) error { + for { + resp, err := fn() + if err != nil { + return err + } + if resp.NextPage == 0 { + return nil + } + opts.Page = resp.NextPage + } +} + +func allProjectPages(opts *gitlab.ListProjectsOptions, fn func() (*gitlab.Response, error)) error { + for { + resp, err := fn() + if err != nil { + return err + } + if resp.NextPage == 0 { + return nil + } + opts.Page = resp.NextPage + } +} + +func allProjectUserPages(opts *gitlab.ListProjectUserOptions, fn func() (*gitlab.Response, error)) error { + for { + resp, err := fn() + if err != nil { + return err + } + if resp.NextPage == 0 { + return nil + } + opts.Page = resp.NextPage + } +} + +func allDeployKeyPages(opts *gitlab.ListProjectDeployKeysOptions, fn func() (*gitlab.Response, error)) error { + for { + resp, err := fn() + if err != nil { + return err + } + if resp.NextPage == 0 { + return nil + } + opts.Page = resp.NextPage + } +} + +// validateUserRepositoryRef makes sure the UserRepositoryRef is valid for GitHub's usage. +func validateUserRepositoryRef(ref gitprovider.UserRepositoryRef, expectedDomain string) error { + // Make sure the RepositoryRef fields are valid + if err := validation.ValidateTargets("UserRepositoryRef", ref); err != nil { + return err + } + // Make sure the type is valid, and domain is expected + return validateIdentityFields(ref, expectedDomain) +} + +// validateOrgRepositoryRef makes sure the OrgRepositoryRef is valid for GitHub's usage. +func validateOrgRepositoryRef(ref gitprovider.OrgRepositoryRef, expectedDomain string) error { + // Make sure the RepositoryRef fields are valid + if err := validation.ValidateTargets("OrgRepositoryRef", ref); err != nil { + return err + } + // Make sure the type is valid, and domain is expected + return validateIdentityFields(ref, expectedDomain) +} + +// validateUserRef makes sure the UserRef is valid for GitHub's usage. +func validateUserRef(ref gitprovider.UserRef, expectedDomain string) error { + // Make sure the OrganizationRef fields are valid + if err := validation.ValidateTargets("UserRef", ref); err != nil { + return err + } + // Make sure the type is valid, and domain is expected + return validateIdentityFields(ref, expectedDomain) +} + +// validateAPIObject creates a Validatior with the specified name, gives it to fn, and +// depending on if any error was registered with it; either returns nil, or a MultiError +// with both the validation error and ErrInvalidServerData, to mark that the server data +// was invalid. +func validateAPIObject(name string, fn func(validation.Validator)) error { + v := validation.New(name) + fn(v) + // If there was a validation error, also mark it specifically as invalid server data + if err := v.Error(); err != nil { + return validation.NewMultiError(err, gitprovider.ErrInvalidServerData) + } + return nil +} + +func validateProjectAPI(apiObj *gitlab.Project) error { + return validateAPIObject("GitLab.Project", func(validator validation.Validator) { + // Make sure name is set + if apiObj.Name == "" { + validator.Required("Name") + } + // Make sure visibility is valid if set + if apiObj.Visibility != "" { + v := gitprovider.RepositoryVisibility(apiObj.Visibility) + validator.Append(gitprovider.ValidateRepositoryVisibility(v), v, "Visibility") + } + // Set default branch to master if unset + if apiObj.DefaultBranch == "" { + apiObj.DefaultBranch = masterBranchName + } + }) +} + +// validateOrganizationRef makes sure the OrganizationRef is valid for GitHub's usage. +func validateOrganizationRef(ref gitprovider.OrganizationRef, expectedDomain string) error { + // Make sure the OrganizationRef fields are valid + if err := validation.ValidateTargets("OrganizationRef", ref); err != nil { + return err + } + // Make sure the type is valid, and domain is expected + return validateIdentityFields(ref, expectedDomain) +} + +// validateIdentityFields makes sure the type of the IdentityRef is supported, and the domain is as expected. +func validateIdentityFields(ref gitprovider.IdentityRef, expectedDomain string) error { + // Make sure the expected domain is used + if ref.GetDomain() != expectedDomain { + return fmt.Errorf("domain %q not supported by this client: %w", ref.GetDomain(), gitprovider.ErrDomainUnsupported) + } + // Make sure the right type of identityref is used + switch ref.GetType() { + case gitprovider.IdentityTypeOrganization, gitprovider.IdentityTypeUser: + return nil + case gitprovider.IdentityTypeSuborganization: + return fmt.Errorf("github doesn't support sub-organizations: %w", gitprovider.ErrNoProviderSupport) + } + return fmt.Errorf("invalid identity type: %v: %w", ref.GetType(), gitprovider.ErrInvalidArgument) +} + +// handleHTTPError checks the type of err, and returns typed variants of it +// However, it _always_ keeps the original error too, and just wraps it in a MultiError +// The consumer must use errors.Is and errors.As to check for equality and get data out of it. +func handleHTTPError(err error) error { + // Short-circuit quickly if possible, allow always piping through this function + if err == nil { + return nil + } + glErrorResponse := &gitlab.ErrorResponse{} + if errors.As(err, &glErrorResponse) { + httpErr := gitprovider.HTTPError{ + Response: glErrorResponse.Response, + ErrorMessage: glErrorResponse.Error(), + Message: glErrorResponse.Message, + } + // Check for invalid credentials, and return a typed error in that case + if glErrorResponse.Response.StatusCode == http.StatusForbidden || + glErrorResponse.Response.StatusCode == http.StatusUnauthorized { + return validation.NewMultiError(err, + &gitprovider.InvalidCredentialsError{HTTPError: httpErr}, + ) + } + // Check for 404 Not Found + if glErrorResponse.Response.StatusCode == http.StatusNotFound { + return validation.NewMultiError(err, gitprovider.ErrNotFound) + } + // Check for already exists errors + if strings.Contains(glErrorResponse.Message, alreadyExistsMagicString) { + return validation.NewMultiError(err, gitprovider.ErrAlreadyExists) + } + // Otherwise, return a generic *HTTPError + return validation.NewMultiError(err, &httpErr) + } + // Do nothing, just pipe through the unknown err + return err +} diff --git a/gitlab/util_test.go b/gitlab/util_test.go new file mode 100644 index 00000000..90b53e22 --- /dev/null +++ b/gitlab/util_test.go @@ -0,0 +1,135 @@ +/* +Copyright 2020 The Flux CD contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitlab + +import ( + "net/http" + "net/url" + "testing" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/fluxcd/go-git-providers/validation" + "github.com/xanzy/go-gitlab" +) + +func Test_validateAPIObject(t *testing.T) { + tests := []struct { + name string + structName string + fn func(validation.Validator) + expectedErrs []error + }{ + { + name: "no error => nil", + structName: "Foo", + fn: func(validation.Validator) {}, + }, + { + name: "one error => MultiError & InvalidServerData", + structName: "Foo", + fn: func(v validation.Validator) { + v.Required("FieldBar") + }, + expectedErrs: []error{gitprovider.ErrInvalidServerData, &validation.MultiError{}, validation.ErrFieldRequired}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAPIObject(tt.structName, tt.fn) + validation.TestExpectErrors(t, "validateAPIObject", err, tt.expectedErrs...) + }) + } +} + +func newGLError() *gitlab.ErrorResponse { + return &gitlab.ErrorResponse{ + Response: &http.Response{ + Request: &http.Request{ + Method: "GET", + URL: &url.URL{}, + }, + StatusCode: 404, + }, + } +} + +func Test_allGroupPages(t *testing.T) { + tests := []struct { + name string + opts *gitlab.ListGroupsOptions + fn func(int) (*gitlab.Response, error) + expectedErrs []error + expectedCalls int + }{ + { + name: "one page only, no error", + opts: &gitlab.ListGroupsOptions{}, + fn: func(_ int) (*gitlab.Response, error) { + return &gitlab.Response{NextPage: 0}, nil + }, + expectedCalls: 1, + }, + { + name: "two pages, no error", + opts: &gitlab.ListGroupsOptions{}, + fn: func(i int) (*gitlab.Response, error) { + switch i { + case 1: + return &gitlab.Response{NextPage: 2}, nil + } + return &gitlab.Response{NextPage: 0}, nil + }, + expectedCalls: 2, + }, + { + name: "four pages, error at second", + opts: &gitlab.ListGroupsOptions{}, + fn: func(i int) (*gitlab.Response, error) { + switch i { + case 1: + return &gitlab.Response{NextPage: 2}, nil + case 2: + return nil, newGLError() + case 3: + return &gitlab.Response{NextPage: 4}, nil + } + return &gitlab.Response{NextPage: 0}, nil + }, + expectedCalls: 2, + expectedErrs: []error{&validation.MultiError{}, gitprovider.ErrNotFound, newGLError()}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := 0 + // the page index are 1-based, and omitting page is the same as page=1 + // set page=1 here just to be able to test more easily + tt.opts.Page = 1 + err := allGroupPages(tt.opts, func() (*gitlab.Response, error) { + i++ + if tt.opts.Page != i { + t.Fatalf("page number is unexpected: got = %d want = %d", tt.opts.Page, i) + } + return tt.fn(i) + }) + validation.TestExpectErrors(t, "allGroupPages", err, tt.expectedErrs...) + if i != tt.expectedCalls { + t.Errorf("allPages() expectedCalls = %v, want %v", i, tt.expectedCalls) + } + }) + } +} diff --git a/gitprovider/errors.go b/gitprovider/errors.go index b4332276..44cfb4b7 100644 --- a/gitprovider/errors.go +++ b/gitprovider/errors.go @@ -62,6 +62,10 @@ var ( ErrDestructiveCallDisallowed = errors.New("destructive call was blocked, disallowed by client") // ErrInvalidTransportChainReturn is returned if a ChainableRoundTripperFunc returns nil, which is invalid. ErrInvalidTransportChainReturn = errors.New("the return value of a ChainableRoundTripperFunc must not be nil") + + // ErrInvalidPermissionLevel is the error returned when there is no mapping + // from the given level to the gitprovider levels. + ErrInvalidPermissionLevel = errors.New("invalid permission level") ) // HTTPError is an error that contains context about the HTTP request/response that failed. diff --git a/gitprovider/repositoryref.go b/gitprovider/repositoryref.go index cad689db..3c5362af 100644 --- a/gitprovider/repositoryref.go +++ b/gitprovider/repositoryref.go @@ -254,7 +254,10 @@ func GetCloneURL(rs RepositoryRef, transport TransportType) string { case TransportTypeGit: return fmt.Sprintf("git@%s:%s/%s.git", rs.GetDomain(), rs.GetIdentity(), rs.GetRepository()) case TransportTypeSSH: - return fmt.Sprintf("ssh://git@%s/%s/%s", rs.GetDomain(), rs.GetIdentity(), rs.GetRepository()) + trimmedDomain := rs.GetDomain() + trimmedDomain = strings.Replace(trimmedDomain, "https://", "", -1) + trimmedDomain = strings.Replace(trimmedDomain, "http://", "", -1) + return fmt.Sprintf("ssh://git@%s/%s/%s", trimmedDomain, rs.GetIdentity(), rs.GetRepository()) } return "" } diff --git a/gitprovider/repositoryref_test.go b/gitprovider/repositoryref_test.go index df99e891..238a8b43 100644 --- a/gitprovider/repositoryref_test.go +++ b/gitprovider/repositoryref_test.go @@ -501,6 +501,30 @@ func TestGetCloneURL(t *testing.T) { transport: TransportTypeHTTPS, want: "https://github.com/luxas/foo-bar.git", }, + { + name: "org: ssh", + repoinfo: newOrgRepoRef("my-gitlab.com:6443", "luxas", []string{"test-org", "other"}, "foo-bar"), + transport: TransportTypeSSH, + want: "ssh://git@my-gitlab.com:6443/luxas/test-org/other/foo-bar", + }, + { + name: "org: ssh", + repoinfo: newOrgRepoRef("https://my-gitlab.com", "luxas", []string{"test-org", "other"}, "foo-bar"), + transport: TransportTypeSSH, + want: "ssh://git@my-gitlab.com/luxas/test-org/other/foo-bar", + }, + { + name: "org: ssh", + repoinfo: newOrgRepoRef("https://my-gitlab.com:6443", "luxas", []string{"test-org", "other"}, "foo-bar"), + transport: TransportTypeSSH, + want: "ssh://git@my-gitlab.com:6443/luxas/test-org/other/foo-bar", + }, + { + name: "org: ssh", + repoinfo: newOrgRepoRef("http://my-gitlab.com:6443", "luxas", []string{"test-org", "other"}, "foo-bar"), + transport: TransportTypeSSH, + want: "ssh://git@my-gitlab.com:6443/luxas/test-org/other/foo-bar", + }, { name: "user: git", repoinfo: newUserRepoRef("gitlab.com", "luxas", "foo-bar"), diff --git a/go.mod b/go.mod index 774dafd2..0e7cb4df 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/fluxcd/go-git-providers go 1.14 require ( + github.com/google/go-cmp v0.4.0 github.com/google/go-github/v32 v32.1.0 github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 github.com/ktrysmt/go-bitbucket v0.6.2