diff --git a/internal/workflows/eks_test.go b/internal/workflows/eks_test.go new file mode 100644 index 0000000..bfdaa91 --- /dev/null +++ b/internal/workflows/eks_test.go @@ -0,0 +1,106 @@ +package workflows + +import ( + "errors" + "testing" + + "github.com/Amertz08/gitops-example/internal/activities" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/testsuite" +) + +type EKSWorkflowTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + env *testsuite.TestWorkflowEnvironment +} + +func (s *EKSWorkflowTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() +} + +func (s *EKSWorkflowTestSuite) AfterTest(_, _ string) { + s.env.AssertExpectations(s.T()) +} + +func TestEKSWorkflowSuite(t *testing.T) { + suite.Run(t, new(EKSWorkflowTestSuite)) +} + +func validEKSInput() SpinUpEKSInput { + return SpinUpEKSInput{ + Region: "us-east-1", + ClusterName: "my-cluster", + ClusterRoleARN: "arn:aws:iam::123:role/cluster-role", + NodeRoleARN: "arn:aws:iam::123:role/node-role", + VpcID: "vpc-123", + SubnetIDs: []string{"subnet-a", "subnet-b"}, + NodeCount: 2, + NodeInstanceType: "t3.medium", + Environment: "prod", + Team: "platform", + } +} + +func (s *EKSWorkflowTestSuite) Test_SpinUpEKS_InvalidInput() { + s.env.ExecuteWorkflow(SpinUpEKSWorkflow, SpinUpEKSInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *EKSWorkflowTestSuite) Test_SpinUpEKS_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateEKSCluster, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.CreateNodeGroup, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(SpinUpEKSWorkflow, validEKSInput()) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +// CreateNodeGroup failing must trigger saga compensation to delete the cluster. +func (s *EKSWorkflowTestSuite) Test_SpinUpEKS_NodeGroupFailure_CompensatesCluster() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateEKSCluster, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.CreateNodeGroup, mock.Anything, mock.Anything). + Return(errors.New("node group quota exceeded")) + s.env.OnActivity(aws.DeleteEKSCluster, mock.Anything, mock.Anything).Return(nil).Once() + + s.env.ExecuteWorkflow(SpinUpEKSWorkflow, validEKSInput()) + + s.True(s.env.IsWorkflowCompleted()) + s.Error(s.env.GetWorkflowError()) +} + +func (s *EKSWorkflowTestSuite) Test_SpinDownEKS_InvalidInput() { + s.env.ExecuteWorkflow(SpinDownEKSWorkflow, SpinDownEKSInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *EKSWorkflowTestSuite) Test_SpinDownEKS_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.DeleteNodeGroup, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteEKSCluster, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(SpinDownEKSWorkflow, SpinDownEKSInput{ + Region: "us-east-1", + ClusterName: "my-cluster", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} diff --git a/internal/workflows/iam_test.go b/internal/workflows/iam_test.go new file mode 100644 index 0000000..aa8ac74 --- /dev/null +++ b/internal/workflows/iam_test.go @@ -0,0 +1,109 @@ +package workflows + +import ( + "errors" + "testing" + + "github.com/Amertz08/gitops-example/internal/activities" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/testsuite" +) + +type IAMWorkflowTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + env *testsuite.TestWorkflowEnvironment +} + +func (s *IAMWorkflowTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() +} + +func (s *IAMWorkflowTestSuite) AfterTest(_, _ string) { + s.env.AssertExpectations(s.T()) +} + +func TestIAMWorkflowSuite(t *testing.T) { + suite.Run(t, new(IAMWorkflowTestSuite)) +} + +func (s *IAMWorkflowTestSuite) Test_SpinUpIAM_InvalidInput() { + s.env.ExecuteWorkflow(SpinUpIAMWorkflow, SpinUpEKSIAMInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *IAMWorkflowTestSuite) Test_SpinUpIAM_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("arn:aws:iam::123:role/cluster-role", nil).Once() + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("arn:aws:iam::123:role/node-role", nil).Once() + + s.env.ExecuteWorkflow(SpinUpIAMWorkflow, SpinUpEKSIAMInput{ + ClusterName: "my-cluster", + Environment: "prod", + Team: "platform", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var out SpinUpEKSIAMOutput + s.NoError(s.env.GetWorkflowResult(&out)) + s.Equal("arn:aws:iam::123:role/cluster-role", out.ClusterRoleARN) + s.Equal("my-cluster-eks-cluster-role", out.ClusterRoleName) + s.Equal("arn:aws:iam::123:role/node-role", out.NodeRoleARN) + s.Equal("my-cluster-eks-node-role", out.NodeRoleName) +} + +// When node role creation fails, the saga must compensate the already-created cluster role. +func (s *IAMWorkflowTestSuite) Test_SpinUpIAM_NodeRoleFailure_CompensatesClusterRole() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("arn:aws:iam::123:role/cluster-role", nil).Once() + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("", errors.New("IAM quota exceeded")) + s.env.OnActivity(aws.DeleteIAMRole, mock.Anything, mock.Anything). + Return(nil).Once() + + s.env.ExecuteWorkflow(SpinUpIAMWorkflow, SpinUpEKSIAMInput{ + ClusterName: "my-cluster", + Environment: "prod", + Team: "platform", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.Error(s.env.GetWorkflowError()) +} + +func (s *IAMWorkflowTestSuite) Test_SpinDownIAM_InvalidInput() { + s.env.ExecuteWorkflow(SpinDownIAMWorkflow, SpinDownEKSIAMInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *IAMWorkflowTestSuite) Test_SpinDownIAM_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.DeleteIAMRole, mock.Anything, mock.Anything).Return(nil).Times(2) + + s.env.ExecuteWorkflow(SpinDownIAMWorkflow, SpinDownEKSIAMInput{ + ClusterRoleName: "my-cluster-eks-cluster-role", + NodeRoleName: "my-cluster-eks-node-role", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} diff --git a/internal/workflows/infrastructure_test.go b/internal/workflows/infrastructure_test.go new file mode 100644 index 0000000..f919160 --- /dev/null +++ b/internal/workflows/infrastructure_test.go @@ -0,0 +1,160 @@ +package workflows + +import ( + "errors" + "testing" + + "github.com/Amertz08/gitops-example/internal/activities" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/testsuite" +) + +type InfraWorkflowTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + env *testsuite.TestWorkflowEnvironment +} + +func (s *InfraWorkflowTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() + s.env.RegisterWorkflow(SpinUpNetworkWorkflow) + s.env.RegisterWorkflow(SpinUpIAMWorkflow) + s.env.RegisterWorkflow(SpinUpEKSWorkflow) + s.env.RegisterWorkflow(SpinDownEKSWorkflow) + s.env.RegisterWorkflow(SpinDownNetworkWorkflow) + s.env.RegisterWorkflow(SpinDownIAMWorkflow) +} + +func (s *InfraWorkflowTestSuite) AfterTest(_, _ string) { + s.env.AssertExpectations(s.T()) +} + +func TestInfraWorkflowSuite(t *testing.T) { + suite.Run(t, new(InfraWorkflowTestSuite)) +} + +// mockHappyPathNetworkActivities mocks all network activities to return success. +func (s *InfraWorkflowTestSuite) mockHappyPathNetworkActivities() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateVPC, mock.Anything, mock.Anything).Return("vpc-123", nil) + s.env.OnActivity(aws.CreateSubnets, mock.Anything, mock.Anything). + Return([]string{"subnet-a", "subnet-b"}, nil) + s.env.OnActivity(aws.CreateInternetGateway, mock.Anything, mock.Anything).Return("igw-123", nil) + s.env.OnActivity(aws.ConfigureRouteTables, mock.Anything, mock.Anything).Return(nil) +} + +// mockHappyPathEKSActivities mocks all EKS activities to return success. +func (s *InfraWorkflowTestSuite) mockHappyPathEKSActivities() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateEKSCluster, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.CreateNodeGroup, mock.Anything, mock.Anything).Return(nil) +} + +func validSpinUpInput() SpinUpInput { + return SpinUpInput{ + Region: "us-east-1", + ClusterName: "my-cluster", + NodeCount: 2, + NodeInstanceType: "t3.medium", + Environment: "prod", + Team: "platform", + } +} + +func (s *InfraWorkflowTestSuite) Test_SpinUp_InvalidInput() { + s.env.ExecuteWorkflow(SpinUpWorkflow, SpinUpInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +// When no role ARNs are supplied, SpinUpWorkflow must run SpinUpIAMWorkflow concurrently with +// SpinUpNetworkWorkflow, then use the resulting ARNs for SpinUpEKSWorkflow. +func (s *InfraWorkflowTestSuite) Test_SpinUp_Success_WithoutPreSuppliedARNs() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("arn:aws:iam::123:role/cluster-role", nil).Once() + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("arn:aws:iam::123:role/node-role", nil).Once() + s.mockHappyPathNetworkActivities() + s.mockHappyPathEKSActivities() + + s.env.ExecuteWorkflow(SpinUpWorkflow, validSpinUpInput()) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +// When role ARNs are pre-supplied, SpinUpWorkflow must skip IAM creation entirely. +func (s *InfraWorkflowTestSuite) Test_SpinUp_Success_WithPreSuppliedARNs() { + s.mockHappyPathNetworkActivities() + s.mockHappyPathEKSActivities() + + input := validSpinUpInput() + input.ClusterRoleARN = "arn:aws:iam::123:role/existing-cluster-role" + input.NodeRoleARN = "arn:aws:iam::123:role/existing-node-role" + s.env.ExecuteWorkflow(SpinUpWorkflow, input) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +func (s *InfraWorkflowTestSuite) Test_SpinDown_InvalidInput() { + s.env.ExecuteWorkflow(SpinDownWorkflow, SpinDownInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +// SpinDownWorkflow tears down EKS, network, and (when role names are provided) IAM. +func (s *InfraWorkflowTestSuite) Test_SpinDown_Success_WithRoles() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.DeleteNodeGroup, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteEKSCluster, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteSubnets, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteRouteTables, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DetachDeleteInternetGateway, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteVPC, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteIAMRole, mock.Anything, mock.Anything).Return(nil).Times(2) + + s.env.ExecuteWorkflow(SpinDownWorkflow, SpinDownInput{ + Region: "us-east-1", + ClusterName: "my-cluster", + VpcID: "vpc-123", + ClusterRoleName: "my-cluster-eks-cluster-role", + NodeRoleName: "my-cluster-eks-node-role", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +// When role names are empty, SpinDownWorkflow must skip the IAM teardown child workflow. +func (s *InfraWorkflowTestSuite) Test_SpinDown_Success_WithoutRoles() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.DeleteNodeGroup, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteEKSCluster, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteSubnets, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteRouteTables, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DetachDeleteInternetGateway, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteVPC, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(SpinDownWorkflow, SpinDownInput{ + Region: "us-east-1", + ClusterName: "my-cluster", + VpcID: "vpc-123", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} diff --git a/internal/workflows/vpc_test.go b/internal/workflows/vpc_test.go new file mode 100644 index 0000000..160538e --- /dev/null +++ b/internal/workflows/vpc_test.go @@ -0,0 +1,125 @@ +package workflows + +import ( + "errors" + "testing" + + "github.com/Amertz08/gitops-example/internal/activities" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/testsuite" +) + +type VPCWorkflowTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + env *testsuite.TestWorkflowEnvironment +} + +func (s *VPCWorkflowTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() +} + +func (s *VPCWorkflowTestSuite) AfterTest(_, _ string) { + s.env.AssertExpectations(s.T()) +} + +func TestVPCWorkflowSuite(t *testing.T) { + suite.Run(t, new(VPCWorkflowTestSuite)) +} + +func (s *VPCWorkflowTestSuite) Test_SpinUpNetwork_InvalidInput() { + s.env.ExecuteWorkflow(SpinUpNetworkWorkflow, SpinUpNetworkInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *VPCWorkflowTestSuite) Test_SpinUpNetwork_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateVPC, mock.Anything, mock.Anything).Return("vpc-123", nil) + s.env.OnActivity(aws.CreateSubnets, mock.Anything, mock.Anything). + Return([]string{"subnet-a", "subnet-b"}, nil) + s.env.OnActivity(aws.CreateInternetGateway, mock.Anything, mock.Anything).Return("igw-123", nil) + s.env.OnActivity(aws.ConfigureRouteTables, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(SpinUpNetworkWorkflow, SpinUpNetworkInput{ + Region: "us-east-1", + Environment: "prod", + Team: "platform", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var out SpinUpNetworkOutput + s.NoError(s.env.GetWorkflowResult(&out)) + s.Equal("vpc-123", out.VpcID) + s.Equal([]string{"subnet-a", "subnet-b"}, out.SubnetIDs) +} + +// CreateVPC fails before any compensation is registered, so no deletes should occur. +func (s *VPCWorkflowTestSuite) Test_SpinUpNetwork_CreateVPCFailure() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateVPC, mock.Anything, mock.Anything). + Return("", errors.New("VPC limit reached")) + + s.env.ExecuteWorkflow(SpinUpNetworkWorkflow, SpinUpNetworkInput{ + Region: "us-east-1", + Environment: "prod", + Team: "platform", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.Error(s.env.GetWorkflowError()) +} + +// CreateSubnets fails after VPC is created; saga must compensate by deleting the VPC. +func (s *VPCWorkflowTestSuite) Test_SpinUpNetwork_SubnetsFailure_CompensatesVPC() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateVPC, mock.Anything, mock.Anything).Return("vpc-123", nil) + s.env.OnActivity(aws.CreateSubnets, mock.Anything, mock.Anything). + Return([]string(nil), errors.New("subnet CIDR conflict")) + s.env.OnActivity(aws.DeleteVPC, mock.Anything, mock.Anything).Return(nil).Once() + + s.env.ExecuteWorkflow(SpinUpNetworkWorkflow, SpinUpNetworkInput{ + Region: "us-east-1", + Environment: "prod", + Team: "platform", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.Error(s.env.GetWorkflowError()) +} + +func (s *VPCWorkflowTestSuite) Test_SpinDownNetwork_InvalidInput() { + s.env.ExecuteWorkflow(SpinDownNetworkWorkflow, SpinDownNetworkInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *VPCWorkflowTestSuite) Test_SpinDownNetwork_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.DeleteSubnets, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteRouteTables, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DetachDeleteInternetGateway, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteVPC, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(SpinDownNetworkWorkflow, SpinDownNetworkInput{ + Region: "us-east-1", + VpcID: "vpc-123", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +}