@@ -19,6 +19,8 @@ package clusterctl
19
19
import (
20
20
"context"
21
21
"fmt"
22
+ "io"
23
+ "net/http"
22
24
"net/url"
23
25
"os"
24
26
"path/filepath"
@@ -33,6 +35,7 @@ import (
33
35
. "github.com/onsi/gomega"
34
36
"github.com/pkg/errors"
35
37
"k8s.io/apimachinery/pkg/util/version"
38
+ "k8s.io/apimachinery/pkg/util/wait"
36
39
"k8s.io/utils/ptr"
37
40
"sigs.k8s.io/yaml"
38
41
@@ -304,13 +307,16 @@ func ResolveRelease(ctx context.Context, releaseMarker string) (string, error) {
304
307
return "" , errors .Errorf ("releasemarker does not support disabling the go proxy: GOPROXY=%q" , os .Getenv ("GOPROXY" ))
305
308
}
306
309
goproxyClient := goproxy .NewClient (scheme , host )
307
- return resolveReleaseMarker (ctx , releaseMarker , goproxyClient )
310
+ return resolveReleaseMarker (ctx , releaseMarker , goproxyClient , githubReleaseMetadataURL )
308
311
}
309
312
310
313
// resolveReleaseMarker resolves releaseMarker string to verion string e.g.
311
314
// - Resolves "go://sigs.k8s.io/cluster-api@v1.0" to the latest stable patch release of v1.0.
312
315
// - Resolves "go://sigs.k8s.io/cluster-api@latest-v1.0" to the latest patch release of v1.0 including rc and pre releases.
313
- func resolveReleaseMarker (ctx context.Context , releaseMarker string , goproxyClient * goproxy.Client ) (string , error ) {
316
+ // It also checks if a release actually exists by trying to query for the `metadata.yaml` file.
317
+ // To do that the url gets calculated by the toMetadataURL function. The toMetadataURL func
318
+ // is passed as variable to be able to write a proper unit test.
319
+ func resolveReleaseMarker (ctx context.Context , releaseMarker string , goproxyClient * goproxy.Client , toMetadataURL func (gomodule , version string ) string ) (string , error ) {
314
320
if ! strings .HasPrefix (releaseMarker , "go://" ) {
315
321
return "" , errors .Errorf ("unknown release marker scheme" )
316
322
}
@@ -330,31 +336,131 @@ func resolveReleaseMarker(ctx context.Context, releaseMarker string, goproxyClie
330
336
if strings .HasPrefix (gomoduleParts [1 ], "latest-" ) {
331
337
includePrereleases = true
332
338
}
333
- version := strings .TrimPrefix (gomoduleParts [1 ], "latest-" ) + ".0"
334
- version = strings .TrimPrefix (version , "v" )
335
- semVersion , err := semver .Parse (version )
339
+ rawVersion := strings .TrimPrefix (gomoduleParts [1 ], "latest-" ) + ".0"
340
+ rawVersion = strings .TrimPrefix (rawVersion , "v" )
341
+ semVersion , err := semver .Parse (rawVersion )
336
342
if err != nil {
337
- return "" , errors .Wrapf (err , "parsing semver for %s" , version )
343
+ return "" , errors .Wrapf (err , "parsing semver for %s" , rawVersion )
338
344
}
339
345
340
- parsedTags , err := goproxyClient .GetVersions (ctx , gomodule )
346
+ versions , err := goproxyClient .GetVersions (ctx , gomodule )
341
347
if err != nil {
342
348
return "" , err
343
349
}
344
350
345
- var picked semver.Version
346
- for i , tag := range parsedTags {
347
- if ! includePrereleases && len (tag .Pre ) > 0 {
351
+ // Search for the latest release according to semantic version ordering.
352
+ // Releases with tag name that are not in semver format are ignored.
353
+ versionCandidates := []semver.Version {}
354
+ for _ , version := range versions {
355
+ if ! includePrereleases && len (version .Pre ) > 0 {
348
356
continue
349
357
}
350
- if tag .Major == semVersion .Major && tag .Minor = = semVersion .Minor {
351
- picked = parsedTags [ i ]
358
+ if version .Major != semVersion .Major || version .Minor ! = semVersion .Minor {
359
+ continue
352
360
}
361
+
362
+ versionCandidates = append (versionCandidates , version )
353
363
}
354
- if picked .Major == 0 && picked .Minor == 0 && picked .Patch == 0 {
364
+
365
+ if len (versionCandidates ) == 0 {
355
366
return "" , errors .Errorf ("no suitable release available for release marker %s" , releaseMarker )
356
367
}
357
- return picked .String (), nil
368
+
369
+ // Sort parsed versions by semantic version order.
370
+ sort .SliceStable (versionCandidates , func (i , j int ) bool {
371
+ // Prioritize pre-release versions over releases. For example v2.0.0-alpha > v1.0.0
372
+ // If both are pre-releases, sort by semantic version order as usual.
373
+ if len (versionCandidates [i ].Pre ) == 0 && len (versionCandidates [j ].Pre ) > 0 {
374
+ return false
375
+ }
376
+ if len (versionCandidates [j ].Pre ) == 0 && len (versionCandidates [i ].Pre ) > 0 {
377
+ return true
378
+ }
379
+
380
+ return versionCandidates [j ].LT (versionCandidates [i ])
381
+ })
382
+
383
+ // Limit the number of searchable versions by 5.
384
+ versionCandidates = versionCandidates [:min (5 , len (versionCandidates ))]
385
+
386
+ for _ , v := range versionCandidates {
387
+ // Iterate through sorted versions and try to fetch a file from that release.
388
+ // If it's completed successfully, we get the latest release.
389
+ // Note: the fetched file will be cached and next time we will get it from the cache.
390
+ versionString := "v" + v .String ()
391
+ _ , err := httpGetURL (ctx , toMetadataURL (gomodule , versionString ))
392
+ if err != nil {
393
+ if errors .Is (err , errNotFound ) {
394
+ // Ignore this version
395
+ continue
396
+ }
397
+
398
+ return "" , err
399
+ }
400
+
401
+ return v .String (), nil
402
+ }
403
+
404
+ // If we reached this point, it means we didn't find any release.
405
+ return "" , errors .New ("failed to find releases tagged with a valid semantic version number" )
406
+ }
407
+
408
+ var (
409
+ retryableOperationInterval = 10 * time .Second
410
+ retryableOperationTimeout = 1 * time .Minute
411
+ errNotFound = errors .New ("404 Not Found" )
412
+ )
413
+
414
+ func githubReleaseMetadataURL (gomodule , version string ) string {
415
+ // Rewrite gomodule to the github repository
416
+ if strings .HasPrefix (gomodule , "k8s.io" ) {
417
+ gomodule = strings .Replace (gomodule , "k8s.io" , "github.com/kubernetes" , 1 )
418
+ }
419
+ if strings .HasPrefix (gomodule , "sigs.k8s.io" ) {
420
+ gomodule = strings .Replace (gomodule , "sigs.k8s.io" , "github.com/kubernetes-sigs" , 1 )
421
+ }
422
+
423
+ return fmt .Sprintf ("https://%s/releases/download/%s/metadata.yaml" , gomodule , version )
424
+ }
425
+
426
+ // httpGetURL does a GET request to the given url and returns its content.
427
+ // If the responses StatusCode is 404 (StatusNotFound) it does not do a retry because
428
+ // the result is not expected to change.
429
+ func httpGetURL (ctx context.Context , url string ) ([]byte , error ) {
430
+ var retryError error
431
+ var content []byte
432
+ _ = wait .PollUntilContextTimeout (ctx , retryableOperationInterval , retryableOperationTimeout , true , func (context.Context ) (bool , error ) {
433
+ resp , err := http .Get (url ) //nolint:gosec,noctx
434
+ if err != nil {
435
+ retryError = errors .Wrap (err , "error sending request" )
436
+ return false , nil
437
+ }
438
+ defer resp .Body .Close ()
439
+
440
+ // if we get 404 there is no reason to retry
441
+ if resp .StatusCode == http .StatusNotFound {
442
+ retryError = errNotFound
443
+ return true , nil
444
+ }
445
+
446
+ if resp .StatusCode != http .StatusOK {
447
+ retryError = errors .Errorf ("error getting file, status code: %d" , resp .StatusCode )
448
+ return false , nil
449
+ }
450
+
451
+ content , err = io .ReadAll (resp .Body )
452
+ if err != nil {
453
+ retryError = errors .Wrap (err , "error reading response body" )
454
+ return false , nil
455
+ }
456
+
457
+ retryError = nil
458
+ return true , nil
459
+ })
460
+ if retryError != nil {
461
+ return nil , retryError
462
+ }
463
+ return content , nil
358
464
}
359
465
360
466
// Defaults assigns default values to the object. More specifically:
0 commit comments