github.com/stefanmcshane/helm@v0.0.0-20221213002717-88a4a2c6e77d/pkg/downloader/chart_downloader.go (about) 1 /* 2 Copyright The Helm Authors. 3 Licensed under the Apache License, Version 2.0 (the "License"); 4 you may not use this file except in compliance with the License. 5 You may obtain a copy of the License at 6 7 http://www.apache.org/licenses/LICENSE-2.0 8 9 Unless required by applicable law or agreed to in writing, software 10 distributed under the License is distributed on an "AS IS" BASIS, 11 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 See the License for the specific language governing permissions and 13 limitations under the License. 14 */ 15 16 package downloader 17 18 import ( 19 "fmt" 20 "io" 21 "net/url" 22 "os" 23 "path/filepath" 24 "strings" 25 26 "github.com/Masterminds/semver/v3" 27 "github.com/pkg/errors" 28 29 "github.com/stefanmcshane/helm/internal/fileutil" 30 "github.com/stefanmcshane/helm/internal/urlutil" 31 "github.com/stefanmcshane/helm/pkg/getter" 32 "github.com/stefanmcshane/helm/pkg/helmpath" 33 "github.com/stefanmcshane/helm/pkg/provenance" 34 "github.com/stefanmcshane/helm/pkg/registry" 35 "github.com/stefanmcshane/helm/pkg/repo" 36 ) 37 38 // VerificationStrategy describes a strategy for determining whether to verify a chart. 39 type VerificationStrategy int 40 41 const ( 42 // VerifyNever will skip all verification of a chart. 43 VerifyNever VerificationStrategy = iota 44 // VerifyIfPossible will attempt a verification, it will not error if verification 45 // data is missing. But it will not stop processing if verification fails. 46 VerifyIfPossible 47 // VerifyAlways will always attempt a verification, and will fail if the 48 // verification fails. 49 VerifyAlways 50 // VerifyLater will fetch verification data, but not do any verification. 51 // This is to accommodate the case where another step of the process will 52 // perform verification. 53 VerifyLater 54 ) 55 56 // ErrNoOwnerRepo indicates that a given chart URL can't be found in any repos. 57 var ErrNoOwnerRepo = errors.New("could not find a repo containing the given URL") 58 59 // ChartDownloader handles downloading a chart. 60 // 61 // It is capable of performing verifications on charts as well. 62 type ChartDownloader struct { 63 // Out is the location to write warning and info messages. 64 Out io.Writer 65 // Verify indicates what verification strategy to use. 66 Verify VerificationStrategy 67 // Keyring is the keyring file used for verification. 68 Keyring string 69 // Getter collection for the operation 70 Getters getter.Providers 71 // Options provide parameters to be passed along to the Getter being initialized. 72 Options []getter.Option 73 RegistryClient *registry.Client 74 RepositoryConfig string 75 RepositoryCache string 76 } 77 78 // DownloadTo retrieves a chart. Depending on the settings, it may also download a provenance file. 79 // 80 // If Verify is set to VerifyNever, the verification will be nil. 81 // If Verify is set to VerifyIfPossible, this will return a verification (or nil on failure), and print a warning on failure. 82 // If Verify is set to VerifyAlways, this will return a verification or an error if the verification fails. 83 // If Verify is set to VerifyLater, this will download the prov file (if it exists), but not verify it. 84 // 85 // For VerifyNever and VerifyIfPossible, the Verification may be empty. 86 // 87 // Returns a string path to the location where the file was downloaded and a verification 88 // (if provenance was verified), or an error if something bad happened. 89 func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *provenance.Verification, error) { 90 u, err := c.ResolveChartVersion(ref, version) 91 if err != nil { 92 return "", nil, err 93 } 94 95 g, err := c.Getters.ByScheme(u.Scheme) 96 if err != nil { 97 return "", nil, err 98 } 99 100 data, err := g.Get(u.String(), c.Options...) 101 if err != nil { 102 return "", nil, err 103 } 104 105 name := filepath.Base(u.Path) 106 if u.Scheme == registry.OCIScheme { 107 idx := strings.LastIndexByte(name, ':') 108 name = fmt.Sprintf("%s-%s.tgz", name[:idx], name[idx+1:]) 109 } 110 111 destfile := filepath.Join(dest, name) 112 if err := fileutil.AtomicWriteFile(destfile, data, 0644); err != nil { 113 return destfile, nil, err 114 } 115 116 // If provenance is requested, verify it. 117 ver := &provenance.Verification{} 118 if c.Verify > VerifyNever { 119 body, err := g.Get(u.String() + ".prov") 120 if err != nil { 121 if c.Verify == VerifyAlways { 122 return destfile, ver, errors.Errorf("failed to fetch provenance %q", u.String()+".prov") 123 } 124 fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err) 125 return destfile, ver, nil 126 } 127 provfile := destfile + ".prov" 128 if err := fileutil.AtomicWriteFile(provfile, body, 0644); err != nil { 129 return destfile, nil, err 130 } 131 132 if c.Verify != VerifyLater { 133 ver, err = VerifyChart(destfile, c.Keyring) 134 if err != nil { 135 // Fail always in this case, since it means the verification step 136 // failed. 137 return destfile, ver, err 138 } 139 } 140 } 141 return destfile, ver, nil 142 } 143 144 func (c *ChartDownloader) getOciURI(ref, version string, u *url.URL) (*url.URL, error) { 145 var tag string 146 var err error 147 148 // Evaluate whether an explicit version has been provided. Otherwise, determine version to use 149 _, errSemVer := semver.NewVersion(version) 150 if errSemVer == nil { 151 tag = version 152 } else { 153 // Retrieve list of repository tags 154 tags, err := c.RegistryClient.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme))) 155 if err != nil { 156 return nil, err 157 } 158 if len(tags) == 0 { 159 return nil, errors.Errorf("Unable to locate any tags in provided repository: %s", ref) 160 } 161 162 // Determine if version provided 163 // If empty, try to get the highest available tag 164 // If exact version, try to find it 165 // If semver constraint string, try to find a match 166 tag, err = registry.GetTagMatchingVersionOrConstraint(tags, version) 167 if err != nil { 168 return nil, err 169 } 170 } 171 172 u.Path = fmt.Sprintf("%s:%s", u.Path, tag) 173 174 return u, err 175 } 176 177 // ResolveChartVersion resolves a chart reference to a URL. 178 // 179 // It returns the URL and sets the ChartDownloader's Options that can fetch 180 // the URL using the appropriate Getter. 181 // 182 // A reference may be an HTTP URL, an oci reference URL, a 'reponame/chartname' 183 // reference, or a local path. 184 // 185 // A version is a SemVer string (1.2.3-beta.1+f334a6789). 186 // 187 // - For fully qualified URLs, the version will be ignored (since URLs aren't versioned) 188 // - For a chart reference 189 // * If version is non-empty, this will return the URL for that version 190 // * If version is empty, this will return the URL for the latest version 191 // * If no version can be found, an error is returned 192 func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, error) { 193 u, err := url.Parse(ref) 194 if err != nil { 195 return nil, errors.Errorf("invalid chart URL format: %s", ref) 196 } 197 198 if registry.IsOCI(u.String()) { 199 return c.getOciURI(ref, version, u) 200 } 201 202 rf, err := loadRepoConfig(c.RepositoryConfig) 203 if err != nil { 204 return u, err 205 } 206 207 if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 { 208 // In this case, we have to find the parent repo that contains this chart 209 // URL. And this is an unfortunate problem, as it requires actually going 210 // through each repo cache file and finding a matching URL. But basically 211 // we want to find the repo in case we have special SSL cert config 212 // for that repo. 213 214 rc, err := c.scanReposForURL(ref, rf) 215 if err != nil { 216 // If there is no special config, return the default HTTP client and 217 // swallow the error. 218 if err == ErrNoOwnerRepo { 219 // Make sure to add the ref URL as the URL for the getter 220 c.Options = append(c.Options, getter.WithURL(ref)) 221 return u, nil 222 } 223 return u, err 224 } 225 226 // If we get here, we don't need to go through the next phase of looking 227 // up the URL. We have it already. So we just set the parameters and return. 228 c.Options = append( 229 c.Options, 230 getter.WithURL(rc.URL), 231 ) 232 if rc.CertFile != "" || rc.KeyFile != "" || rc.CAFile != "" { 233 c.Options = append(c.Options, getter.WithTLSClientConfig(rc.CertFile, rc.KeyFile, rc.CAFile)) 234 } 235 if rc.Username != "" && rc.Password != "" { 236 c.Options = append( 237 c.Options, 238 getter.WithBasicAuth(rc.Username, rc.Password), 239 getter.WithPassCredentialsAll(rc.PassCredentialsAll), 240 ) 241 } 242 return u, nil 243 } 244 245 // See if it's of the form: repo/path_to_chart 246 p := strings.SplitN(u.Path, "/", 2) 247 if len(p) < 2 { 248 return u, errors.Errorf("non-absolute URLs should be in form of repo_name/path_to_chart, got: %s", u) 249 } 250 251 repoName := p[0] 252 chartName := p[1] 253 rc, err := pickChartRepositoryConfigByName(repoName, rf.Repositories) 254 255 if err != nil { 256 return u, err 257 } 258 259 // Now that we have the chart repository information we can use that URL 260 // to set the URL for the getter. 261 c.Options = append(c.Options, getter.WithURL(rc.URL)) 262 263 r, err := repo.NewChartRepository(rc, c.Getters) 264 if err != nil { 265 return u, err 266 } 267 268 if r != nil && r.Config != nil { 269 if r.Config.CertFile != "" || r.Config.KeyFile != "" || r.Config.CAFile != "" { 270 c.Options = append(c.Options, getter.WithTLSClientConfig(r.Config.CertFile, r.Config.KeyFile, r.Config.CAFile)) 271 } 272 if r.Config.Username != "" && r.Config.Password != "" { 273 c.Options = append(c.Options, 274 getter.WithBasicAuth(r.Config.Username, r.Config.Password), 275 getter.WithPassCredentialsAll(r.Config.PassCredentialsAll), 276 ) 277 } 278 } 279 280 // Next, we need to load the index, and actually look up the chart. 281 idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name)) 282 i, err := repo.LoadIndexFile(idxFile) 283 if err != nil { 284 return u, errors.Wrap(err, "no cached repo found. (try 'helm repo update')") 285 } 286 287 cv, err := i.Get(chartName, version) 288 if err != nil { 289 return u, errors.Wrapf(err, "chart %q matching %s not found in %s index. (try 'helm repo update')", chartName, version, r.Config.Name) 290 } 291 292 if len(cv.URLs) == 0 { 293 return u, errors.Errorf("chart %q has no downloadable URLs", ref) 294 } 295 296 // TODO: Seems that picking first URL is not fully correct 297 u, err = url.Parse(cv.URLs[0]) 298 if err != nil { 299 return u, errors.Errorf("invalid chart URL format: %s", ref) 300 } 301 302 // If the URL is relative (no scheme), prepend the chart repo's base URL 303 if !u.IsAbs() { 304 repoURL, err := url.Parse(rc.URL) 305 if err != nil { 306 return repoURL, err 307 } 308 q := repoURL.Query() 309 // We need a trailing slash for ResolveReference to work, but make sure there isn't already one 310 repoURL.RawPath = strings.TrimSuffix(repoURL.RawPath, "/") + "/" 311 repoURL.Path = strings.TrimSuffix(repoURL.Path, "/") + "/" 312 u = repoURL.ResolveReference(u) 313 u.RawQuery = q.Encode() 314 // TODO add user-agent 315 if _, err := getter.NewHTTPGetter(getter.WithURL(rc.URL)); err != nil { 316 return repoURL, err 317 } 318 return u, err 319 } 320 321 // TODO add user-agent 322 return u, nil 323 } 324 325 // VerifyChart takes a path to a chart archive and a keyring, and verifies the chart. 326 // 327 // It assumes that a chart archive file is accompanied by a provenance file whose 328 // name is the archive file name plus the ".prov" extension. 329 func VerifyChart(path, keyring string) (*provenance.Verification, error) { 330 // For now, error out if it's not a tar file. 331 switch fi, err := os.Stat(path); { 332 case err != nil: 333 return nil, err 334 case fi.IsDir(): 335 return nil, errors.New("unpacked charts cannot be verified") 336 case !isTar(path): 337 return nil, errors.New("chart must be a tgz file") 338 } 339 340 provfile := path + ".prov" 341 if _, err := os.Stat(provfile); err != nil { 342 return nil, errors.Wrapf(err, "could not load provenance file %s", provfile) 343 } 344 345 sig, err := provenance.NewFromKeyring(keyring, "") 346 if err != nil { 347 return nil, errors.Wrap(err, "failed to load keyring") 348 } 349 return sig.Verify(path, provfile) 350 } 351 352 // isTar tests whether the given file is a tar file. 353 // 354 // Currently, this simply checks extension, since a subsequent function will 355 // untar the file and validate its binary format. 356 func isTar(filename string) bool { 357 return strings.EqualFold(filepath.Ext(filename), ".tgz") 358 } 359 360 func pickChartRepositoryConfigByName(name string, cfgs []*repo.Entry) (*repo.Entry, error) { 361 for _, rc := range cfgs { 362 if rc.Name == name { 363 if rc.URL == "" { 364 return nil, errors.Errorf("no URL found for repository %s", name) 365 } 366 return rc, nil 367 } 368 } 369 return nil, errors.Errorf("repo %s not found", name) 370 } 371 372 // scanReposForURL scans all repos to find which repo contains the given URL. 373 // 374 // This will attempt to find the given URL in all of the known repositories files. 375 // 376 // If the URL is found, this will return the repo entry that contained that URL. 377 // 378 // If all of the repos are checked, but the URL is not found, an ErrNoOwnerRepo 379 // error is returned. 380 // 381 // Other errors may be returned when repositories cannot be loaded or searched. 382 // 383 // Technically, the fact that a URL is not found in a repo is not a failure indication. 384 // Charts are not required to be included in an index before they are valid. So 385 // be mindful of this case. 386 // 387 // The same URL can technically exist in two or more repositories. This algorithm 388 // will return the first one it finds. Order is determined by the order of repositories 389 // in the repositories.yaml file. 390 func (c *ChartDownloader) scanReposForURL(u string, rf *repo.File) (*repo.Entry, error) { 391 // FIXME: This is far from optimal. Larger installations and index files will 392 // incur a performance hit for this type of scanning. 393 for _, rc := range rf.Repositories { 394 r, err := repo.NewChartRepository(rc, c.Getters) 395 if err != nil { 396 return nil, err 397 } 398 399 idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name)) 400 i, err := repo.LoadIndexFile(idxFile) 401 if err != nil { 402 return nil, errors.Wrap(err, "no cached repo found. (try 'helm repo update')") 403 } 404 405 for _, entry := range i.Entries { 406 for _, ver := range entry { 407 for _, dl := range ver.URLs { 408 if urlutil.Equal(u, dl) { 409 return rc, nil 410 } 411 } 412 } 413 } 414 } 415 // This means that there is no repo file for the given URL. 416 return nil, ErrNoOwnerRepo 417 } 418 419 func loadRepoConfig(file string) (*repo.File, error) { 420 r, err := repo.LoadFile(file) 421 if err != nil && !os.IsNotExist(errors.Cause(err)) { 422 return nil, err 423 } 424 return r, nil 425 }