Repository: gruntwork-io/kubergrunt
Pull request #139 (2021-08-20 18:56:34)

Mention how to set the version on the compiled binary


Patch:
diff --git a/README.md b/README.md
index 933a359..c3f46ad 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,13 @@ The main package is in `cmd`. To build the binary, you can run:
 go build -o bin/kubergrunt ./cmd
 ```
 
+If you need to set a version on the binary (so that `kubergrunt --version` works), you use `ldflags` to set the version
+string on the compiled binary:
+
+```
+go build -o kubergrunt -ldflags "-X main.VERSION=v0.7.6 -extldflags '-static'" ./cmd
+```
+
 
 ## Commands
 
diff --git a/eks/sync.go b/eks/sync.go
index ba44675..d80360a 100644
--- a/eks/sync.go
+++ b/eks/sync.go
@@ -22,34 +22,44 @@ import (
 	k8stypes "k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/kubernetes"
 
+	"github.com/gruntwork-io/kubergrunt/commonerrors"
 	"github.com/gruntwork-io/kubergrunt/eksawshelper"
 	"github.com/gruntwork-io/kubergrunt/jsonpatch"
 	"github.com/gruntwork-io/kubergrunt/kubectl"
 	"github.com/gruntwork-io/kubergrunt/logging"
 )
 
+const (
+	containerAccountID = "602401143452"
+	kubeProxyRepoPath  = "eks/kube-proxy"
+	coreDNSRepoPath    = "eks/coredns"
+
+	// Largest eksbuild tag we will try looking for.
+	maxEKSBuild = 10
+)
+
 var (
 	// NOTE: Ensure that there is an entry for each supported version in the following tables.
 	supportedVersions = []string{"1.21", "1.20", "1.19", "1.18", "1.17", "1.16"}
 
 	// Reference: https://docs.aws.amazon.com/eks/latest/userguide/managing-coredns.html
 	coreDNSVersionLookupTable = map[string]string{
-		"1.21": "1.8.4-eksbuild.1",
-		"1.20": "1.8.3-eksbuild.1",
-		"1.19": "1.8.0-eksbuild.1",
-		"1.18": "1.7.0-eksbuild.1",
-		"1.17": "1.6.6-eksbuild.1",
-		"1.16": "1.6.6-eksbuild.1",
+		"1.21": "1.8.4-eksbuild",
+		"1.20": "1.8.3-eksbuild",
+		"1.19": "1.8.0-eksbuild",
+		"1.18": "1.7.0-eksbuild",
+		"1.17": "1.6.6-eksbuild",
+		"1.16": "1.6.6-eksbuild",
 	}
 
 	// Reference: https://docs.aws.amazon.com/eks/latest/userguide/managing-kube-proxy.html#updating-kube-proxy-add-on
 	kubeProxyVersionLookupTable = map[string]string{
-		"1.21": "1.21.2-eksbuild.2",
-		"1.20": "1.20.4-eksbuild.2",
-		"1.19": "1.19.6-eksbuild.2",
-		"1.18": "1.18.8-eksbuild.2",
-		"1.17": "1.17.9-eksbuild.2",
-		"1.16": "1.16.13-eksbuild.2",
+		"1.21": "1.21.2-eksbuild",
+		"1.20": "1.20.4-eksbuild",
+		"1.19": "1.19.6-eksbuild",
+		"1.18": "1.18.8-eksbuild",
+		"1.17": "1.17.9-eksbuild",
+		"1.16": "1.16.13-eksbuild",
 	}
 
 	// Reference: https://docs.aws.amazon.com/eks/latest/userguide/managing-vpc-cni.html
@@ -82,6 +92,12 @@ type SkipComponentsConfig struct {
 	VPCCNI    bool
 }
 
+type componentVersions struct {
+	kubeProxy string
+	coreDNS   string
+	vpcCNI    string
+}
+
 // SyncClusterComponents will perform the steps described in
 // https://docs.aws.amazon.com/eks/latest/userguide/update-cluster.html
 // There are three core applications on an EKS cluster:
@@ -113,8 +129,27 @@ func SyncClusterComponents(
 		return errors.WithStackTrace(UnsupportedEKSVersion{k8sVersion})
 	}
 
-	kubeProxyVersion := kubeProxyVersionLookupTable[k8sVersion]
-	coreDNSVersion := coreDNSVersionLookupTable[k8sVersion]
+	awsRegion, err := eksawshelper.GetRegionFromArn(eksClusterArn)
+	if err != nil {
+		return err
+	}
+
+	dockerToken, err := eksawshelper.GetDockerLoginToken(awsRegion)
+	if err != nil {
+		return err
+	}
+
+	repoDomain := getRepoDomain(awsRegion)
+	kubeProxyVersion, err := findLatestEKSBuild(dockerToken, repoDomain, kubeProxyRepoPath, kubeProxyVersionLookupTable[k8sVersion])
+	if err != nil {
+		return err
+	}
+
+	coreDNSVersion, err := findLatestEKSBuild(dockerToken, repoDomain, coreDNSRepoPath, coreDNSVersionLookupTable[k8sVersion])
+	if err != nil {
+		return err
+	}
+
 	amznVPCCNIVersion := amazonVPCCNIVersionLookupTable[k8sVersion]
 
 	logger.Info("Syncing Kubernetes Applications to:")
@@ -134,11 +169,6 @@ func SyncClusterComponents(
 		return err
 	}
 
-	awsRegion, err := eksawshelper.GetRegionFromArn(eksClusterArn)
-	if err != nil {
-		return err
-	}
-
 	if skipConfig.KubeProxy {
 		logger.Info("Skipping kube-proxy sync.")
 	} else {
@@ -179,7 +209,7 @@ func upgradeKubeProxy(
 ) error {
 	logger := logging.GetProjectLogger()
 
-	targetImage := fmt.Sprintf("602401143452.dkr.ecr.%s.amazonaws.com/eks/kube-proxy:v%s", awsRegion, kubeProxyVersion)
+	targetImage := fmt.Sprintf("%s/%s:v%s", getRepoDomain(awsRegion), kubeProxyRepoPath, kubeProxyVersion)
 	currentImage, err := getCurrentDeployedKubeProxyImage(clientset)
 	if err != nil {
 		return err
@@ -292,7 +322,7 @@ func upgradeCoreDNS(
 		logger.Info("ClusterRole permissions for coredns is up to date. Skipping adjusting ClusterRole permissions.")
 	}
 
-	targetImage := fmt.Sprintf("602401143452.dkr.ecr.%s.amazonaws.com/eks/coredns:v%s", awsRegion, coreDNSVersion)
+	targetImage := fmt.Sprintf("%s/%s:v%s", getRepoDomain(awsRegion), coreDNSRepoPath, coreDNSVersion)
 	currentImage, err := getCurrentDeployedCoreDNSImage(clientset)
 	if err != nil {
 		return err
@@ -581,3 +611,40 @@ func removeUpstreamKeywordFromCorednsConfigMap(clientset *kubernetes.Clientset,
 	_, err = configMapAPI.Update(context.Background(), corednsConfigMap, metav1.UpdateOptions{})
 	return errors.WithStackTrace(err)
 }
+
+// findLatestEKSBuild will continuously query the ECR repo to look for the latest eksbuild version. We do this by
+// incrementally checking one tag at a time until we reach a 404, or the maximum trials.
+func findLatestEKSBuild(token, repoDomain, repoPath, tagBase string) (string, error) {
+	logger := logging.GetProjectLogger()
+	logger.Debugf("Looking up latest eksbuild for repo %s/%s", repoDomain, repoPath)
+
+	var existingTag string
+	for i := 0; i < maxEKSBuild; i++ {
+		version := fmt.Sprintf("%s.%d", tagBase, i+1)
+		query := "v" + version
+		logger.Debugf("Trying %s", query)
+		tagExists, err := eksawshelper.TagExistsInRepo(token, repoDomain, repoPath, query)
+		if err != nil {
+			return "", err
+		}
+		if tagExists {
+			logger.Debugf("Found %s", query)
+			// Update the latest tag marker
+			existingTag = version
+		} else {
+			logger.Debugf("Not found %s", query)
+			logger.Debugf("Returning %s", existingTag)
+			// At this point, the last existing tag we encountered is the latest, so we return it.
+			return existingTag, nil
+		}
+	}
+
+	// MAINTAINER'S NOTE: If we ever reach here, this is 100% a bug in kubergrunt. Investigation is needed to resolve
+	// this, as it could be either the wrong version is being queried, or the maxEKSBuild count is too small.
+	return "", commonerrors.ImpossibleErr("TOO_MANY_EKS_BUILD_TAGS")
+}
+
+// getRepoDomain is a conveniency function to construct the ECR docker repo URL domain.
+func getRepoDomain(region string) string {
+	return fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com", containerAccountID, region)
+}
diff --git a/eksawshelper/ecr.go b/eksawshelper/ecr.go
new file mode 100644
index 0000000..8b85e21
--- /dev/null
+++ b/eksawshelper/ecr.go
@@ -0,0 +1,77 @@
+package eksawshelper
+
+import (
+	"fmt"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/ecr"
+	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/gruntwork-io/kubergrunt/commonerrors"
+	"github.com/hashicorp/go-cleanhttp"
+)
+
+// TagExistsInRepo queries the ECR repository docker API to see if the given tag exists for the given ECR repository.
+func TagExistsInRepo(token, repoDomain, repoPath, tag string) (bool, error) {
+	manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", repoDomain, repoPath, tag)
+	req, err := http.NewRequest("GET", manifestURL, nil)
+	if err != nil {
+		return false, errors.WithStackTrace(err)
+	}
+	req.Header.Set("Authorization", "Basic "+token)
+
+	httpClient := cleanhttp.DefaultClient()
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		return false, errors.WithStackTrace(err)
+	}
+
+	switch resp.StatusCode {
+	case 200:
+		return true, nil
+	case 404:
+		return false, nil
+	}
+
+	// All other status codes should be consider API errors.
+	defer resp.Body.Close()
+	body, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return false, errors.WithStackTrace(err)
+	}
+	return false, errors.WithStackTrace(ECRManifestFetchError{
+		manifestURL: manifestURL,
+		statusCode:  resp.StatusCode,
+		body:        string(body),
+	})
+}
+
+// GetDockerLoginToken retrieves an authorization token that can be used to access ECR via the docker APIs. The
+// return token can directly be used as a HTTP authorization header for basic auth.
+func GetDockerLoginToken(region string) (string, error) {
+	client, err := NewECRClient(region)
+	if err != nil {
+		return "", errors.WithStackTrace(err)
+	}
+
+	resp, err := client.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
+	if err != nil {
+		return "", errors.WithStackTrace(err)
+	}
+
+	if len(resp.AuthorizationData) != 1 {
+		// AWS docs mention that there is always one token returned on a successful response.
+		return "", errors.WithStackTrace(commonerrors.ImpossibleErr("AWS_DID_NOT_RETURN_DOCKER_TOKEN"))
+	}
+	return aws.StringValue(resp.AuthorizationData[0].AuthorizationToken), nil
+}
+
+// NewECRClient creates an AWS SDK client to access ECR API.
+func NewECRClient(region string) (*ecr.ECR, error) {
+	sess, err := NewAuthenticatedSession(region)
+	if err != nil {
+		return nil, err
+	}
+	return ecr.New(sess), nil
+}
diff --git a/eksawshelper/errors.go b/eksawshelper/errors.go
index 1fca0aa..76885a5 100644
--- a/eksawshelper/errors.go
+++ b/eksawshelper/errors.go
@@ -10,3 +10,14 @@ type CredentialsError struct {
 func (err CredentialsError) Error() string {
 	return fmt.Sprintf("Error finding AWS credentials. Did you set the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables or configure an AWS profile? Underlying error: %v", err.UnderlyingErr)
 }
+
+// ECRManifestFetchError is an error that occurs when retrieving information about a given tag in an ECR repository.
+type ECRManifestFetchError struct {
+	manifestURL string
+	statusCode  int
+	body        string
+}
+
+func (err ECRManifestFetchError) Error() string {
+	return fmt.Sprintf("Error querying ECR repo URL %s (status code %d) (response body %s)", err.manifestURL, err.statusCode, err.body)
+}
diff --git a/go.mod b/go.mod
index b958120..f921ed3 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/blang/semver/v4 v4.0.0
 	github.com/gruntwork-io/go-commons v0.8.2
 	github.com/gruntwork-io/terratest v0.32.9
+	github.com/hashicorp/go-cleanhttp v0.5.2
 	github.com/hashicorp/go-multierror v1.1.0
 	github.com/mitchellh/go-homedir v1.1.0
 	github.com/sirupsen/logrus v1.6.0
diff --git a/go.sum b/go.sum
index d7535b1..90bf479 100644
--- a/go.sum
+++ b/go.sum
@@ -255,6 +255,8 @@ github.com/gruntwork-io/terratest v0.32.9 h1:ciWWJxISk06LAYImn6h1Vvir8hUz13VtwT2
 github.com/gruntwork-io/terratest v0.32.9/go.mod h1:FckR+7ks472IJfSKUPfPvnJfSxV1LKGWGMJ9m/LHegE=
 github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
 github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
 github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=


Test patch:
diff --git a/eks/sync_test.go b/eks/sync_test.go
index 95c44ec..ec099e0 100644
--- a/eks/sync_test.go
+++ b/eks/sync_test.go
@@ -8,6 +8,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/gruntwork-io/kubergrunt/eksawshelper"
 	"github.com/gruntwork-io/terratest/modules/k8s"
 	"github.com/gruntwork-io/terratest/modules/random"
 	"github.com/stretchr/testify/assert"
@@ -179,6 +180,34 @@ func TestRemoveUpstreamKeywordFromCorednsConfigMap(t *testing.T) {
 	assert.Equal(t, expectedSampleConfigData, testConfigMapUpdated.Data[corednsConfigMapConfigKey])
 }
 
+func TestFindLatestEKSBuild(t *testing.T) {
+	t.Parallel()
+
+	testCase := []struct {
+		k8sVersion      string
+		region          string
+		expectedVersion string
+	}{
+		{"1.20", "us-east-1", "1.20.4-eksbuild.2"},
+		{"1.16", "us-east-1", "1.16.13-eksbuild.1"},
+	}
+
+	for _, tc := range testCase {
+		tc := tc
+		t.Run(tc.k8sVersion, func(t *testing.T) {
+			t.Parallel()
+
+			repoDomain := getRepoDomain(tc.region)
+			dockerToken, err := eksawshelper.GetDockerLoginToken(tc.region)
+			require.NoError(t, err)
+
+			kubeProxyVersion, err := findLatestEKSBuild(dockerToken, repoDomain, kubeProxyRepoPath, kubeProxyVersionLookupTable[tc.k8sVersion])
+			require.NoError(t, err)
+			assert.Equal(t, tc.expectedVersion, kubeProxyVersion)
+		})
+	}
+}
+
 const sampleConfigData = `.:53 {
     errors
     health
diff --git a/eksawshelper/ecr_test.go b/eksawshelper/ecr_test.go
new file mode 100644
index 0000000..fa1f4c9
--- /dev/null
+++ b/eksawshelper/ecr_test.go
@@ -0,0 +1,28 @@
+package eksawshelper
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestTagExistsInRepo(t *testing.T) {
+	t.Parallel()
+
+	region := "us-west-2"
+	existingTag := "v1.18.8-eksbuild.1"
+	nonExistingTag := "v1.18.8-eksbuild.10"
+
+	token, err := GetDockerLoginToken(region)
+	require.NoError(t, err)
+	repoDomain := fmt.Sprintf("602401143452.dkr.ecr.%s.amazonaws.com", region)
+	tagExists1, err := TagExistsInRepo(token, repoDomain, "eks/kube-proxy", existingTag)
+	require.NoError(t, err)
+	assert.True(t, tagExists1)
+
+	tagExists2, err := TagExistsInRepo(token, repoDomain, "eks/kube-proxy", nonExistingTag)
+	require.NoError(t, err)
+	assert.False(t, tagExists2)
+}