Skip to content
Snippets Groups Projects
Select Git revision
  • a8fdb4e38cdcd7f4b24a3f9b722d3fc5bf3424d2
  • main default protected
  • renovate/main-redis-5.x
  • next
  • revert-31645-feat/rename-gradle-wrapper-validation-action
  • fix/36615b-branch-reuse-no-cache
  • chore/punycode
  • fix/36615-branch-reuse-bug
  • refactor/pin-new-value
  • feat/36219--git-x509-signing
  • feat/structured-logger
  • hotfix/39.264.1
  • feat/skip-dangling
  • gh-readonly-queue/next/pr-36034-7a061c4ca1024a19e2c295d773d9642625d1c2be
  • hotfix/39.238.3
  • refactor/gitlab-auto-approve
  • feat/template-strings
  • gh-readonly-queue/next/pr-35654-137d934242c784e0c45d4b957362214f0eade1d7
  • fix/32307-global-extends-merging
  • fix/32307-global-extends-repositories
  • gh-readonly-queue/next/pr-35009-046ebf7cb84ab859f7fefceb5fa53a54ce9736f8
  • 41.35.2
  • 41.35.1
  • 41.35.0
  • 41.34.1
  • 41.34.0
  • 41.33.0
  • 41.32.3
  • 41.32.2
  • 41.32.1
  • 41.32.0
  • 41.31.1
  • 41.31.0
  • 41.30.5
  • 41.30.4
  • 41.30.3
  • 41.30.2
  • 41.30.1
  • 41.30.0
  • 41.29.1
  • 41.29.0
41 results

index.ts

Blame
  • bootstrap_provider.go 18.00 KiB
    /*
    Copyright 2021 The Flux authors
    
    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 bootstrap
    
    import (
    	"context"
    	"errors"
    	"fmt"
    	"net/url"
    	"strings"
    	"time"
    
    	corev1 "k8s.io/api/core/v1"
    	"sigs.k8s.io/controller-runtime/pkg/client"
    
    	"github.com/fluxcd/go-git-providers/gitprovider"
    
    	"github.com/fluxcd/flux2/internal/bootstrap/git"
    	"github.com/fluxcd/flux2/internal/bootstrap/provider"
    	"github.com/fluxcd/flux2/pkg/manifestgen/sourcesecret"
    	"github.com/fluxcd/flux2/pkg/manifestgen/sync"
    )
    
    type GitProviderBootstrapper struct {
    	*PlainGitBootstrapper
    
    	owner          string
    	repositoryName string
    	repository     gitprovider.UserRepository
    
    	personal bool
    
    	description   string
    	defaultBranch string
    	visibility    string
    
    	reconcile bool
    
    	teams map[string]string
    
    	readWriteKey bool
    
    	bootstrapTransportType string
    	syncTransportType      string
    
    	sshHostname string
    
    	provider gitprovider.Client
    }
    
    func NewGitProviderBootstrapper(git git.Git, provider gitprovider.Client, kube client.Client, opts ...GitProviderOption) (*GitProviderBootstrapper, error) {
    	b := &GitProviderBootstrapper{
    		PlainGitBootstrapper: &PlainGitBootstrapper{
    			git:  git,
    			kube: kube,
    		},
    		bootstrapTransportType: "https",
    		syncTransportType:      "ssh",
    		provider:               provider,
    	}
    	b.PlainGitBootstrapper.postGenerateSecret = append(b.PlainGitBootstrapper.postGenerateSecret, b.reconcileDeployKey)
    	for _, opt := range opts {
    		opt.applyGitProvider(b)
    	}
    	return b, nil
    }
    
    type GitProviderOption interface {
    	applyGitProvider(b *GitProviderBootstrapper)
    }
    
    func WithProviderRepository(owner, repositoryName string, personal bool) GitProviderOption {
    	return providerRepositoryOption{
    		owner:          owner,
    		repositoryName: repositoryName,
    		personal:       personal,
    	}
    }
    
    type providerRepositoryOption struct {
    	owner          string
    	repositoryName string
    	personal       bool
    }
    
    func (o providerRepositoryOption) applyGitProvider(b *GitProviderBootstrapper) {
    	b.owner = o.owner
    	b.repositoryName = o.repositoryName
    	b.personal = o.personal
    }
    
    func WithProviderRepositoryConfig(description, defaultBranch, visibility string) GitProviderOption {
    	return providerRepositoryConfigOption{
    		description:   description,
    		defaultBranch: defaultBranch,
    		visibility:    visibility,
    	}
    }
    
    type providerRepositoryConfigOption struct {
    	description   string
    	defaultBranch string
    	visibility    string
    }
    
    func (o providerRepositoryConfigOption) applyGitProvider(b *GitProviderBootstrapper) {
    	b.description = o.description
    	b.defaultBranch = o.defaultBranch
    	b.visibility = o.visibility
    }
    
    func WithProviderTeamPermissions(teams map[string]string) GitProviderOption {
    	return providerRepositoryTeamPermissionsOption(teams)
    }
    
    type providerRepositoryTeamPermissionsOption map[string]string
    
    func (o providerRepositoryTeamPermissionsOption) applyGitProvider(b *GitProviderBootstrapper) {
    	b.teams = o
    }
    
    func WithReadWriteKeyPermissions(b bool) GitProviderOption {
    	return withReadWriteKeyPermissionsOption(b)
    }
    
    type withReadWriteKeyPermissionsOption bool
    
    func (o withReadWriteKeyPermissionsOption) applyGitProvider(b *GitProviderBootstrapper) {
    	b.readWriteKey = bool(o)
    }
    
    func WithBootstrapTransportType(protocol string) GitProviderOption {
    	return bootstrapTransportTypeOption(protocol)
    }
    
    type bootstrapTransportTypeOption string
    
    func (o bootstrapTransportTypeOption) applyGitProvider(b *GitProviderBootstrapper) {
    	b.bootstrapTransportType = string(o)
    }
    
    func WithSyncTransportType(protocol string) GitProviderOption {
    	return syncProtocolOption(protocol)
    }
    
    type syncProtocolOption string
    
    func (o syncProtocolOption) applyGitProvider(b *GitProviderBootstrapper) {
    	b.syncTransportType = string(o)
    }
    
    func WithSSHHostname(hostname string) GitProviderOption {
    	return sshHostnameOption(hostname)
    }
    
    type sshHostnameOption string
    
    func (o sshHostnameOption) applyGitProvider(b *GitProviderBootstrapper) {
    	b.sshHostname = string(o)
    }
    
    func WithReconcile() GitProviderOption {
    	return reconcileOption(true)
    }
    
    type reconcileOption bool
    
    func (o reconcileOption) applyGitProvider(b *GitProviderBootstrapper) {
    	b.reconcile = true
    }
    
    func (b *GitProviderBootstrapper) ReconcileSyncConfig(ctx context.Context, options sync.Options) error {
    	if b.repository == nil {
    		return errors.New("repository is required")
    	}
    
    	if b.url == "" {
    		bootstrapURL, err := b.getCloneURL(b.repository, gitprovider.TransportType(b.bootstrapTransportType))
    		if err != nil {
    			return err
    		}
    		WithRepositoryURL(bootstrapURL).applyGit(b.PlainGitBootstrapper)
    	}
    	if options.URL == "" {
    		syncURL, err := b.getCloneURL(b.repository, gitprovider.TransportType(b.syncTransportType))
    		if err != nil {
    			return err
    		}
    
    		options.URL = syncURL
    	}
    
    	return b.PlainGitBootstrapper.ReconcileSyncConfig(ctx, options)
    }
    
    // ReconcileRepository reconciles an organization or user repository with the
    // GitProviderBootstrapper configuration. On success, the URL in the embedded
    // PlainGitBootstrapper is set to clone URL for the configured protocol.
    //
    // When part of the reconciliation fails with a warning without aborting, an
    // ErrReconciledWithWarning error is returned.
    func (b *GitProviderBootstrapper) ReconcileRepository(ctx context.Context) error {
    	var repo gitprovider.UserRepository
    	var err error
    	if b.personal {
    		repo, err = b.reconcileUserRepository(ctx)
    	} else {
    		repo, err = b.reconcileOrgRepository(ctx)
    	}
    	if err != nil && !errors.Is(err, ErrReconciledWithWarning) {
    		return err
    	}
    
    	cloneURL, err := b.getCloneURL(repo, gitprovider.TransportType(b.bootstrapTransportType))
    	if err != nil {
    		return err
    	}
    
    	b.repository = repo
    	WithRepositoryURL(cloneURL).applyGit(b.PlainGitBootstrapper)
    
    	return err
    }
    
    func (b *GitProviderBootstrapper) reconcileDeployKey(ctx context.Context, secret corev1.Secret, options sourcesecret.Options) error {
    	if b.repository == nil {
    		return errors.New("repository is required")
    	}
    
    	ppk, ok := secret.StringData[sourcesecret.PublicKeySecretKey]
    	if !ok {
    		return nil
    	}
    	b.logger.Successf("public key: %s", strings.TrimSpace(ppk))
    
    	name := deployKeyName(options.Namespace, b.branch, options.Name, options.TargetPath)
    	deployKeyInfo := newDeployKeyInfo(name, ppk, b.readWriteKey)
    
    	_, changed, err := b.repository.DeployKeys().Reconcile(ctx, deployKeyInfo)
    	if err != nil {
    		return err
    	}
    	if changed {
    		b.logger.Successf("configured deploy key %q for %q", deployKeyInfo.Name, b.repository.Repository().String())
    	}
    	return nil
    }
    
    // reconcileOrgRepository reconciles a gitprovider.OrgRepository
    // with the GitProviderBootstrapper values, including any
    // gitprovider.TeamAccessInfo configurations.
    //
    // If one of the gitprovider.TeamAccessInfo does not reconcile
    // successfully, the gitprovider.UserRepository and an
    // ErrReconciledWithWarning error are returned.
    func (b *GitProviderBootstrapper) reconcileOrgRepository(ctx context.Context) (gitprovider.UserRepository, error) {
    	b.logger.Actionf("connecting to %s", b.provider.SupportedDomain())
    
    	// Construct the repository and other configuration objects
    	// go-git-provider likes to work with
    	subOrgs, repoName := splitSubOrganizationsFromRepositoryName(b.repositoryName)
    	orgRef, err := b.getOrganization(ctx, subOrgs)
    	if err != nil {
    		return nil, fmt.Errorf("failed to create new Git repository %q: %w", b.repositoryName, err)
    	}
    	repoRef := newOrgRepositoryRef(*orgRef, repoName)
    	repoInfo := newRepositoryInfo(b.description, b.defaultBranch, b.visibility)
    
    	// Reconcile the organization repository
    	repo, err := b.provider.OrgRepositories().Get(ctx, repoRef)
    	if err != nil {
    		if !errors.Is(err, gitprovider.ErrNotFound) {
    			return nil, fmt.Errorf("failed to get Git repository %q: %w", repoRef.String(), err)
    		}
    		// go-git-providers has at present some issues with the idempotency
    		// of the available Reconcile methods, and setting e.g. the default
    		// branch correctly. Resort to Create until this has been resolved.
    		repo, err = b.provider.OrgRepositories().Create(ctx, repoRef, repoInfo)
    		if err != nil {
    			return nil, fmt.Errorf("failed to create new Git repository %q: %w", repoRef.String(), err)
    		}
    		b.logger.Successf("repository %q created", repoRef.String())
    	}
    
    	var changed bool
    	if b.reconcile {
    		// Set default branch before calling Reconcile due to bug described
    		// above.
    		repoInfo.DefaultBranch = repo.Get().DefaultBranch
    		if err = retry(1, 2*time.Second, func() (err error) {
    			repo, changed, err = b.provider.OrgRepositories().Reconcile(ctx, repoRef, repoInfo)
    			return
    		}); err != nil {
    			return nil, fmt.Errorf("failed to reconcile Git repository %q: %w", repoRef.String(), err)
    		}
    		if changed {
    			b.logger.Successf("repository %q reconciled", repoRef.String())
    		}
    	}
    
    	// Build the team access config
    	teamAccessInfo, err := buildTeamAccessInfo(b.teams, gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionMaintain))
    	if err != nil {
    		return nil, fmt.Errorf("failed to reconcile repository team access: %w", err)
    	}
    
    	// Reconcile the team access config on best effort (that being:
    	// record the error as a warning, but continue with the
    	// reconciliation of the others)
    	var warning error
    	if count := len(teamAccessInfo); count > 0 {
    		b.logger.Actionf("reconciling repository permissions")
    		for _, i := range teamAccessInfo {
    			var err error
    			// Don't reconcile team if team already exists and b.reconcile is false
    			if team, err := repo.TeamAccess().Get(ctx, i.Name); err == nil && !b.reconcile && team != nil {
    				continue
    			}
    			_, changed, err = repo.TeamAccess().Reconcile(ctx, i)
    			if err != nil {
    				warning = fmt.Errorf("failed to grant permissions to team: %w", ErrReconciledWithWarning)
    				b.logger.Failuref("failed to grant %q permissions to %q: %s", *i.Permission, i.Name, err.Error())
    			} else if changed {
    				b.logger.Successf("granted %q permissions to %q", *i.Permission, i.Name)
    			}
    		}
    		b.logger.Successf("reconciled repository permissions")
    	}
    	return repo, warning
    }
    
    // reconcileUserRepository reconciles a gitprovider.UserRepository
    // with the GitProviderBootstrapper values. It returns the reconciled
    // gitprovider.UserRepository, or an error.
    func (b *GitProviderBootstrapper) reconcileUserRepository(ctx context.Context) (gitprovider.UserRepository, error) {
    	b.logger.Actionf("connecting to %s", b.provider.SupportedDomain())
    
    	// Construct the repository and other metadata objects
    	// go-git-provider likes to work with.
    	_, repoName := splitSubOrganizationsFromRepositoryName(b.repositoryName)
    	userRef := newUserRef(b.provider.SupportedDomain(), b.owner)
    	repoRef := newUserRepositoryRef(userRef, repoName)
    	repoInfo := newRepositoryInfo(b.description, b.defaultBranch, b.visibility)
    
    	// Reconcile the user repository
    	repo, err := b.provider.UserRepositories().Get(ctx, repoRef)
    	if err != nil {
    		if !errors.Is(err, gitprovider.ErrNotFound) {
    			return nil, fmt.Errorf("failed to get Git repository %q: %w", repoRef.String(), err)
    		}
    		// go-git-providers has at present some issues with the idempotency
    		// of the available Reconcile methods, and setting e.g. the default
    		// branch correctly. Resort to Create until this has been resolved.
    		repo, err = b.provider.UserRepositories().Create(ctx, repoRef, repoInfo)
    		if err != nil {
    			return nil, fmt.Errorf("failed to create new Git repository %q: %w", repoRef.String(), err)
    		}
    		b.logger.Successf("repository %q created", repoRef.String())
    	}
    
    	if b.reconcile {
    		// Set default branch before calling Reconcile due to bug described
    		// above.
    		repoInfo.DefaultBranch = repo.Get().DefaultBranch
    		var changed bool
    		if err = retry(1, 2*time.Second, func() (err error) {
    			repo, changed, err = b.provider.UserRepositories().Reconcile(ctx, repoRef, repoInfo)
    			return
    		}); err != nil {
    			return nil, fmt.Errorf("failed to reconcile Git repository %q: %w", repoRef.String(), err)
    		}
    		if changed {
    			b.logger.Successf("repository %q reconciled", repoRef.String())
    		}
    	}
    
    	return repo, nil
    }
    
    // getOrganization retrieves and returns the gitprovider.Organization
    // using the GitProviderBootstrapper values.
    func (b *GitProviderBootstrapper) getOrganization(ctx context.Context, subOrgs []string) (*gitprovider.OrganizationRef, error) {
    	orgRef := newOrganizationRef(b.provider.SupportedDomain(), b.owner, subOrgs)
    	// With Stash get the organization to be sure to get the correct key
    	if string(b.provider.ProviderID()) == string(provider.GitProviderStash) {
    		org, err := b.provider.Organizations().Get(ctx, orgRef)
    		if err != nil {
    			return nil, fmt.Errorf("failed to get Git organization: %w", err)
    		}
    
    		orgRef = org.Organization()
    
    		return &orgRef, nil
    	}
    	return &orgRef, nil
    }
    
    // getCloneURL returns the Git clone URL for the given
    // gitprovider.UserRepository. If the given transport type is
    // gitprovider.TransportTypeSSH and a custom SSH hostname is configured,
    // the hostname of the URL will be modified to this hostname.
    func (b *GitProviderBootstrapper) getCloneURL(repository gitprovider.UserRepository, transport gitprovider.TransportType) (string, error) {
    	var url string
    	if cloner, ok := repository.(gitprovider.CloneableURL); ok {
    		url = cloner.GetCloneURL("", transport)
    	} else {
    		url = repository.Repository().GetCloneURL(transport)
    	}
    
    	var err error
    	if transport == gitprovider.TransportTypeSSH && b.sshHostname != "" {
    		if url, err = setHostname(url, b.sshHostname); err != nil {
    			err = fmt.Errorf("failed to set SSH hostname for URL %q: %w", url, err)
    		}
    	}
    	return url, err
    }
    
    // splitSubOrganizationsFromRepositoryName removes any prefixed sub
    // organizations from the given repository name by splitting the
    // string into a slice by '/'.
    // The last (or only) item of the slice result is assumed to be the
    // repository name, other items (nested) sub organizations.
    func splitSubOrganizationsFromRepositoryName(name string) ([]string, string) {
    	elements := strings.Split(name, "/")
    	i := len(elements)
    	switch i {
    	case 1:
    		return nil, name
    	default:
    		return elements[:i-1], elements[i-1]
    	}
    }
    
    // buildTeamAccessInfo constructs a gitprovider.TeamAccessInfo slice
    // from the given string map of team names to permissions.
    //
    // Providing a default gitprovider.RepositoryPermission is optional,
    // and omitting it will make it default to the go-git-provider default.
    //
    // An error is returned if any of the given permissions is invalid.
    func buildTeamAccessInfo(m map[string]string, defaultPermissions *gitprovider.RepositoryPermission) ([]gitprovider.TeamAccessInfo, error) {
    	var infos []gitprovider.TeamAccessInfo
    	if defaultPermissions != nil {
    		if err := gitprovider.ValidateRepositoryPermission(*defaultPermissions); err != nil {
    			return nil, fmt.Errorf("invalid default team permission %q", *defaultPermissions)
    		}
    	}
    	for n, p := range m {
    		permission := defaultPermissions
    		if p != "" {
    			p := gitprovider.RepositoryPermission(p)
    			if err := gitprovider.ValidateRepositoryPermission(p); err != nil {
    				return nil, fmt.Errorf("invalid permission %q for team %q", p, n)
    			}
    			permission = &p
    		}
    		i := gitprovider.TeamAccessInfo{
    			Name:       n,
    			Permission: permission,
    		}
    		infos = append(infos, i)
    	}
    	return infos, nil
    }
    
    // newOrganizationRef constructs a gitprovider.OrganizationRef with the
    // given values and returns the result.
    func newOrganizationRef(domain, organization string, subOrganizations []string) gitprovider.OrganizationRef {
    	return gitprovider.OrganizationRef{
    		Domain:           domain,
    		Organization:     organization,
    		SubOrganizations: subOrganizations,
    	}
    }
    
    // newOrgRepositoryRef constructs a gitprovider.OrgRepositoryRef with
    // the given values and returns the result.
    func newOrgRepositoryRef(organizationRef gitprovider.OrganizationRef, name string) gitprovider.OrgRepositoryRef {
    	return gitprovider.OrgRepositoryRef{
    		OrganizationRef: organizationRef,
    		RepositoryName:  name,
    	}
    }
    
    // newUserRef constructs a gitprovider.UserRef with the given values
    // and returns the result.
    func newUserRef(domain, login string) gitprovider.UserRef {
    	return gitprovider.UserRef{
    		Domain:    domain,
    		UserLogin: login,
    	}
    }
    
    // newUserRepositoryRef constructs a gitprovider.UserRepositoryRef with
    // the given values and returns the result.
    func newUserRepositoryRef(userRef gitprovider.UserRef, name string) gitprovider.UserRepositoryRef {
    	return gitprovider.UserRepositoryRef{
    		UserRef:        userRef,
    		RepositoryName: name,
    	}
    }
    
    // newRepositoryInfo constructs a gitprovider.RepositoryInfo with the
    // given values and returns the result.
    func newRepositoryInfo(description, defaultBranch, visibility string) gitprovider.RepositoryInfo {
    	var i gitprovider.RepositoryInfo
    	if description != "" {
    		i.Description = gitprovider.StringVar(description)
    	}
    	if defaultBranch != "" {
    		i.DefaultBranch = gitprovider.StringVar(defaultBranch)
    	}
    	if visibility != "" {
    		i.Visibility = gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibility(visibility))
    	}
    	return i
    }
    
    // newDeployKeyInfo constructs a gitprovider.DeployKeyInfo with the
    // given values and returns the result.
    func newDeployKeyInfo(name, publicKey string, readWrite bool) gitprovider.DeployKeyInfo {
    	keyInfo := gitprovider.DeployKeyInfo{
    		Name: name,
    		Key:  []byte(publicKey),
    	}
    	if readWrite {
    		keyInfo.ReadOnly = gitprovider.BoolVar(false)
    	}
    	return keyInfo
    }
    
    func deployKeyName(namespace, secretName, branch, path string) string {
    	var name string
    	for _, v := range []string{namespace, secretName, branch, path} {
    		if v == "" {
    			continue
    		}
    		if name == "" {
    			name = v
    		} else {
    			name = name + "-" + v
    		}
    	}
    	return name
    }
    
    // setHostname is a helper to replace the hostname of the given URL.
    // TODO(hidde): support for this should be added in go-git-providers.
    func setHostname(URL, hostname string) (string, error) {
    	u, err := url.Parse(URL)
    	if err != nil {
    		return URL, err
    	}
    	u.Host = hostname
    	return u.String(), nil
    }