Skip to content

Commit

Permalink
Merge pull request #1274 from marcellmartini/feature/issue-1260
Browse files Browse the repository at this point in the history
Feature/issue 1260
  • Loading branch information
denis256 authored Apr 28, 2023
2 parents 0d72561 + 5d3c598 commit e78d052
Show file tree
Hide file tree
Showing 5 changed files with 340 additions and 0 deletions.
108 changes: 108 additions & 0 deletions modules/k8s/deployment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package k8s

import (
"context"
"fmt"
"time"

"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/gruntwork-io/terratest/modules/logger"
"github.com/gruntwork-io/terratest/modules/retry"
"github.com/gruntwork-io/terratest/modules/testing"
)

// ListDeployments will look for deployments in the given namespace that match the given filters and return them. This will
// fail the test if there is an error.
func ListDeployments(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) []appsv1.Deployment {
deployment, err := ListDeploymentsE(t, options, filters)
require.NoError(t, err)
return deployment
}

// ListDeploymentsE will look for deployments in the given namespace that match the given filters and return them.
func ListDeploymentsE(t testing.TestingT, options *KubectlOptions, filters metav1.ListOptions) ([]appsv1.Deployment, error) {
clientset, err := GetKubernetesClientFromOptionsE(t, options)
if err != nil {
return nil, err
}
deployments, err := clientset.AppsV1().Deployments(options.Namespace).List(context.Background(), filters)
if err != nil {
return nil, err
}
return deployments.Items, nil
}

// GetDeployment returns a Kubernetes deployment resource in the provided namespace with the given name. This will
// fail the test if there is an error.
func GetDeployment(t testing.TestingT, options *KubectlOptions, deploymentName string) *appsv1.Deployment {
deployment, err := GetDeploymentE(t, options, deploymentName)
require.NoError(t, err)
return deployment
}

// GetDeploymentE returns a Kubernetes deployment resource in the provided namespace with the given name.
func GetDeploymentE(t testing.TestingT, options *KubectlOptions, deploymentName string) (*appsv1.Deployment, error) {
clientset, err := GetKubernetesClientFromOptionsE(t, options)
if err != nil {
return nil, err
}
return clientset.AppsV1().Deployments(options.Namespace).Get(context.Background(), deploymentName, metav1.GetOptions{})
}

// WaitUntilDeploymentAvailableE waits until all pods within the deployment are ready and started,
// retrying the check for the specified amount of times, sleeping
// for the provided duration between each try.
// This will fail the test if there is an error.
func WaitUntilDeploymentAvailable(t testing.TestingT, options *KubectlOptions, deploymentName string, retries int, sleepBetweenRetries time.Duration) {
require.NoError(t, WaitUntilDeploymentAvailableE(t, options, deploymentName, retries, sleepBetweenRetries))
}

// WaitUntilDeploymentAvailableE waits until all pods within the deployment are ready and started,
// retrying the check for the specified amount of times, sleeping
// for the provided duration between each try.
func WaitUntilDeploymentAvailableE(
t testing.TestingT,
options *KubectlOptions,
deploymentName string,
retries int,
sleepBetweenRetries time.Duration,
) error {
statusMsg := fmt.Sprintf("Wait for deployment %s to be provisioned.", deploymentName)
message, err := retry.DoWithRetryE(
t,
statusMsg,
retries,
sleepBetweenRetries,
func() (string, error) {
deployment, err := GetDeploymentE(t, options, deploymentName)
if err != nil {
return "", err
}
if !IsDeploymentAvailable(deployment) {
return "", NewDeploymentNotAvailableError(deployment)
}
return "Deployment is now available", nil
},
)
if err != nil {
logger.Logf(t, "Timedout waiting for Deployment to be provisioned: %s", err)
return err
}
logger.Logf(t, message)
return nil
}

// IsDeploymentAvailable returns true if all pods within the deployment are ready and started
func IsDeploymentAvailable(deploy *appsv1.Deployment) bool {
availableType := 0

if deploy.Status.UnavailableReplicas > 0 {
return false
}

return deploy.Status.Conditions[availableType].Status == v1.ConditionTrue
}
157 changes: 157 additions & 0 deletions modules/k8s/deployment_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//go:build kubeall || kubernetes
// +build kubeall kubernetes

// NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube
// is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with
// `connection refused` errors from `minikube`. To avoid overloading the system, we run the kubernetes tests and helm
// tests separately from the others. This may not be necessary if you have a sufficiently powerful machine. We
// recommend at least 4 cores and 16GB of RAM if you want to run all the tests together.

package k8s

import (
"fmt"
"time"

"strings"
"testing"

"github.com/gruntwork-io/terratest/modules/random"
"github.com/stretchr/testify/require"
v1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func TestGetDeploymentEReturnsError(t *testing.T) {
t.Parallel()

options := NewKubectlOptions("", "", "")
_, err := GetDeploymentE(t, options, "nginx-deployment")
require.Error(t, err)
}

func TestGetDeployments(t *testing.T) {
t.Parallel()

uniqueID := strings.ToLower(random.UniqueId())
options := NewKubectlOptions("", "", uniqueID)
configData := fmt.Sprintf(ExampleDeploymentYAMLTemplate, uniqueID)
KubectlApplyFromString(t, options, configData)
defer KubectlDeleteFromString(t, options, configData)

deployment := GetDeployment(t, options, "nginx-deployment")
require.Equal(t, deployment.Name, "nginx-deployment")
require.Equal(t, deployment.Namespace, uniqueID)
}

func TestListDeployments(t *testing.T) {
t.Parallel()

uniqueID := strings.ToLower(random.UniqueId())
options := NewKubectlOptions("", "", uniqueID)
configData := fmt.Sprintf(ExampleDeploymentYAMLTemplate, uniqueID)
KubectlApplyFromString(t, options, configData)
defer KubectlDeleteFromString(t, options, configData)

deployments := ListDeployments(t, options, metav1.ListOptions{})
require.Equal(t, len(deployments), 1)

deployment := deployments[0]
require.Equal(t, deployment.Name, "nginx-deployment")
require.Equal(t, deployment.Namespace, uniqueID)
}

func TestWaitUntilDeploymentAvailable(t *testing.T) {
t.Parallel()

uniqueID := strings.ToLower(random.UniqueId())
options := NewKubectlOptions("", "", uniqueID)
configData := fmt.Sprintf(ExampleDeploymentYAMLTemplate, uniqueID)
KubectlApplyFromString(t, options, configData)
defer KubectlDeleteFromString(t, options, configData)

WaitUntilDeploymentAvailable(t, options, "nginx-deployment", 60, 1*time.Second)
}

func TestTestIsDeploymentAvailable(t *testing.T) {
testCases := []struct {
title string
deploy *v1.Deployment
expectedResult bool
}{
{
title: "TestIsDeploymentAvailableReadyButWithUnavailableReplicas",
deploy: &v1.Deployment{
Status: v1.DeploymentStatus{
UnavailableReplicas: 1,
Conditions: []v1.DeploymentCondition{
{
Status: "True",
},
},
},
},
expectedResult: false,
},
{
title: "TestIsDeploymentAvailableReadyButWithoutUnavailableReplicas",
deploy: &v1.Deployment{
Status: v1.DeploymentStatus{
UnavailableReplicas: 0,
Conditions: []v1.DeploymentCondition{
{
Status: "True",
},
},
},
},
expectedResult: true,
},
}

for _, tc := range testCases {
tc := tc
t.Run(tc.title, func(t *testing.T) {
t.Parallel()
actualResult := IsDeploymentAvailable(tc.deploy)
require.Equal(t, tc.expectedResult, actualResult)
})
}
}

const ExampleDeploymentYAMLTemplate = `---
apiVersion: v1
kind: Namespace
metadata:
name: %s
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
strategy:
rollingUpdate:
maxSurge: 10%%
maxUnavailable: 0
replicas: 2
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.15.7
ports:
- containerPort: 80
readinessProbe:
httpGet:
path: /
port: 80
`
21 changes: 21 additions & 0 deletions modules/k8s/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package k8s
import (
"fmt"

appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
Expand Down Expand Up @@ -61,6 +62,26 @@ func (err ServiceAccountTokenNotAvailable) Error() string {
return fmt.Sprintf("ServiceAccount %s does not have a token yet.", err.Name)
}

// DeploymentNotAvailable is returned when a Kubernetes deployment is not yet available to accept traffic.
type DeploymentNotAvailable struct {
deploy *appsv1.Deployment
}

// Error is a simple function to return a formatted error message as a string
func (err DeploymentNotAvailable) Error() string {
return fmt.Sprintf(
"Deployment %s is not available, reason: %s, message: %s",
err.deploy.Name,
err.deploy.Status.Conditions[0].Reason,
err.deploy.Status.Conditions[0].Message,
)
}

// NewDeploymentNotAvailableError returnes a DeploymentNotAvailable struct when Kubernetes deems a deployment is not available
func NewDeploymentNotAvailableError(deploy *appsv1.Deployment) DeploymentNotAvailable {
return DeploymentNotAvailable{deploy}
}

// PodNotAvailable is returned when a Kubernetes service is not yet available to accept traffic.
type PodNotAvailable struct {
pod *corev1.Pod
Expand Down
25 changes: 25 additions & 0 deletions modules/k8s/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,16 @@ type KubeResourceType int
const (
// ResourceTypePod is a k8s pod kind identifier
ResourceTypePod KubeResourceType = iota
// ResourceTypeDeployment is a k8s deployment kind identifier
ResourceTypeDeployment
// ResourceTypeService is a k8s service kind identifier
ResourceTypeService
)

func (resourceType KubeResourceType) String() string {
switch resourceType {
case ResourceTypeDeployment:
return "deploy"
case ResourceTypePod:
return "pod"
case ResourceTypeService:
Expand Down Expand Up @@ -118,11 +122,32 @@ func (tunnel *Tunnel) getAttachablePodForResourceE(t testing.TestingT) (string,
return tunnel.resourceName, nil
case ResourceTypeService:
return tunnel.getAttachablePodForServiceE(t)
case ResourceTypeDeployment:
return tunnel.getAttachablePodForDeploymentE(t)
default:
return "", UnknownKubeResourceType{tunnel.resourceType}
}
}

// getAttachablePodForDeploymentE will find an active pod associated with the Deployment and return the pod name.
func (tunnel *Tunnel) getAttachablePodForDeploymentE(t testing.TestingT) (string, error) {
deploy, err := GetDeploymentE(t, tunnel.kubectlOptions, tunnel.resourceName)
if err != nil {
return "", err
}
selectorLabelsOfPods := makeLabels(deploy.Spec.Selector.MatchLabels)
deploymentPods, err := ListPodsE(t, tunnel.kubectlOptions, metav1.ListOptions{LabelSelector: selectorLabelsOfPods})
if err != nil {
return "", err
}
for _, pod := range deploymentPods {
if IsPodAvailable(&pod) {
return pod.Name, nil
}
}
return "", DeploymentNotAvailable{deploy}
}

// getAttachablePodForServiceE will find an active pod associated with the Service and return the pod name.
func (tunnel *Tunnel) getAttachablePodForServiceE(t testing.TestingT) (string, error) {
service, err := GetServiceE(t, tunnel.kubectlOptions, tunnel.resourceName)
Expand Down
29 changes: 29 additions & 0 deletions modules/k8s/tunnel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,35 @@ func TestTunnelOpensAPortForwardTunnelToPod(t *testing.T) {
)
}

func TestTunnelOpensAPortForwardTunnelToDeployment(t *testing.T) {
t.Parallel()

uniqueID := strings.ToLower(random.UniqueId())
options := NewKubectlOptions("", "", uniqueID)
configData := fmt.Sprintf(ExampleDeploymentYAMLTemplate, uniqueID)
KubectlApplyFromString(t, options, configData)
defer KubectlDeleteFromString(t, options, configData)
WaitUntilDeploymentAvailable(t, options, "nginx-deployment", 60, 1*time.Second)

// Open a tunnel to pod from any available port locally
tunnel := NewTunnel(options, ResourceTypeDeployment, "nginx-deployment", 0, 80)
defer 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 TestTunnelOpensAPortForwardTunnelToService(t *testing.T) {
t.Parallel()

Expand Down

0 comments on commit e78d052

Please sign in to comment.