diff --git a/.circleci/config.yml b/.circleci/config.yml index 0a0ac0cdc..54155cc82 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ env: &env TERRAFORM_VERSION: 1.5.7 TOFU_VERSION: 1.8.0 PACKER_VERSION: 1.10.0 - TERRAGRUNT_VERSION: v0.69.8 + TERRAGRUNT_VERSION: v0.80.4 OPA_VERSION: v0.33.1 GO_VERSION: 1.21.1 GO111MODULE: auto diff --git a/CODEOWNERS b/CODEOWNERS index 25e677384..9c734c0a0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @denis256 @levkohimins @yhakbar @james03160927 +* @denis256 @yhakbar @james03160927 diff --git a/go.mod b/go.mod index b65f6e7e5..c7268cab5 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,8 @@ module github.com/gruntwork-io/terratest -go 1.21 -toolchain go1.24.1 +go 1.23.0 + +toolchain go1.24.2 require ( cloud.google.com/go v0.116.0 // indirect diff --git a/modules/aws/vpc.go b/modules/aws/vpc.go index 8091a3353..5e9392474 100644 --- a/modules/aws/vpc.go +++ b/modules/aws/vpc.go @@ -31,6 +31,7 @@ type Subnet struct { AvailabilityZone string // The Availability Zone the subnet is in DefaultForAz bool // If the subnet is default for the Availability Zone Tags map[string]string // The tags associated with the subnet + CidrBlock string // The CIDR block associated with the subnet } const vpcIDFilterName = "vpc-id" @@ -201,7 +202,7 @@ func GetSubnetsForVpcE(t testing.TestingT, region string, filters []types.Filter for _, ec2Subnet := range subnetOutput.Subnets { subnetTags := GetTagsForSubnet(t, *ec2Subnet.SubnetId, region) - subnet := Subnet{Id: aws.ToString(ec2Subnet.SubnetId), AvailabilityZone: aws.ToString(ec2Subnet.AvailabilityZone), DefaultForAz: aws.ToBool(ec2Subnet.DefaultForAz), Tags: subnetTags} + subnet := Subnet{Id: aws.ToString(ec2Subnet.SubnetId), AvailabilityZone: aws.ToString(ec2Subnet.AvailabilityZone), DefaultForAz: aws.ToBool(ec2Subnet.DefaultForAz), Tags: subnetTags, CidrBlock: aws.ToString(ec2Subnet.CidrBlock)} subnets = append(subnets, subnet) } diff --git a/modules/azure/resourcegroup.go b/modules/azure/resourcegroup.go index 7d624d4c8..6248bfc77 100644 --- a/modules/azure/resourcegroup.go +++ b/modules/azure/resourcegroup.go @@ -5,7 +5,10 @@ import ( "fmt" "testing" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2020-10-01/resources" + "github.com/Azure/go-autorest/autorest" + "github.com/Azure/go-autorest/autorest/azure" "github.com/stretchr/testify/require" ) @@ -21,7 +24,7 @@ func ResourceGroupExists(t *testing.T, resourceGroupName string, subscriptionID func ResourceGroupExistsE(resourceGroupName, subscriptionID string) (bool, error) { exists, err := GetResourceGroupE(resourceGroupName, subscriptionID) if err != nil { - if ResourceNotFoundErrorExists(err) { + if resourceGroupNotFoundError(err) { return false, nil } return false, err @@ -99,3 +102,17 @@ func ListResourceGroupsByTagE(tag string, subscriptionID string) ([]resources.Gr } return rg.Values(), nil } + +func resourceGroupNotFoundError(err error) bool { + if err != nil { + if autorestError, ok := err.(autorest.DetailedError); ok { + if requestError, ok := autorestError.Original.(*azure.RequestError); ok { + return (requestError.ServiceError.Code == "ResourceGroupNotFound") + } + } + if azcoreErr, ok := err.(*azcore.ResponseError); ok { + return azcoreErr.ErrorCode == "ResourceGroupNotFound" + } + } + return false +} diff --git a/modules/azure/resourcegroup_test.go b/modules/azure/resourcegroup_test.go index e330292da..f5162314c 100644 --- a/modules/azure/resourcegroup_test.go +++ b/modules/azure/resourcegroup_test.go @@ -8,6 +8,7 @@ package azure import ( "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -20,8 +21,9 @@ func TestResourceGroupExists(t *testing.T) { t.Parallel() resourceGroupName := "fakeResourceGroupName" - _, err := ResourceGroupExistsE(resourceGroupName, "") - require.Error(t, err) + exists, err := ResourceGroupExistsE(resourceGroupName, "") + assert.NoError(t, err) + require.False(t, exists) } func TestGetAResourceGroup(t *testing.T) { diff --git a/modules/azure/resourcegroupv2.go b/modules/azure/resourcegroupv2.go index 62add3265..eddca5518 100644 --- a/modules/azure/resourcegroupv2.go +++ b/modules/azure/resourcegroupv2.go @@ -21,7 +21,7 @@ func ResourceGroupExistsV2(t *testing.T, resourceGroupName string, subscriptionI func ResourceGroupExistsV2E(resourceGroupName, subscriptionID string) (bool, error) { exists, err := GetResourceGroupV2E(resourceGroupName, subscriptionID) if err != nil { - if ResourceNotFoundErrorExists(err) { + if resourceGroupNotFoundError(err) { return false, nil } return false, err diff --git a/modules/azure/resourcegroupv2_test.go b/modules/azure/resourcegroupv2_test.go index 480fa8ec5..ac8f38df7 100644 --- a/modules/azure/resourcegroupv2_test.go +++ b/modules/azure/resourcegroupv2_test.go @@ -22,10 +22,9 @@ func TestResourceGroupExistsV2(t *testing.T) { t.Parallel() resourceGroupName := "fakeResourceGroupName" - _, err := ResourceGroupExistsV2E(resourceGroupName, "") - errAzure := &azcore.ResponseError{} - require.ErrorAs(t, err, &errAzure) - assert.Equal(t, errAzure.StatusCode, 404) + exists, err := ResourceGroupExistsV2E(resourceGroupName, "") + assert.NoError(t, err) + assert.False(t, exists) } func TestGetAResourceGroupV2(t *testing.T) { diff --git a/modules/gcp/cloudbuild.go b/modules/gcp/cloudbuild.go index 67a9df183..17b8a6fee 100644 --- a/modules/gcp/cloudbuild.go +++ b/modules/gcp/cloudbuild.go @@ -147,7 +147,7 @@ func NewCloudBuildService(t testing.TestingT) *cloudbuild.Client { func NewCloudBuildServiceE(t testing.TestingT) (*cloudbuild.Client, error) { ctx := context.Background() - service, err := cloudbuild.NewClient(ctx) + service, err := cloudbuild.NewClient(ctx, withOptions()...) if err != nil { return nil, err } diff --git a/modules/gcp/compute.go b/modules/gcp/compute.go index 5b7f0dc4b..17e836b0d 100644 --- a/modules/gcp/compute.go +++ b/modules/gcp/compute.go @@ -14,6 +14,7 @@ import ( "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/testing" + "golang.org/x/oauth2" "golang.org/x/oauth2/google" ) @@ -585,6 +586,10 @@ func NewComputeService(t testing.TestingT) *compute.Service { func NewComputeServiceE(t testing.TestingT) (*compute.Service, error) { ctx := context.Background() + if ts, ok := getStaticTokenSource(); ok { + return compute.New(oauth2.NewClient(ctx, ts)) + } + // Retrieve the Google OAuth token using a retry loop as it can sometimes return an error. // e.g: oauth2: cannot fetch token: Post https://oauth2.googleapis.com/token: net/http: TLS handshake timeout // This is loosely based on https://github.com/kubernetes/kubernetes/blob/7e8de5422cb5ad76dd0c147cf4336220d282e34b/pkg/cloudprovider/providers/gce/gce.go#L831. diff --git a/modules/gcp/gcp.go b/modules/gcp/gcp.go index c0e23c321..7825f2d06 100644 --- a/modules/gcp/gcp.go +++ b/modules/gcp/gcp.go @@ -1,2 +1,15 @@ // Package gcp allows interaction with Google Cloud Platform resources. package gcp + +import ( + "google.golang.org/api/option" +) + +func withOptions() (opts []option.ClientOption) { + v, ok := getStaticTokenSource() + if ok { + opts = append(opts, option.WithTokenSource(v)) + } + + return +} diff --git a/modules/gcp/gcr.go b/modules/gcp/gcr.go index ceb02e0e6..e5b33473e 100644 --- a/modules/gcp/gcr.go +++ b/modules/gcp/gcr.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/google/go-containerregistry/pkg/authn" gcrname "github.com/google/go-containerregistry/pkg/name" gcrgoogle "github.com/google/go-containerregistry/pkg/v1/google" gcrremote "github.com/google/go-containerregistry/pkg/v1/remote" @@ -21,7 +22,7 @@ func DeleteGCRRepo(t testing.TestingT, repo string) { // DeleteGCRRepoE deletes a GCR repository including all tagged images func DeleteGCRRepoE(t testing.TestingT, repo string) error { // create a new auther for the API calls - auther, err := gcrgoogle.NewEnvAuthenticator(context.Background()) + auther, err := newGCRAuther() if err != nil { return fmt.Errorf("Failed to create auther. Got error: %v", err) } @@ -71,7 +72,7 @@ func DeleteGCRImageRefE(t testing.TestingT, ref string) error { } // create a new auther for the API calls - auther, err := gcrgoogle.NewEnvAuthenticator(context.Background()) + auther, err := newGCRAuther() if err != nil { return fmt.Errorf("Failed to create auther. Got error: %v", err) } @@ -84,3 +85,11 @@ func DeleteGCRImageRefE(t testing.TestingT, ref string) error { return nil } + +func newGCRAuther() (authn.Authenticator, error) { + if ts, ok := getStaticTokenSource(); ok { + return gcrgoogle.NewTokenSourceAuthenticator(ts), nil + } + + return gcrgoogle.NewEnvAuthenticator(context.Background()) +} diff --git a/modules/gcp/oslogin.go b/modules/gcp/oslogin.go index 9dc3a33b6..f2dd76be4 100644 --- a/modules/gcp/oslogin.go +++ b/modules/gcp/oslogin.go @@ -7,6 +7,7 @@ import ( "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/testing" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/api/compute/v1" "google.golang.org/api/oslogin/v1" @@ -145,6 +146,10 @@ func GetLoginProfileE(t testing.TestingT, user string) (*oslogin.LoginProfile, e func NewOSLoginServiceE(t testing.TestingT) (*oslogin.Service, error) { ctx := context.Background() + if ts, ok := getStaticTokenSource(); ok { + return oslogin.New(oauth2.NewClient(ctx, ts)) + } + client, err := google.DefaultClient(ctx, compute.CloudPlatformScope) if err != nil { return nil, fmt.Errorf("Failed to get default client: %v", err) diff --git a/modules/gcp/static_token.go b/modules/gcp/static_token.go new file mode 100644 index 000000000..7e120f66d --- /dev/null +++ b/modules/gcp/static_token.go @@ -0,0 +1,15 @@ +package gcp + +import ( + "os" + + "golang.org/x/oauth2" +) + +func getStaticTokenSource() (oauth2.TokenSource, bool) { + v, ok := os.LookupEnv("GOOGLE_OAUTH_ACCESS_TOKEN") + if ok { + return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: v}), true + } + return nil, false +} diff --git a/modules/gcp/static_token_test.go b/modules/gcp/static_token_test.go new file mode 100644 index 000000000..41862155e --- /dev/null +++ b/modules/gcp/static_token_test.go @@ -0,0 +1,45 @@ +package gcp + +import ( + "context" + "strings" + "testing" + + "github.com/gruntwork-io/terratest/modules/random" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2/google" +) + +func TestStaticTokenClient(t *testing.T) { + ctx := context.Background() + creds, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/cloud-platform") + require.NoError(t, err) + token, err := creds.TokenSource.Token() + require.NoError(t, err) + projectID := GetGoogleProjectIDFromEnvVar(t) + + // we poison the default client instantiation with invalid file so that if it is used, it fails + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "non-existent-credentials.json") + _, err = NewCloudBuildServiceE(t) + require.Error(t, err) + _, err = NewComputeServiceE(t) + require.Error(t, err) + _, err = newGCRAuther() + require.Error(t, err) + _, err = NewOSLoginServiceE(t) + require.Error(t, err) + _, err = newStorageClient() + require.Error(t, err) + + // now we instantiate client with oauth2 token + // and run several function to make sure the new client is correctly configured with access token + t.Setenv("GOOGLE_OAUTH_ACCESS_TOKEN", token.AccessToken) + GetAllGcpRegions(t, projectID) + GetBuilds(t, projectID) + GetLoginProfile(t, GetGoogleIdentityEmailEnvVar(t)) + _, err = newGCRAuther() + require.NoError(t, err) + bucket := "gruntwork-terratest-" + strings.ToLower(random.UniqueId()) + CreateStorageBucket(t, projectID, bucket, nil) + defer DeleteStorageBucket(t, bucket) +} diff --git a/modules/gcp/storage.go b/modules/gcp/storage.go index 08a2f771d..6b39d5f65 100644 --- a/modules/gcp/storage.go +++ b/modules/gcp/storage.go @@ -26,7 +26,7 @@ func CreateStorageBucketE(t testing.TestingT, projectID string, name string, att ctx := context.Background() // Creates a client. - client, err := storage.NewClient(ctx) + client, err := newStorageClient() if err != nil { return err } @@ -52,7 +52,7 @@ func DeleteStorageBucketE(t testing.TestingT, name string) error { ctx := context.Background() - client, err := storage.NewClient(ctx) + client, err := newStorageClient() if err != nil { return err } @@ -75,7 +75,7 @@ func ReadBucketObjectE(t testing.TestingT, bucketName string, filePath string) ( ctx := context.Background() - client, err := storage.NewClient(ctx) + client, err := newStorageClient() if err != nil { return nil, err } @@ -109,7 +109,7 @@ func WriteBucketObjectE(t testing.TestingT, bucketName string, filePath string, ctx := context.Background() - client, err := storage.NewClient(ctx) + client, err := newStorageClient() if err != nil { return "", err } @@ -147,7 +147,7 @@ func EmptyStorageBucketE(t testing.TestingT, name string) error { ctx := context.Background() - client, err := storage.NewClient(ctx) + client, err := newStorageClient() if err != nil { return err } @@ -192,7 +192,7 @@ func AssertStorageBucketExistsE(t testing.TestingT, name string) error { ctx := context.Background() // Creates a client. - client, err := storage.NewClient(ctx) + client, err := newStorageClient() if err != nil { return err } @@ -217,3 +217,12 @@ func AssertStorageBucketExistsE(t testing.TestingT, name string) error { return nil } + +func newStorageClient() (*storage.Client, error) { + ctx := context.Background() + client, err := storage.NewClient(ctx, withOptions()...) + if err != nil { + return nil, err + } + return client, nil +} diff --git a/modules/k8s/cronjob.go b/modules/k8s/cronjob.go new file mode 100644 index 000000000..6afae4ed6 --- /dev/null +++ b/modules/k8s/cronjob.go @@ -0,0 +1,90 @@ +package k8s + +import ( + "context" + "fmt" + "time" + + "github.com/gruntwork-io/terratest/modules/retry" + "github.com/gruntwork-io/terratest/modules/testing" + "github.com/stretchr/testify/require" + + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ListCronJobs list cron jobs in namespace that match provided filters. This will fail the test if there is an error. +func ListCronJobs(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []batchv1.CronJob { + cronJobs, err := ListCronJobsE(t, options, filters) + require.NoError(t, err) + return cronJobs +} + +// ListCronJobsE list cron jobs in namespace that match provided filters. This will return list or error. +func ListCronJobsE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]batchv1.CronJob, error) { + clientset, err := GetKubernetesClientFromOptionsE(t, options) + if err != nil { + return nil, err + } + resp, err := clientset.BatchV1().CronJobs(options.Namespace).List(context.Background(), filters) + if err != nil { + return nil, err + } + return resp.Items, nil +} + +// GetCronJob return cron job resource from namespace by name. This will fail the test if there is an error. +func GetCronJob(t testing.TestingT, options *KubectlOptions, cronJobName string) *batchv1.CronJob { + job, err := GetCronJobE(t, options, cronJobName) + require.NoError(t, err) + return job +} + +// GetCronJobE return cron job resource from namespace by name. This will return cron job or error. +func GetCronJobE(t testing.TestingT, options *KubectlOptions, cronJobName string) (*batchv1.CronJob, error) { + clientset, err := GetKubernetesClientFromOptionsE(t, options) + if err != nil { + return nil, err + } + return clientset.BatchV1().CronJobs(options.Namespace).Get(context.Background(), cronJobName, metav1.GetOptions{}) +} + +// WaitUntilCronJobSucceed waits until cron job will successfully complete a job. This will fail the test if there is an +// error or if the check times out. +func WaitUntilCronJobSucceed(t testing.TestingT, options *KubectlOptions, cronJobName string, retries int, sleepBetweenRetries time.Duration) { + require.NoError(t, WaitUntilCronJobSucceedE(t, options, cronJobName, retries, sleepBetweenRetries)) +} + +// WaitUntilCronJobSucceedE waits until cron job will successfully complete a job, retrying the check for the specified +// amount of times, sleeping for the provided duration between each try. +func WaitUntilCronJobSucceedE(t testing.TestingT, options *KubectlOptions, cronJobName string, retries int, sleepBetweenRetries time.Duration) error { + statusMsg := fmt.Sprintf("Wait for CronJob %s to successfully schedule container", cronJobName) + message, err := retry.DoWithRetryE( + t, + statusMsg, + retries, + sleepBetweenRetries, + func() (string, error) { + job, err := GetCronJobE(t, options, cronJobName) + if err != nil { + return "", err + } + if !IsCronJobSucceeded(job) { + return "", NewCronJobNotSucceeded(job) + } + return "CronJob scheduled container", nil + }, + ) + if err != nil { + options.Logger.Logf(t, "Timed out waiting for CronJob to schedule job: %s", err) + return err + } + options.Logger.Logf(t, message) + return nil +} + +// IsCronJobSucceeded returns true if cron job successfully scheduled and completed job. +// https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/cron-job-v1/#CronJobStatus +func IsCronJobSucceeded(cronJob *batchv1.CronJob) bool { + return cronJob.Status.LastScheduleTime != nil +} diff --git a/modules/k8s/cronjob_test.go b/modules/k8s/cronjob_test.go new file mode 100644 index 000000000..20e258a25 --- /dev/null +++ b/modules/k8s/cronjob_test.go @@ -0,0 +1,128 @@ +//go:build kubeall || kubernetes +// +build kubeall kubernetes + +package k8s + +import ( + "fmt" + "strings" + "testing" + "time" + + batchv1 "k8s.io/api/batch/v1" + + "github.com/gruntwork-io/terratest/modules/random" + "github.com/stretchr/testify/require" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestListCronJobsReturnsCronJobsInNamespace(t *testing.T) { + t.Parallel() + + uniqueID := strings.ToLower(random.UniqueId()) + options := NewKubectlOptions("", "", uniqueID) + configData := fmt.Sprintf(ExampleCronjobYamlTemplate, uniqueID, uniqueID) + defer KubectlDeleteFromString(t, options, configData) + KubectlApplyFromString(t, options, configData) + + jobs := ListCronJobs(t, options, metav1.ListOptions{}) + require.Equal(t, len(jobs), 1) + job := jobs[0] + require.Equal(t, job.Name, "cron-job") + require.Equal(t, job.Namespace, uniqueID) +} + +func TestGetCronJobEReturnErrorForNotExistingCronJob(t *testing.T) { + t.Parallel() + + options := NewKubectlOptions("", "", "default") + _, err := GetJobE(t, options, random.UniqueId()) + require.Error(t, err) +} + +func TestGetCronJobEReturnsCorrectJobInNamespace(t *testing.T) { + t.Parallel() + uniqueID := strings.ToLower(random.UniqueId()) + options := NewKubectlOptions("", "", uniqueID) + configData := fmt.Sprintf(ExampleCronjobYamlTemplate, uniqueID, uniqueID) + defer KubectlDeleteFromString(t, options, configData) + KubectlApplyFromString(t, options, configData) + + job := GetCronJob(t, options, "cron-job") + require.Equal(t, job.Name, "cron-job") + require.Equal(t, job.Namespace, uniqueID) +} + +func TestWaitUntilCronJobScheduleSuccessfullyContainer(t *testing.T) { + t.Parallel() + + uniqueID := strings.ToLower(random.UniqueId()) + options := NewKubectlOptions("", "", uniqueID) + configData := fmt.Sprintf(ExampleCronjobYamlTemplate, uniqueID, uniqueID) + defer KubectlDeleteFromString(t, options, configData) + KubectlApplyFromString(t, options, configData) + + WaitUntilCronJobSucceed(t, options, "cron-job", 60, 5*time.Second) +} + +func TestIsCronJobSucceeded(t *testing.T) { + + cases := []struct { + title string + cronJob *batchv1.CronJob + expectedResult bool + }{ + { + title: "CronJobScheduledContainer", + cronJob: &batchv1.CronJob{ + Status: batchv1.CronJobStatus{ + LastScheduleTime: &metav1.Time{}, + }, + }, + expectedResult: true, + }, + { + title: "CronJobNotScheduledContainer", + cronJob: &batchv1.CronJob{ + Status: batchv1.CronJobStatus{ + LastScheduleTime: nil, + }, + }, + expectedResult: false, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.title, func(t *testing.T) { + t.Parallel() + actualResult := IsCronJobSucceeded(tc.cronJob) + require.Equal(t, tc.expectedResult, actualResult) + }) + } +} + +const ExampleCronjobYamlTemplate = `--- +apiVersion: v1 +kind: Namespace +metadata: + name: %s +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: cron-job + namespace: %s +spec: + schedule: "* * * * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: ubuntu + image: ubuntu:20.04 + command: ["sh", "-c", "ls"] + restartPolicy: OnFailure +` diff --git a/modules/k8s/errors.go b/modules/k8s/errors.go index 1b098b2e3..965dce041 100644 --- a/modules/k8s/errors.go +++ b/modules/k8s/errors.go @@ -281,3 +281,18 @@ type JSONPathMalformedJSONPathResultErr struct { func (err JSONPathMalformedJSONPathResultErr) Error() string { return fmt.Sprintf("Error unmarshaling json path output: %s", err.underlyingErr) } + +// CronJobNotSucceeded is returned when a Kubernetes cron job didn't successfully schedule a job. +type CronJobNotSucceeded struct { + cronJob *batchv1.CronJob +} + +// Error format message for cron job error. +func (err CronJobNotSucceeded) Error() string { + return fmt.Sprintf("CronJob %s failed to be scheduled.", err.cronJob.Name) +} + +// NewCronJobNotSucceeded create error for case when CronJob didn't schedule a job. +func NewCronJobNotSucceeded(cronJob *batchv1.CronJob) CronJobNotSucceeded { + return CronJobNotSucceeded{cronJob} +} diff --git a/modules/k8s/tunnel.go b/modules/k8s/tunnel.go index e9a6a4ea3..4415fa3bb 100644 --- a/modules/k8s/tunnel.go +++ b/modules/k8s/tunnel.go @@ -16,7 +16,9 @@ import ( "sync" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/tools/portforward" "k8s.io/client-go/transport/spdy" @@ -220,6 +222,19 @@ func (tunnel *Tunnel) ForwardPortE(t testing.TestingT) error { var portFound = false for _, portSpec := range service.Spec.Ports { if portSpec.Port == int32(targetPort) { + if portSpec.TargetPort.Type == intstr.String { + pod, err := GetPodE(t, tunnel.kubectlOptions, podName) + if err != nil { + return err + } + targetPort, err = getPodPortByName(pod, portSpec.TargetPort.String()) + if err != nil { + tunnel.logger.Logf(t, "Error selecting port by name: %s", err) + return err + } + portFound = true + break + } targetPort = portSpec.TargetPort.IntValue() portFound = true break @@ -323,3 +338,17 @@ func GetAvailablePortE(t testing.TestingT) (int, error) { } return port, err } + +func getPodPortByName(pod *corev1.Pod, portName string) (int, error) { + if pod == nil { + return 0, errors.New("cannot get port for pod which is nil") + } + for _, container := range pod.Spec.Containers { + for _, port := range container.Ports { + if port.Name == portName { + return int(port.ContainerPort), nil + } + } + } + return 0, fmt.Errorf("could not find port %s in pod %s", portName, pod.Name) +} diff --git a/modules/k8s/tunnel_test.go b/modules/k8s/tunnel_test.go index 79a3ce6a4..fee918a76 100644 --- a/modules/k8s/tunnel_test.go +++ b/modules/k8s/tunnel_test.go @@ -83,29 +83,55 @@ func TestTunnelOpensAPortForwardTunnelToService(t *testing.T) { uniqueID := strings.ToLower(random.UniqueId()) options := NewKubectlOptions("", "", uniqueID) - configData := fmt.Sprintf(ExamplePodWithServiceYAMLTemplate, uniqueID, uniqueID, uniqueID) - defer KubectlDeleteFromString(t, options, configData) + configData := fmt.Sprintf(ExamplePodWithServiceYAMLTemplate, uniqueID, uniqueID, uniqueID, uniqueID) + t.Cleanup(func() { + KubectlDeleteFromString(t, options, configData) + }) KubectlApplyFromString(t, options, configData) + // t.FailNow() WaitUntilPodAvailable(t, options, "nginx-pod", 60, 1*time.Second) - WaitUntilServiceAvailable(t, options, "nginx-service", 60, 1*time.Second) - - // Open a tunnel from any available port locally - tunnel := NewTunnel(options, ResourceTypeService, "nginx-service", 0, 8080) - defer tunnel.Close() - tunnel.ForwardPort(t) - // Setup a TLS configuration to submit with the helper, a blank struct is acceptable - tlsConfig := tls.Config{} + testCases := []struct { + name string + serviceName string + }{ + { + "Pod target port by number", + "nginx-service-number", + }, + { + "Pod target port by name", + "nginx-service-name", + }, + } - // Try to access the nginx service on the local port, retrying until we get a good response for up to 5 minutes - http_helper.HttpGetWithRetryWithCustomValidation( - t, - fmt.Sprintf("http://%s", tunnel.Endpoint()), - &tlsConfig, - 60, - 5*time.Second, - verifyNginxWelcomePage, - ) + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + WaitUntilServiceAvailable(t, options, testCase.serviceName, 60, 1*time.Second) + + // Open a tunnel from any available port locally + tunnel := NewTunnel(options, ResourceTypeService, testCase.serviceName, 0, 8080) + t.Cleanup(func() { + tunnel.Close() + }) + tunnel.ForwardPort(t) + + // Setup a TLS configuration to submit with the helper, a blank struct is acceptable + tlsConfig := tls.Config{} + + // Try to access the nginx service on the local port, retrying until we get a good response for up to 5 minutes + http_helper.HttpGetWithRetryWithCustomValidation( + t, + fmt.Sprintf("http://%s", tunnel.Endpoint()), + &tlsConfig, + 60, + 5*time.Second, + verifyNginxWelcomePage, + ) + }) + } } func verifyNginxWelcomePage(statusCode int, body string) bool { @@ -134,6 +160,7 @@ spec: image: nginx:1.15.7 ports: - containerPort: 80 + name: http readinessProbe: httpGet: path: / @@ -142,7 +169,7 @@ spec: apiVersion: v1 kind: Service metadata: - name: nginx-service + name: nginx-service-number namespace: %s spec: selector: @@ -151,4 +178,17 @@ spec: - protocol: TCP targetPort: 80 port: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: nginx-service-name + namespace: %s +spec: + selector: + app: nginx + ports: + - protocol: TCP + targetPort: http + port: 8080 ` diff --git a/modules/terraform/cmd.go b/modules/terraform/cmd.go index fef62fe10..81206ab9c 100644 --- a/modules/terraform/cmd.go +++ b/modules/terraform/cmd.go @@ -198,7 +198,7 @@ func defaultTerraformExecutable() string { func hasWarning(opts *Options, out string) error { for k, v := range opts.WarningsAsErrors { - str := fmt.Sprintf("\nWarning: %s[^\n]*\n", k) + str := fmt.Sprintf("\n.*(?i:Warning): %s[^\n]*\n", k) re, err := regexp.Compile(str) if err != nil { return fmt.Errorf("cannot compile regex for warning detection: %w", err) diff --git a/modules/terraform/cmd_test.go b/modules/terraform/cmd_test.go index 83d974716..a11e20e6a 100644 --- a/modules/terraform/cmd_test.go +++ b/modules/terraform/cmd_test.go @@ -27,6 +27,24 @@ func TestTerraformCommand(t *testing.T) { assert.Greater(t, code, 0) }) + t.Run("WithWarning", func(t *testing.T) { + testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-with-warning", strings.ReplaceAll(t.Name(), "/", "-")) + require.NoError(t, err) + options := &Options{ + TerraformDir: testFolder, + WarningsAsErrors: map[string]string{ + ".*lorem ipsum.*": "this warning message should shown.", + }, + } + Init(t, options) + + stdout, stderr, code, err := RunTerraformCommandAndGetStdOutErrCodeE(t, options, "apply", "-input=false", "-auto-approve") + assert.Error(t, err) + assert.Contains(t, stdout, "Creating...", "should capture stdout") + assert.Contains(t, stderr, "", "should capture stderr") + assert.Greater(t, code, 0) + }) + t.Run("NoError", func(t *testing.T) { testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terraform-no-error", strings.ReplaceAll(t.Name(), "/", "-")) require.NoError(t, err) diff --git a/modules/terragrunt/cmd.go b/modules/terragrunt/cmd.go new file mode 100644 index 000000000..30e946686 --- /dev/null +++ b/modules/terragrunt/cmd.go @@ -0,0 +1,71 @@ +package terragrunt + +import ( + "fmt" + "regexp" + "strings" + + "github.com/gruntwork-io/terratest/modules/retry" + "github.com/gruntwork-io/terratest/modules/shell" + "github.com/gruntwork-io/terratest/modules/terraform" + "github.com/gruntwork-io/terratest/modules/testing" +) + +func runTerragruntStackCommandE(t testing.TestingT, opts *Options, additionalArgs ...string) (string, error) { + args := []string{"stack", "run"} + { + // check if we are using older version of terragrunt + cmd := shell.Command{Command: opts.TerraformBinary, Args: []string{"-experiment", "stack"}} + if err := shell.RunCommandE(t, cmd); err == nil { + args = prepend(args, "-experiment", "stack") + } + } + + options, args := terraform.GetCommonOptions(&opts.Options, args...) + args = append(args, prepend(additionalArgs, "--")...) + + cmd := generateCommand(options, args...) + description := fmt.Sprintf("%s %v", options.TerraformBinary, args) + + return retry.DoWithRetryableErrorsE(t, description, options.RetryableTerraformErrors, options.MaxRetries, options.TimeBetweenRetries, func() (string, error) { + s, err := shell.RunCommandAndGetOutputE(t, cmd) + if err != nil { + return s, err + } + if err := hasWarning(opts, s); err != nil { + return s, err + } + return s, err + }) +} + +func prepend(args []string, arg ...string) []string { + return append(arg, args...) +} + +func hasWarning(opts *Options, out string) error { + for k, v := range opts.WarningsAsErrors { + str := fmt.Sprintf("\nWarning: %s[^\n]*\n", k) + re, err := regexp.Compile(str) + if err != nil { + return fmt.Errorf("cannot compile regex for warning detection: %w", err) + } + m := re.FindAllString(out, -1) + if len(m) == 0 { + continue + } + return fmt.Errorf("warning(s) were found: %s:\n%s", v, strings.Join(m, "")) + } + return nil +} + +func generateCommand(options *terraform.Options, args ...string) shell.Command { + cmd := shell.Command{ + Command: options.TerraformBinary, + Args: args, + WorkingDir: options.TerraformDir, + Env: options.EnvVars, + Logger: options.Logger, + } + return cmd +} diff --git a/modules/terragrunt/init.go b/modules/terragrunt/init.go new file mode 100644 index 000000000..c6337525b --- /dev/null +++ b/modules/terragrunt/init.go @@ -0,0 +1,51 @@ +package terragrunt + +import ( + "fmt" + + "github.com/gruntwork-io/terratest/modules/terraform" + "github.com/gruntwork-io/terratest/modules/testing" +) + +type Options struct { + terraform.Options +} + +// TgStackInit calls terragrunt init and return stdout/stderr +func TgStackInit(t testing.TestingT, options *Options) string { + out, err := TgStackInitE(t, options) + if err != nil { + t.Fatal(err) + } + return out +} + +// TgStackInitE calls terragrunt init and return stdout/stderr +func TgStackInitE(t testing.TestingT, options *Options) (string, error) { + if options.TerraformBinary != "terragrunt" { + return "", terraform.TgInvalidBinary(options.TerraformBinary) + } + return runTerragruntStackCommandE(t, options, initArgs(options)...) +} + +func initArgs(options *Options) []string { + args := []string{"init", fmt.Sprintf("-upgrade=%t", options.Upgrade)} + + // Append reconfigure option if specified + if options.Reconfigure { + args = append(args, "-reconfigure") + } + // Append combination of migrate-state and force-copy to suppress answer prompt + if options.MigrateState { + args = append(args, "-migrate-state", "-force-copy") + } + // Append no-color option if needed + if options.NoColor { + args = append(args, "-no-color") + } + + args = append(args, terraform.FormatTerraformBackendConfigAsArgs(options.BackendConfig)...) + args = append(args, terraform.FormatTerraformPluginDirAsArgs(options.PluginDir)...) + args = append(args, options.ExtraArgs.Init...) + return args +} diff --git a/modules/terragrunt/init_test.go b/modules/terragrunt/init_test.go new file mode 100644 index 000000000..cca2f6914 --- /dev/null +++ b/modules/terragrunt/init_test.go @@ -0,0 +1,27 @@ +package terragrunt + +import ( + "path" + "testing" + + "github.com/gruntwork-io/terratest/modules/files" + "github.com/gruntwork-io/terratest/modules/terraform" + "github.com/stretchr/testify/require" +) + +func TestTerragruntStackInit(t *testing.T) { + t.Parallel() + + testFolder, err := files.CopyTerraformFolderToTemp("../../test/fixtures/terragrunt/terragrunt-stack-init", t.Name()) + require.NoError(t, err) + + out, err := TgStackInitE(t, &Options{ + Options: terraform.Options{ + TerraformDir: path.Join(testFolder, "live"), + TerraformBinary: "terragrunt", + }, + }) + require.NoError(t, err) + require.Contains(t, out, ".terragrunt-stack") + require.Contains(t, out, "has been successfully initialized!") +} diff --git a/test/fixtures/terragrunt/terragrunt-stack-init/live/terragrunt.stack.hcl b/test/fixtures/terragrunt/terragrunt-stack-init/live/terragrunt.stack.hcl new file mode 100644 index 000000000..e5c16100b --- /dev/null +++ b/test/fixtures/terragrunt/terragrunt-stack-init/live/terragrunt.stack.hcl @@ -0,0 +1,20 @@ +unit "mother" { + source = "../units/chicken" + path = "mother" +} + +unit "father" { + source = "../units/chicken" + path = "father" +} + +unit "chick_1" { + source = "../units/chick" + path = "chicks/chick-1" +} + +unit "chick_2" { + source = "../units/chick" + path = "chicks/chick-2" +} + diff --git a/test/fixtures/terragrunt/terragrunt-stack-init/units/chick/main.tf b/test/fixtures/terragrunt/terragrunt-stack-init/units/chick/main.tf new file mode 100644 index 000000000..763f6aaa6 --- /dev/null +++ b/test/fixtures/terragrunt/terragrunt-stack-init/units/chick/main.tf @@ -0,0 +1,9 @@ + +resource "local_file" "file" { + content = "chick" + filename = "${path.module}/test.txt" +} + +output "output" { + value = local_file.file.filename +} diff --git a/test/fixtures/terragrunt/terragrunt-stack-init/units/chick/terragrunt.hcl b/test/fixtures/terragrunt/terragrunt-stack-init/units/chick/terragrunt.hcl new file mode 100644 index 000000000..3e98bd91b --- /dev/null +++ b/test/fixtures/terragrunt/terragrunt-stack-init/units/chick/terragrunt.hcl @@ -0,0 +1,4 @@ + +terraform { + source = "." +} \ No newline at end of file diff --git a/test/fixtures/terragrunt/terragrunt-stack-init/units/chicken/main.tf b/test/fixtures/terragrunt/terragrunt-stack-init/units/chicken/main.tf new file mode 100644 index 000000000..6b07342fc --- /dev/null +++ b/test/fixtures/terragrunt/terragrunt-stack-init/units/chicken/main.tf @@ -0,0 +1,9 @@ + +resource "local_file" "file" { + content = "chicken" + filename = "${path.module}/test.txt" +} + +output "output" { + value = local_file.file.filename +} diff --git a/test/fixtures/terragrunt/terragrunt-stack-init/units/chicken/terragrunt.hcl b/test/fixtures/terragrunt/terragrunt-stack-init/units/chicken/terragrunt.hcl new file mode 100644 index 000000000..3e98bd91b --- /dev/null +++ b/test/fixtures/terragrunt/terragrunt-stack-init/units/chicken/terragrunt.hcl @@ -0,0 +1,4 @@ + +terraform { + source = "." +} \ No newline at end of file diff --git a/test/fixtures/terragrunt/terragrunt-stack-init/units/father/main.tf b/test/fixtures/terragrunt/terragrunt-stack-init/units/father/main.tf new file mode 100644 index 000000000..122601dd1 --- /dev/null +++ b/test/fixtures/terragrunt/terragrunt-stack-init/units/father/main.tf @@ -0,0 +1,9 @@ + +resource "local_file" "file" { + content = "father" + filename = "${path.module}/test.txt" +} + +output "output" { + value = local_file.file.filename +} diff --git a/test/fixtures/terragrunt/terragrunt-stack-init/units/father/terragrunt.hcl b/test/fixtures/terragrunt/terragrunt-stack-init/units/father/terragrunt.hcl new file mode 100644 index 000000000..3e98bd91b --- /dev/null +++ b/test/fixtures/terragrunt/terragrunt-stack-init/units/father/terragrunt.hcl @@ -0,0 +1,4 @@ + +terraform { + source = "." +} \ No newline at end of file diff --git a/test/fixtures/terragrunt/terragrunt-stack-init/units/mother/main.tf b/test/fixtures/terragrunt/terragrunt-stack-init/units/mother/main.tf new file mode 100644 index 000000000..7e99b70ef --- /dev/null +++ b/test/fixtures/terragrunt/terragrunt-stack-init/units/mother/main.tf @@ -0,0 +1,9 @@ + +resource "local_file" "file" { + content = "mother" + filename = "${path.module}/test.txt" +} + +output "output" { + value = local_file.file.filename +} diff --git a/test/fixtures/terragrunt/terragrunt-stack-init/units/mother/terragrunt.hcl b/test/fixtures/terragrunt/terragrunt-stack-init/units/mother/terragrunt.hcl new file mode 100644 index 000000000..3e98bd91b --- /dev/null +++ b/test/fixtures/terragrunt/terragrunt-stack-init/units/mother/terragrunt.hcl @@ -0,0 +1,4 @@ + +terraform { + source = "." +} \ No newline at end of file diff --git a/test/helm_basic_example_integration_test.go b/test/helm_basic_example_integration_test.go index b87132805..530cc6243 100644 --- a/test/helm_basic_example_integration_test.go +++ b/test/helm_basic_example_integration_test.go @@ -11,6 +11,7 @@ package test import ( "crypto/tls" "fmt" + "net/http" "path/filepath" "strings" "testing" @@ -56,6 +57,9 @@ func TestHelmBasicExampleDeployment(t *testing.T) { "containerImageRepo": "nginx", "containerImageTag": "1.15.8", }, + ExtraArgs: map[string][]string{ + "install": []string{"--wait", "--timeout", "1m30s"}, + }, } // We generate a unique release name so that we can refer to after deployment. @@ -81,10 +85,13 @@ func TestHelmBasicExampleDeployment(t *testing.T) { // to ensure that we can access it. k8s.WaitUntilServiceAvailable(t, kubectlOptions, serviceName, 10, 1*time.Second) - // Now we verify that the service will successfully boot and start serving requests - service := k8s.GetService(t, kubectlOptions, serviceName) - endpoint := k8s.GetServiceEndpoint(t, kubectlOptions, service, 80) - + // Now we open a tunnel to port forward service port to localhost + tunnel := k8s.NewTunnel( + kubectlOptions, k8s.ResourceTypeService, serviceName, 0, 80) + defer tunnel.Close() + tunnel.ForwardPort(t) + // Get endpoint + endpoint := tunnel.Endpoint() // Setup a TLS configuration to submit with the helper, a blank struct is acceptable tlsConfig := tls.Config{} @@ -97,7 +104,7 @@ func TestHelmBasicExampleDeployment(t *testing.T) { 30, 10*time.Second, func(statusCode int, body string) bool { - return statusCode == 200 + return statusCode == http.StatusOK }, ) }