sigs.k8s.io/cluster-api@v1.7.1/cmd/clusterctl/client/cluster/template.go (about)

     1  /*
     2  Copyright 2020 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package cluster
    18  
    19  import (
    20  	"context"
    21  	"encoding/base64"
    22  	"fmt"
    23  	"io"
    24  	"net/http"
    25  	"net/url"
    26  	"os"
    27  	"strings"
    28  
    29  	"github.com/google/go-github/v53/github"
    30  	"github.com/pkg/errors"
    31  	"golang.org/x/oauth2"
    32  	corev1 "k8s.io/api/core/v1"
    33  	"sigs.k8s.io/controller-runtime/pkg/client"
    34  
    35  	"sigs.k8s.io/cluster-api/cmd/clusterctl/client/config"
    36  	"sigs.k8s.io/cluster-api/cmd/clusterctl/client/repository"
    37  	yaml "sigs.k8s.io/cluster-api/cmd/clusterctl/client/yamlprocessor"
    38  )
    39  
    40  // TemplateClient has methods to work with templates stored in the cluster/out of the provider repository.
    41  type TemplateClient interface {
    42  	// GetFromConfigMap returns a workload cluster template from the given ConfigMap.
    43  	GetFromConfigMap(ctx context.Context, namespace, name, dataKey, targetNamespace string, skipTemplateProcess bool) (repository.Template, error)
    44  
    45  	// GetFromURL returns a workload cluster template from the given URL.
    46  	GetFromURL(ctx context.Context, templateURL, targetNamespace string, skipTemplateProcess bool) (repository.Template, error)
    47  }
    48  
    49  // templateClient implements TemplateClient.
    50  type templateClient struct {
    51  	proxy               Proxy
    52  	configClient        config.Client
    53  	gitHubClientFactory func(ctx context.Context, configVariablesClient config.VariablesClient) (*github.Client, error)
    54  	processor           yaml.Processor
    55  	httpClient          *http.Client
    56  }
    57  
    58  // ensure templateClient implements TemplateClient.
    59  var _ TemplateClient = &templateClient{}
    60  
    61  // TemplateClientInput is an input struct for newTemplateClient.
    62  type TemplateClientInput struct {
    63  	proxy        Proxy
    64  	configClient config.Client
    65  	processor    yaml.Processor
    66  }
    67  
    68  // newTemplateClient returns a templateClient.
    69  func newTemplateClient(input TemplateClientInput) *templateClient {
    70  	return &templateClient{
    71  		proxy:               input.proxy,
    72  		configClient:        input.configClient,
    73  		gitHubClientFactory: getGitHubClient,
    74  		processor:           input.processor,
    75  		httpClient:          http.DefaultClient,
    76  	}
    77  }
    78  
    79  func (t *templateClient) GetFromConfigMap(ctx context.Context, configMapNamespace, configMapName, configMapDataKey, targetNamespace string, skipTemplateProcess bool) (repository.Template, error) {
    80  	if configMapNamespace == "" {
    81  		return nil, errors.New("invalid GetFromConfigMap operation: missing configMapNamespace value")
    82  	}
    83  	if configMapName == "" {
    84  		return nil, errors.New("invalid GetFromConfigMap operation: missing configMapName value")
    85  	}
    86  
    87  	c, err := t.proxy.NewClient(ctx)
    88  	if err != nil {
    89  		return nil, err
    90  	}
    91  
    92  	configMap := &corev1.ConfigMap{}
    93  	key := client.ObjectKey{
    94  		Namespace: configMapNamespace,
    95  		Name:      configMapName,
    96  	}
    97  
    98  	if err := c.Get(ctx, key, configMap); err != nil {
    99  		return nil, errors.Wrapf(err, "error reading ConfigMap %s/%s", configMapNamespace, configMapName)
   100  	}
   101  
   102  	data, ok := configMap.Data[configMapDataKey]
   103  	if !ok {
   104  		return nil, errors.Errorf("the ConfigMap %s/%s does not have the %q data key", configMapNamespace, configMapName, configMapDataKey)
   105  	}
   106  
   107  	return repository.NewTemplate(repository.TemplateInput{
   108  		RawArtifact:           []byte(data),
   109  		ConfigVariablesClient: t.configClient.Variables(),
   110  		Processor:             t.processor,
   111  		TargetNamespace:       targetNamespace,
   112  		SkipTemplateProcess:   skipTemplateProcess,
   113  	})
   114  }
   115  
   116  func (t *templateClient) GetFromURL(ctx context.Context, templateURL, targetNamespace string, skipTemplateProcess bool) (repository.Template, error) {
   117  	if templateURL == "" {
   118  		return nil, errors.New("invalid GetFromURL operation: missing templateURL value")
   119  	}
   120  
   121  	content, err := t.getURLContent(ctx, templateURL)
   122  	if err != nil {
   123  		return nil, errors.Wrapf(err, "invalid GetFromURL operation")
   124  	}
   125  
   126  	return repository.NewTemplate(repository.TemplateInput{
   127  		RawArtifact:           content,
   128  		ConfigVariablesClient: t.configClient.Variables(),
   129  		Processor:             t.processor,
   130  		TargetNamespace:       targetNamespace,
   131  		SkipTemplateProcess:   skipTemplateProcess,
   132  	})
   133  }
   134  
   135  func (t *templateClient) getURLContent(ctx context.Context, templateURL string) ([]byte, error) {
   136  	if templateURL == "-" {
   137  		b, err := io.ReadAll(os.Stdin)
   138  		if err != nil {
   139  			return nil, errors.Wrapf(err, "failed to read stdin")
   140  		}
   141  		return b, nil
   142  	}
   143  
   144  	rURL, err := url.Parse(templateURL)
   145  	if err != nil {
   146  		return nil, errors.Wrapf(err, "failed to parse %q", templateURL)
   147  	}
   148  
   149  	if rURL.Scheme == "https" {
   150  		if rURL.Host == "github.com" {
   151  			return t.getGitHubFileContent(ctx, rURL)
   152  		}
   153  		return t.getRawURLFileContent(ctx, templateURL)
   154  	}
   155  
   156  	if rURL.Scheme == "file" || rURL.Scheme == "" {
   157  		return t.getLocalFileContent(rURL)
   158  	}
   159  
   160  	return nil, errors.Errorf("unable to read content from %q. Only reading from GitHub and local file system is supported", templateURL)
   161  }
   162  
   163  func (t *templateClient) getLocalFileContent(rURL *url.URL) ([]byte, error) {
   164  	f, err := os.Stat(rURL.Path)
   165  	if err != nil {
   166  		return nil, errors.Errorf("failed to read file %q", rURL.Path)
   167  	}
   168  	if f.IsDir() {
   169  		return nil, errors.Errorf("invalid path: file %q is actually a directory", rURL.Path)
   170  	}
   171  	content, err := os.ReadFile(rURL.Path)
   172  	if err != nil {
   173  		return nil, errors.Wrapf(err, "failed to read file %q", rURL.Path)
   174  	}
   175  
   176  	return content, nil
   177  }
   178  
   179  func (t *templateClient) getGitHubFileContent(ctx context.Context, rURL *url.URL) ([]byte, error) {
   180  	// Check if the path is in the expected format,
   181  	urlSplit := strings.Split(strings.TrimPrefix(rURL.Path, "/"), "/")
   182  	if len(urlSplit) < 5 {
   183  		return nil, errors.Errorf(
   184  			"invalid GitHub url %q: a GitHub url should be in on of these the forms\n"+
   185  				"- https://github.com/{owner}/{repository}/blob/{branch}/{path-to-file}\n"+
   186  				"- https://github.com/{owner}/{repository}/releases/download/{tag}/{asset-file-name}", rURL,
   187  		)
   188  	}
   189  
   190  	// Extract all the info from url split.
   191  	owner := urlSplit[0]
   192  	repo := urlSplit[1]
   193  	linkType := urlSplit[2]
   194  
   195  	// gets the GitHub client
   196  	ghClient, err := t.gitHubClientFactory(ctx, t.configClient.Variables())
   197  	if err != nil {
   198  		return nil, err
   199  	}
   200  
   201  	// gets the file from GiHub
   202  	switch linkType {
   203  	case "blob": // get file from a code in a github repo
   204  		branch := urlSplit[3]
   205  		path := strings.Join(urlSplit[4:], "/")
   206  
   207  		return getGithubFileContentFromCode(ctx, ghClient, rURL.Path, owner, repo, path, branch)
   208  
   209  	case "releases": // get a github release asset
   210  		if urlSplit[3] != "download" {
   211  			break
   212  		}
   213  		tag := urlSplit[4]
   214  		assetName := urlSplit[5]
   215  
   216  		return getGithubAssetFromRelease(ctx, ghClient, rURL.Path, owner, repo, tag, assetName)
   217  	}
   218  
   219  	return nil, fmt.Errorf("unknown github URL: %v", rURL)
   220  }
   221  
   222  func getGithubFileContentFromCode(ctx context.Context, ghClient *github.Client, fullPath string, owner string, repo string, path string, branch string) ([]byte, error) {
   223  	fileContent, _, _, err := ghClient.Repositories.GetContents(ctx, owner, repo, path, &github.RepositoryContentGetOptions{Ref: branch})
   224  	if err != nil {
   225  		return nil, handleGithubErr(err, "failed to get %q", fullPath)
   226  	}
   227  	if fileContent == nil {
   228  		return nil, errors.Errorf("%q does not return a valid file content", fullPath)
   229  	}
   230  	if fileContent.Encoding == nil || *fileContent.Encoding != "base64" {
   231  		return nil, errors.Errorf("invalid encoding detected for %q. Only base64 encoding supported", fullPath)
   232  	}
   233  	content, err := base64.StdEncoding.DecodeString(*fileContent.Content)
   234  	if err != nil {
   235  		return nil, errors.Wrapf(err, "failed to decode file %q", fullPath)
   236  	}
   237  	return content, nil
   238  }
   239  
   240  func (t *templateClient) getRawURLFileContent(ctx context.Context, rURL string) ([]byte, error) {
   241  	request, err := http.NewRequestWithContext(ctx, http.MethodGet, rURL, http.NoBody)
   242  	if err != nil {
   243  		return nil, err
   244  	}
   245  
   246  	response, err := t.httpClient.Do(request)
   247  	if err != nil {
   248  		return nil, err
   249  	}
   250  	defer response.Body.Close()
   251  
   252  	if response.StatusCode != http.StatusOK {
   253  		return nil, errors.Errorf("failed to get file, got %d", response.StatusCode)
   254  	}
   255  
   256  	content, err := io.ReadAll(response.Body)
   257  	if err != nil {
   258  		return nil, err
   259  	}
   260  
   261  	return content, nil
   262  }
   263  
   264  func getGithubAssetFromRelease(ctx context.Context, ghClient *github.Client, path string, owner string, repo string, tag string, assetName string) ([]byte, error) {
   265  	release, _, err := ghClient.Repositories.GetReleaseByTag(ctx, owner, repo, tag)
   266  	if err != nil {
   267  		return nil, handleGithubErr(err, "failed to get release '%s' from %s/%s repository", tag, owner, repo)
   268  	}
   269  
   270  	if release == nil {
   271  		return nil, fmt.Errorf("can't find release '%s' in %s/%s repository", tag, owner, repo)
   272  	}
   273  
   274  	var rc io.ReadCloser
   275  	for _, asset := range release.Assets {
   276  		if asset.GetName() == assetName {
   277  			rc, _, err = ghClient.Repositories.DownloadReleaseAsset(ctx, owner, repo, asset.GetID(), ghClient.Client())
   278  			if err != nil {
   279  				return nil, errors.Wrapf(err, "failed to download file %q", path)
   280  			}
   281  			break
   282  		}
   283  	}
   284  
   285  	if rc == nil {
   286  		return nil, fmt.Errorf("failed to download the file %q", path)
   287  	}
   288  
   289  	defer func() { _ = rc.Close() }()
   290  
   291  	return io.ReadAll(rc)
   292  }
   293  
   294  func getGitHubClient(ctx context.Context, configVariablesClient config.VariablesClient) (*github.Client, error) {
   295  	var authenticatingHTTPClient *http.Client
   296  	if token, err := configVariablesClient.Get(config.GitHubTokenVariable); err == nil {
   297  		ts := oauth2.StaticTokenSource(
   298  			&oauth2.Token{AccessToken: token},
   299  		)
   300  		authenticatingHTTPClient = oauth2.NewClient(ctx, ts)
   301  	}
   302  
   303  	return github.NewClient(authenticatingHTTPClient), nil
   304  }
   305  
   306  // handleGithubErr wraps error messages.
   307  func handleGithubErr(err error, message string, args ...interface{}) error {
   308  	if _, ok := err.(*github.RateLimitError); ok {
   309  		return errors.New("rate limit for github api has been reached. Please wait one hour or get a personal API token and assign it to the GITHUB_TOKEN environment variable")
   310  	}
   311  	return errors.Wrapf(err, message, args...)
   312  }