diff --git a/docs/api-types/gateway.md b/docs/api-types/gateway.md index c322bfdf..a88b8c19 100644 --- a/docs/api-types/gateway.md +++ b/docs/api-types/gateway.md @@ -11,14 +11,28 @@ Service networks are identified by Gateway name (without namespace) - for exampl will point to a VPC Lattice service network `my-gateway`. If multiple Gateways share the same name, all of them will point to the same service network. -VPC Lattice service networks must be managed separately, as it is a broader concept that can cover resources -outside the Kubernetes cluster. To create and manage a service network, you can either: - -- Specify `DEFAULT_SERVICE_NETWORK` configuration option on the controller. This will make the controller - to create a service network with such name, and associate the cluster VPC to it for you. This is suitable - for simple use cases with single service network. -- Manage service networks outside the cluster, using AWS Console, CDK, CloudFormation, etc. This is recommended - for more advanced use cases that cover multiple clusters and VPCs. +### ServiceNetwork Lifecycle + +When a Gateway is created, the controller automatically creates a VPC Lattice ServiceNetwork with the same name +and associates it with the cluster VPC. When the Gateway is deleted, the controller deletes the ServiceNetwork +if it was created by the controller (tracked via the `application-networking.k8s.aws/ManagedBy` tag). + +Auto-created ServiceNetworks use **default VPC Lattice settings** — no auth policy, no sharing configuration, +and no custom attributes. If you need to configure auth type, sharing, or other +[ServiceNetwork attributes](https://docs.aws.amazon.com/vpc-lattice/latest/APIReference/API_CreateServiceNetwork.html), +create the ServiceNetwork externally (via Console, CLI, CDK, CloudFormation, etc.) before creating the Gateway. +The controller will detect the existing ServiceNetwork by name and reuse it without modifying or deleting it. + +**Deletion behavior:** + +- If multiple Gateways share the same ServiceNetwork name, deleting one Gateway will **not** delete the + ServiceNetwork as long as another active Gateway with the same name still exists. +- If the ServiceNetwork has active service associations, the controller will not delete it and will report + an error asking you to detach all services first. +- Externally-created ServiceNetworks are never deleted by the controller. + +In addition to auto-creation, you can also use the `DEFAULT_SERVICE_NETWORK` configuration option on the controller +to create a default ServiceNetwork at startup. Gateways with `amazon-vpc-lattice` GatewayClass do not create a single entrypoint to bind Listeners and Routes under them. Instead, each Route will have its own domain name assigned. To see an example of how domain names diff --git a/pkg/controllers/gateway_controller.go b/pkg/controllers/gateway_controller.go index 6444a0df..429b9b7f 100644 --- a/pkg/controllers/gateway_controller.go +++ b/pkg/controllers/gateway_controller.go @@ -61,6 +61,7 @@ type gatewayReconciler struct { finalizerManager k8s.FinalizerManager eventRecorder record.EventRecorder cloud aws.Cloud + snManager deploy.ServiceNetworkManager } func RegisterGatewayController( @@ -73,6 +74,8 @@ func RegisterGatewayController( scheme := mgr.GetScheme() evtRec := mgr.GetEventRecorderFor("gateway") + snManager := deploy.NewDefaultServiceNetworkManager(log, cloud) + r := &gatewayReconciler{ log: log, client: mgrClient, @@ -80,11 +83,11 @@ func RegisterGatewayController( finalizerManager: finalizerManager, eventRecorder: evtRec, cloud: cloud, + snManager: snManager, } if config.DefaultServiceNetwork != "" { // Attempt creation of default service network, move gracefully even if it fails. - snManager := deploy.NewDefaultServiceNetworkManager(log, cloud) _, err := snManager.CreateOrUpdate(context.Background(), &model.ServiceNetwork{ Spec: model.ServiceNetworkSpec{ Name: config.DefaultServiceNetwork, @@ -177,12 +180,60 @@ func (r *gatewayReconciler) reconcileDelete(ctx context.Context, gw *gwv1.Gatewa } } - err = r.finalizerManager.RemoveFinalizers(ctx, gw, gatewayFinalizer) + hasSibling, err := r.hasSiblingGateway(ctx, gw) if err != nil { return err } + if hasSibling { + r.log.Infof(ctx, "Skipping ServiceNetwork deletion for %s, another Gateway with the same name exists", gw.Name) + } else { + if err := r.snManager.Delete(ctx, gw.Name); err != nil { + return err + } + } - return nil + return r.finalizerManager.RemoveFinalizers(ctx, gw, gatewayFinalizer) +} + +// hasSiblingGateway checks if another active Lattice-controlled Gateway with the same .Name exists. +// Gateways that are themselves being deleted (DeletionTimestamp set) are not counted as siblings, +// so that simultaneous deletion of all Gateways sharing an SN name still cleans up the SN. +func (r *gatewayReconciler) hasSiblingGateway(ctx context.Context, gw *gwv1.Gateway) (bool, error) { + gwList := &gwv1.GatewayList{} + if err := r.client.List(ctx, gwList); err != nil { + return false, fmt.Errorf("failed to list gateways: %w", err) + } + for i := range gwList.Items { + other := &gwList.Items[i] + if other.UID == gw.UID { + continue + } + if other.Name == gw.Name && other.DeletionTimestamp.IsZero() && + k8s.IsControlledByLatticeGatewayController(ctx, r.client, other) { + return true, nil + } + } + return false, nil +} + +// shouldAssociateWithVpc returns true unless a VpcAssociationPolicy targeting this Gateway +// explicitly sets associateWithVpc to false. This prevents the Gateway reconciler from +// re-creating a VPC association that the VpcAssociationPolicy controller intentionally deleted. +func (r *gatewayReconciler) shouldAssociateWithVpc(ctx context.Context, gw *gwv1.Gateway) bool { + vapList := &anv1alpha1.VpcAssociationPolicyList{} + if err := r.client.List(ctx, vapList, client.InNamespace(gw.Namespace)); err != nil { + return true + } + for i := range vapList.Items { + vap := &vapList.Items[i] + if string(vap.Spec.TargetRef.Name) == gw.Name { + if vap.Spec.AssociateWithVpc != nil && !*vap.Spec.AssociateWithVpc { + return false + } + return true + } + } + return true } func (r *gatewayReconciler) reconcileUpsert(ctx context.Context, gw *gwv1.Gateway) error { @@ -205,14 +256,13 @@ func (r *gatewayReconciler) reconcileUpsert(ctx context.Context, gw *gwv1.Gatewa return err } - snInfo, err := r.cloud.Lattice().FindServiceNetwork(ctx, gw.Name) + snStatus, err := r.snManager.CreateOrUpdate(ctx, &model.ServiceNetwork{ + Spec: model.ServiceNetworkSpec{ + Name: gw.Name, + AssociateToVPC: r.shouldAssociateWithVpc(ctx, gw), + }, + }) if err != nil { - if services.IsNotFoundError(err) { - if err = r.updateGatewayProgrammedStatus(ctx, gw, gwv1.GatewayReasonPending, "VPC Lattice Service Network not found"); err != nil { - return lattice_runtime.NewRetryError() - } - return nil - } if errors.Is(err, services.ErrNameConflict) { if err = r.updateGatewayProgrammedStatus(ctx, gw, gwv1.GatewayReasonInvalid, "Found multiple VPC Lattice Service Networks matching Gateway name. Either ensure only one Service Network has a matching name, or use the Service Network's id as the Gateway name."); err != nil { return lattice_runtime.NewRetryError() @@ -222,7 +272,7 @@ func (r *gatewayReconciler) reconcileUpsert(ctx context.Context, gw *gwv1.Gatewa return err } - err = r.updateGatewayProgrammedStatus(ctx, gw, gwv1.GatewayReasonProgrammed, fmt.Sprintf("aws-service-network-arn: %s", *snInfo.SvcNetwork.Arn)) + err = r.updateGatewayProgrammedStatus(ctx, gw, gwv1.GatewayReasonProgrammed, fmt.Sprintf("aws-service-network-arn: %s", snStatus.ServiceNetworkARN)) if err != nil { return err } diff --git a/pkg/controllers/gateway_controller_test.go b/pkg/controllers/gateway_controller_test.go index 14ec4911..c658420a 100644 --- a/pkg/controllers/gateway_controller_test.go +++ b/pkg/controllers/gateway_controller_test.go @@ -701,3 +701,79 @@ func TestUpdateGWListenerStatus_SupportedKinds(t *testing.T) { }) } } + +func TestHasSiblingGateway(t *testing.T) { + scheme := runtime.NewScheme() + clientgoscheme.AddToScheme(scheme) + gwv1.Install(scheme) + gwv1alpha2.Install(scheme) + addOptionalCRDs(scheme) + + latticeControllerName := gwv1.GatewayController("application-networking.k8s.aws/gateway-api-controller") + gwClass := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "amazon-vpc-lattice"}, + Spec: gwv1.GatewayClassSpec{ControllerName: latticeControllerName}, + } + + makeGW := func(name, ns string, uid types.UID, deleting bool) *gwv1.Gateway { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: ns, UID: uid, + }, + Spec: gwv1.GatewaySpec{GatewayClassName: "amazon-vpc-lattice"}, + } + if deleting { + now := metav1.Now() + gw.DeletionTimestamp = &now + gw.Finalizers = []string{"test"} + } + return gw + } + + tests := []struct { + name string + gw *gwv1.Gateway + others []*gwv1.Gateway + expected bool + }{ + { + name: "no sibling — only gateway with this name", + gw: makeGW("my-network", "ns1", "uid-1", false), + others: nil, + expected: false, + }, + { + name: "has sibling — same name different namespace", + gw: makeGW("my-network", "ns1", "uid-1", false), + others: []*gwv1.Gateway{makeGW("my-network", "ns2", "uid-2", false)}, + expected: true, + }, + { + name: "sibling being deleted — not counted", + gw: makeGW("my-network", "ns1", "uid-1", false), + others: []*gwv1.Gateway{makeGW("my-network", "ns2", "uid-2", true)}, + expected: false, + }, + { + name: "different name — not a sibling", + gw: makeGW("my-network", "ns1", "uid-1", false), + others: []*gwv1.Gateway{makeGW("other-network", "ns2", "uid-2", false)}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objs := []runtime.Object{gwClass, tt.gw} + for _, o := range tt.others { + objs = append(objs, o) + } + k8sClient := testclient.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build() + + r := &gatewayReconciler{client: k8sClient} + result, err := r.hasSiblingGateway(context.Background(), tt.gw) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/deploy/lattice/service_network_manager.go b/pkg/deploy/lattice/service_network_manager.go index 9a74baf7..d83a9c32 100644 --- a/pkg/deploy/lattice/service_network_manager.go +++ b/pkg/deploy/lattice/service_network_manager.go @@ -27,6 +27,7 @@ type ServiceNetworkManager interface { DeleteVpcAssociation(ctx context.Context, snName string) error CreateOrUpdate(ctx context.Context, serviceNetwork *model.ServiceNetwork) (model.ServiceNetworkStatus, error) + Delete(ctx context.Context, snName string) error } func NewDefaultServiceNetworkManager(log gwlog.Logger, cloud pkg_aws.Cloud) *defaultServiceNetworkManager { @@ -172,6 +173,52 @@ func (m *defaultServiceNetworkManager) DeleteVpcAssociation(ctx context.Context, return nil } +func (m *defaultServiceNetworkManager) Delete(ctx context.Context, snName string) error { + sn, err := m.cloud.Lattice().FindServiceNetwork(ctx, snName) + if err != nil { + if services.IsNotFoundError(err) { + return nil + } + return err + } + + snArn := aws.StringValue(sn.SvcNetwork.Arn) + owned, err := m.cloud.IsArnManaged(ctx, snArn) + if err != nil { + m.log.Warnf(ctx, "cannot check ownership of ServiceNetwork %s: %s, skipping deletion", snName, err) + return nil + } + if !owned { + m.log.Infof(ctx, "ServiceNetwork %s not owned by controller, skipping deletion", snName) + return nil + } + + assocs, err := m.cloud.Lattice().ListServiceNetworkServiceAssociationsAsList(ctx, + &vpclattice.ListServiceNetworkServiceAssociationsInput{ + ServiceNetworkIdentifier: sn.SvcNetwork.Id, + }) + if err != nil { + return fmt.Errorf("failed to list service associations for ServiceNetwork %s: %w", snName, err) + } + if len(assocs) > 0 { + return fmt.Errorf("cannot delete ServiceNetwork %s: %d service association(s) still active, "+ + "detach all services before deleting the Gateway", snName, len(assocs)) + } + + if err := m.DeleteVpcAssociation(ctx, snName); err != nil { + return err + } + + _, err = m.cloud.Lattice().DeleteServiceNetworkWithContext(ctx, &vpclattice.DeleteServiceNetworkInput{ + ServiceNetworkIdentifier: sn.SvcNetwork.Id, + }) + if err != nil { + return err + } + m.log.Infof(ctx, "Deleted ServiceNetwork %s", snName) + return nil +} + func (m *defaultServiceNetworkManager) getActiveVpcAssociation(ctx context.Context, serviceNetworkId string) (*vpclattice.ServiceNetworkVpcAssociationSummary, error) { vpcLatticeSess := m.cloud.Lattice() associationStatusInput := vpclattice.ListServiceNetworkVpcAssociationsInput{ @@ -252,6 +299,11 @@ func (m *defaultServiceNetworkManager) CreateOrUpdate(ctx context.Context, servi } } + if !serviceNetwork.Spec.AssociateToVPC { + m.log.Debugf(ctx, "Skipping VPC association for ServiceNetwork %s (AssociateToVPC=false)", serviceNetwork.Spec.Name) + return model.ServiceNetworkStatus{ServiceNetworkARN: serviceNetworkArn, ServiceNetworkID: serviceNetworkId}, nil + } + m.log.Debugf(ctx, "Creating association between ServiceNetwork %s and VPC %s", serviceNetworkId, config.VpcID) createServiceNetworkVpcAssociationInput := vpclattice.CreateServiceNetworkVpcAssociationInput{ ServiceNetworkIdentifier: &serviceNetworkId, diff --git a/pkg/deploy/lattice/service_network_manager_mock.go b/pkg/deploy/lattice/service_network_manager_mock.go index 548b4c64..e908bd9e 100644 --- a/pkg/deploy/lattice/service_network_manager_mock.go +++ b/pkg/deploy/lattice/service_network_manager_mock.go @@ -50,6 +50,20 @@ func (mr *MockServiceNetworkManagerMockRecorder) CreateOrUpdate(arg0, arg1 inter return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOrUpdate", reflect.TypeOf((*MockServiceNetworkManager)(nil).CreateOrUpdate), arg0, arg1) } +// Delete mocks base method. +func (m *MockServiceNetworkManager) Delete(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockServiceNetworkManagerMockRecorder) Delete(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockServiceNetworkManager)(nil).Delete), arg0, arg1) +} + // DeleteVpcAssociation mocks base method. func (m *MockServiceNetworkManager) DeleteVpcAssociation(arg0 context.Context, arg1 string) error { m.ctrl.T.Helper() diff --git a/pkg/deploy/lattice/service_network_manager_test.go b/pkg/deploy/lattice/service_network_manager_test.go index c27260e6..1bef9176 100644 --- a/pkg/deploy/lattice/service_network_manager_test.go +++ b/pkg/deploy/lattice/service_network_manager_test.go @@ -30,8 +30,9 @@ func Test_CreateOrUpdateServiceNetwork_SnNotExist_NeedToAssociate(t *testing.T) snCreateInput := model.ServiceNetwork{ Spec: model.ServiceNetworkSpec{ - Name: "test", - Account: "123456789", + Name: "test", + Account: "123456789", + AssociateToVPC: true, }, Status: &model.ServiceNetworkStatus{ServiceNetworkARN: "", ServiceNetworkID: ""}, } @@ -822,3 +823,108 @@ func Test_UpsertVpcAssociation_WithAdditionalTags_NoExistingAssociation(t *testi assert.Equal(t, err, nil) assert.Equal(t, resp, snArn) } + +func Test_Delete_SnNotFound(t *testing.T) { + c := gomock.NewController(t) + defer c.Finish() + ctx := context.TODO() + mockLattice := mocks.NewMockLattice(c) + cloud := pkg_aws.NewDefaultCloud(mockLattice, TestCloudConfig) + + mockLattice.EXPECT().FindServiceNetwork(ctx, "test-sn").Return(nil, mocks.NewNotFoundError("ServiceNetwork", "test-sn")) + + snMgr := NewDefaultServiceNetworkManager(gwlog.FallbackLogger, cloud) + err := snMgr.Delete(ctx, "test-sn") + assert.Nil(t, err) +} + +func Test_Delete_SnNotOwned(t *testing.T) { + c := gomock.NewController(t) + defer c.Finish() + ctx := context.TODO() + mockLattice := mocks.NewMockLattice(c) + cloud := pkg_aws.NewDefaultCloudWithTagging(mockLattice, mocks.NewMockTagging(c), TestCloudConfig) + + snArn := "arn:aws:vpc-lattice:us-west-2:account-id:servicenetwork/sn-123" + snId := "sn-123" + name := "test-sn" + + mockLattice.EXPECT().FindServiceNetwork(ctx, name).Return(&mocks.ServiceNetworkInfo{ + SvcNetwork: vpclattice.ServiceNetworkSummary{Arn: &snArn, Id: &snId, Name: &name}, + }, nil) + // Return tags that don't match the controller's managed-by tag + mockLattice.EXPECT().ListTagsForResourceWithContext(ctx, gomock.Any()).Return( + &vpclattice.ListTagsForResourceOutput{Tags: map[string]*string{}}, nil) + + snMgr := NewDefaultServiceNetworkManager(gwlog.FallbackLogger, cloud) + err := snMgr.Delete(ctx, name) + assert.Nil(t, err) +} + +func Test_Delete_SnOwned_Success(t *testing.T) { + c := gomock.NewController(t) + defer c.Finish() + ctx := context.TODO() + mockLattice := mocks.NewMockLattice(c) + cloud := pkg_aws.NewDefaultCloudWithTagging(mockLattice, mocks.NewMockTagging(c), TestCloudConfig) + + snArn := "arn:aws:vpc-lattice:us-west-2:account-id:servicenetwork/sn-123" + snId := "sn-123" + name := "test-sn" + + // First call from Delete, second from DeleteVpcAssociation + mockLattice.EXPECT().FindServiceNetwork(ctx, name).Return(&mocks.ServiceNetworkInfo{ + SvcNetwork: vpclattice.ServiceNetworkSummary{Arn: &snArn, Id: &snId, Name: &name}, + }, nil).Times(2) + + // IsArnManaged check for the SN itself - return controller's tags + mockLattice.EXPECT().ListTagsForResourceWithContext(ctx, gomock.Any()).Return( + &vpclattice.ListTagsForResourceOutput{Tags: cloud.DefaultTags()}, nil) + + // No active service associations + mockLattice.EXPECT().ListServiceNetworkServiceAssociationsAsList(ctx, gomock.Any()).Return( + []*vpclattice.ServiceNetworkServiceAssociationSummary{}, nil) + + // DeleteVpcAssociation: no active association + mockLattice.EXPECT().ListServiceNetworkVpcAssociationsAsList(ctx, gomock.Any()).Return( + []*vpclattice.ServiceNetworkVpcAssociationSummary{}, nil) + + // Delete the SN + mockLattice.EXPECT().DeleteServiceNetworkWithContext(ctx, &vpclattice.DeleteServiceNetworkInput{ + ServiceNetworkIdentifier: &snId, + }).Return(&vpclattice.DeleteServiceNetworkOutput{}, nil) + + snMgr := NewDefaultServiceNetworkManager(gwlog.FallbackLogger, cloud) + err := snMgr.Delete(ctx, name) + assert.Nil(t, err) +} + +func Test_Delete_SnOwned_ActiveServiceAssociations(t *testing.T) { + c := gomock.NewController(t) + defer c.Finish() + ctx := context.TODO() + mockLattice := mocks.NewMockLattice(c) + cloud := pkg_aws.NewDefaultCloudWithTagging(mockLattice, mocks.NewMockTagging(c), TestCloudConfig) + + snArn := "arn:aws:vpc-lattice:us-west-2:account-id:servicenetwork/sn-123" + snId := "sn-123" + name := "test-sn" + + mockLattice.EXPECT().FindServiceNetwork(ctx, name).Return(&mocks.ServiceNetworkInfo{ + SvcNetwork: vpclattice.ServiceNetworkSummary{Arn: &snArn, Id: &snId, Name: &name}, + }, nil) + + mockLattice.EXPECT().ListTagsForResourceWithContext(ctx, gomock.Any()).Return( + &vpclattice.ListTagsForResourceOutput{Tags: cloud.DefaultTags()}, nil) + + svcName := "my-service" + mockLattice.EXPECT().ListServiceNetworkServiceAssociationsAsList(ctx, gomock.Any()).Return( + []*vpclattice.ServiceNetworkServiceAssociationSummary{ + {ServiceName: &svcName}, + }, nil) + + snMgr := NewDefaultServiceNetworkManager(gwlog.FallbackLogger, cloud) + err := snMgr.Delete(ctx, name) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "1 service association(s) still active") +} diff --git a/test/suites/integration/gateway_service_network_test.go b/test/suites/integration/gateway_service_network_test.go new file mode 100644 index 00000000..00950b8f --- /dev/null +++ b/test/suites/integration/gateway_service_network_test.go @@ -0,0 +1,157 @@ +package integration + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/vpclattice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var _ = Describe("Gateway ServiceNetwork Lifecycle", Ordered, func() { + + Context("Sibling Gateway deletion safety", Ordered, func() { + var ( + ns2 *corev1.Namespace + gw1 *gwv1.Gateway + gw2 *gwv1.Gateway + gwName string + ) + + BeforeAll(func() { + gwName = "sibling-gw-test" + + gw1 = testFramework.NewGateway(gwName, k8snamespace) + testFramework.ExpectCreated(ctx, gw1) + + ns2 = &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "e2e-sibling-ns"}} + testFramework.ExpectCreated(ctx, ns2) + gw2 = testFramework.NewGateway(gwName, ns2.Name) + testFramework.ExpectCreated(ctx, gw2) + + // Wait for SN to exist + _ = testFramework.GetServiceNetwork(ctx, gw1) + }) + + It("deleting gw1 does NOT delete the ServiceNetwork while gw2 exists", func() { + testFramework.ExpectDeletedThenNotFound(ctx, gw1) + + // SN must still exist because gw2 is alive + Consistently(func(g Gomega) { + sn := testFramework.GetServiceNetwork(ctx, gw2) + g.Expect(sn).ToNot(BeNil()) + }, "30s", "5s").Should(Succeed()) + }) + + It("deleting gw2 (last Gateway) deletes the ServiceNetwork", func() { + testFramework.ExpectDeletedThenNotFound(ctx, gw2) + + Eventually(func(g Gomega) { + list, err := testFramework.LatticeClient.ListServiceNetworksWithContext(ctx, &vpclattice.ListServiceNetworksInput{}) + g.Expect(err).ToNot(HaveOccurred()) + for _, sn := range list.Items { + g.Expect(aws.StringValue(sn.Name)).ToNot(Equal(gwName)) + } + }).Should(Succeed()) + + // Cleanup namespace + testFramework.ExpectDeletedThenNotFound(ctx, ns2) + }) + }) + + Context("Service association delete guard", Ordered, func() { + var ( + gw *gwv1.Gateway + gwName string + dummySvcId *string + ) + + BeforeAll(func() { + gwName = "assoc-guard-test" + gw = testFramework.NewGateway(gwName, k8snamespace) + testFramework.ExpectCreated(ctx, gw) + + sn := testFramework.GetServiceNetwork(ctx, gw) + + // Create a dummy Lattice service and wait for it to become ACTIVE + svcResp, err := testFramework.LatticeClient.CreateServiceWithContext(ctx, &vpclattice.CreateServiceInput{ + Name: aws.String(gwName + "-dummy-svc"), + }) + Expect(err).ToNot(HaveOccurred()) + dummySvcId = svcResp.Id + + Eventually(func(g Gomega) { + out, err := testFramework.LatticeClient.GetServiceWithContext(ctx, &vpclattice.GetServiceInput{ + ServiceIdentifier: dummySvcId, + }) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(aws.StringValue(out.Status)).To(Equal(vpclattice.ServiceStatusActive)) + }).Should(Succeed()) + + _, err = testFramework.LatticeClient.CreateServiceNetworkServiceAssociationWithContext(ctx, &vpclattice.CreateServiceNetworkServiceAssociationInput{ + ServiceNetworkIdentifier: sn.Id, + ServiceIdentifier: dummySvcId, + }) + Expect(err).ToNot(HaveOccurred()) + + // Wait for association to become active + Eventually(func(g Gomega) { + assocs, err := testFramework.LatticeClient.ListServiceNetworkServiceAssociationsAsList(ctx, + &vpclattice.ListServiceNetworkServiceAssociationsInput{ServiceNetworkIdentifier: sn.Id}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(assocs).ToNot(BeEmpty()) + }).Should(Succeed()) + }) + + It("Gateway deletion is blocked while SN has active service associations", func() { + Expect(testFramework.Delete(ctx, gw)).To(Succeed()) + + // Gateway should still exist — finalizer can't be removed + Consistently(func(g Gomega) { + got := &gwv1.Gateway{} + err := testFramework.Get(ctx, types.NamespacedName{Name: gw.Name, Namespace: gw.Namespace}, got) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got.DeletionTimestamp.IsZero()).To(BeFalse()) + }, "30s", "5s").Should(Succeed()) + }) + + It("Gateway deletes after removing the service association", func() { + sn := testFramework.GetServiceNetwork(ctx, gw) + + // Remove associations + assocs, err := testFramework.LatticeClient.ListServiceNetworkServiceAssociationsAsList(ctx, + &vpclattice.ListServiceNetworkServiceAssociationsInput{ServiceNetworkIdentifier: sn.Id}) + Expect(err).ToNot(HaveOccurred()) + for _, a := range assocs { + _, err := testFramework.LatticeClient.DeleteServiceNetworkServiceAssociationWithContext(ctx, + &vpclattice.DeleteServiceNetworkServiceAssociationInput{ + ServiceNetworkServiceAssociationIdentifier: a.Id, + }) + Expect(err).ToNot(HaveOccurred()) + } + + // Wait for associations to be fully deleted, then delete dummy service + Eventually(func(g Gomega) { + _, err := testFramework.LatticeClient.DeleteServiceWithContext(ctx, &vpclattice.DeleteServiceInput{ + ServiceIdentifier: dummySvcId, + }) + g.Expect(err).ToNot(HaveOccurred()) + }).Should(Succeed()) + + // Gateway should now fully delete + testFramework.EventuallyExpectNotFound(ctx, gw) + + // SN should be gone + Eventually(func(g Gomega) { + list, err := testFramework.LatticeClient.ListServiceNetworksWithContext(ctx, &vpclattice.ListServiceNetworksInput{}) + g.Expect(err).ToNot(HaveOccurred()) + for _, s := range list.Items { + g.Expect(aws.StringValue(s.Name)).ToNot(Equal(gwName)) + } + }).Should(Succeed()) + }) + }) +})