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 }