diff --git a/internal/database/migrations/55_add_virtual_network_child_ref_triggers.up.sql b/internal/database/migrations/55_add_virtual_network_child_ref_triggers.up.sql new file mode 100644 index 000000000..5457e56ec --- /dev/null +++ b/internal/database/migrations/55_add_virtual_network_child_ref_triggers.up.sql @@ -0,0 +1,151 @@ +-- +-- Copyright (c) 2026 Red Hat Inc. +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +-- an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +-- specific language governing permissions and limitations under the License. +-- + +-- This migration adds triggers that enforce bidirectional referential integrity between virtual networks and their +-- child subnets and security groups: +-- +-- 1. A BEFORE UPDATE trigger on virtual_networks that prevents soft-deleting a virtual network while active subnets +-- or security groups still reference it. +-- +-- 2. BEFORE INSERT triggers on subnets and security_groups that prevent creating a child that references a virtual +-- network that does not exist or has been soft-deleted. Each trigger uses FOR SHARE to acquire a shared lock on +-- the virtual network row, which conflicts with the exclusive lock held by a concurrent virtual network +-- soft-delete. This bidirectional locking eliminates the TOCTOU race condition between virtual network deletion +-- and child creation. The triggers only fire on INSERT because virtual_network references are immutable +-- (enforced by the application). + +-- Indexes to speed up child reference lookups used by the delete trigger: +create index subnets_by_virtual_network on subnets ((data->'spec'->>'virtual_network')) + where deletion_timestamp = 'epoch'; + +create index security_groups_by_virtual_network on security_groups ((data->'spec'->>'virtual_network')) + where deletion_timestamp = 'epoch'; + +-- Trigger function that checks whether any active subnet or security group references the virtual network being deleted: +create function check_virtual_network_not_in_use() returns trigger as $$ +declare + subnet_count bigint; + sg_count bigint; +begin + select count(*) into subnet_count + from subnets + where deletion_timestamp = 'epoch' + and data->'spec'->>'virtual_network' = old.id; + + if subnet_count > 0 then + raise exception using + errcode = 'Z0003', + message = format( + 'cannot delete VirtualNetwork ''%s'': %s Subnet(s) still reference it', + old.id, subnet_count + ); + end if; + + select count(*) into sg_count + from security_groups + where deletion_timestamp = 'epoch' + and data->'spec'->>'virtual_network' = old.id; + + if sg_count > 0 then + raise exception using + errcode = 'Z0003', + message = format( + 'cannot delete VirtualNetwork ''%s'': %s SecurityGroup(s) still reference it', + old.id, sg_count + ); + end if; + + return new; +end; +$$ language plpgsql; + +-- Attach the trigger so that it only fires when the row transitions from active to soft-deleted: +create trigger check_virtual_network_not_in_use + before update on virtual_networks + for each row + when (old.deletion_timestamp = 'epoch' and new.deletion_timestamp != 'epoch') + execute function check_virtual_network_not_in_use(); + +-- Trigger function that validates the virtual_network reference in a subnet: +create function check_subnet_virtual_network_ref() returns trigger as $$ +declare + vn_id text; + found_id text; +begin + vn_id := new.data->'spec'->>'virtual_network'; + if coalesce(vn_id, '') = '' then + return new; + end if; + + select id into found_id + from virtual_networks + where id = vn_id + and deletion_timestamp = 'epoch' + for share; + + if found_id is null then + raise exception using + errcode = 'Z0002', + message = format( + 'VirtualNetwork ''%s'' does not exist or has been deleted', + vn_id + ); + end if; + + return new; +end; +$$ language plpgsql; + +-- Attach the trigger so that it fires on insert of active subnets: +create trigger check_subnet_virtual_network_ref + before insert on subnets + for each row + when (new.deletion_timestamp = 'epoch') + execute function check_subnet_virtual_network_ref(); + +-- Trigger function that validates the virtual_network reference in a security group: +create function check_security_group_virtual_network_ref() returns trigger as $$ +declare + vn_id text; + found_id text; +begin + vn_id := new.data->'spec'->>'virtual_network'; + if coalesce(vn_id, '') = '' then + return new; + end if; + + select id into found_id + from virtual_networks + where id = vn_id + and deletion_timestamp = 'epoch' + for share; + + if found_id is null then + raise exception using + errcode = 'Z0002', + message = format( + 'VirtualNetwork ''%s'' does not exist or has been deleted', + vn_id + ); + end if; + + return new; +end; +$$ language plpgsql; + +-- Attach the trigger so that it fires on insert of active security groups: +create trigger check_security_group_virtual_network_ref + before insert on security_groups + for each row + when (new.deletion_timestamp = 'epoch') + execute function check_security_group_virtual_network_ref(); diff --git a/internal/database/migrations/55_add_virtual_network_child_ref_triggers_test.go b/internal/database/migrations/55_add_virtual_network_child_ref_triggers_test.go new file mode 100644 index 000000000..757e84afd --- /dev/null +++ b/internal/database/migrations/55_add_virtual_network_child_ref_triggers_test.go @@ -0,0 +1,337 @@ +/* +Copyright (c) 2026 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package migrations + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgconn" + . "github.com/onsi/ginkgo/v2/dsl/core" + . "github.com/onsi/gomega" +) + +var _ = DescribeMigration("Add virtual network child ref triggers", func() { + insertTenant := func(ctx context.Context) { + _, err := conn.Exec(ctx, + `insert into organizations (id, name, tenant, creator, data) + values ('test-tenant', 'test-tenant', 'system', 'system', '{}') + on conflict do nothing`) + Expect(err).ToNot(HaveOccurred()) + } + + It("Creates the 'check_virtual_network_not_in_use' function", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + + var count int + err = conn.QueryRow(ctx, ` + select + count(*) + from + information_schema.routines + where + routine_name = 'check_virtual_network_not_in_use' and + routine_type = 'FUNCTION' + `).Scan(&count) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(1)) + }) + + It("Adds a delete trigger to the virtual_networks table", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + + var count int + err = conn.QueryRow(ctx, ` + select + count(*) + from + information_schema.triggers + where + trigger_name = 'check_virtual_network_not_in_use' and + event_object_table = 'virtual_networks' and + action_timing = 'BEFORE' and + event_manipulation = 'UPDATE' + `).Scan(&count) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(1)) + }) + + It("Creates indexes for child virtual_network lookups", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + + type idxExpectation struct { + name string + table string + } + for _, tc := range []idxExpectation{ + {name: "subnets_by_virtual_network", table: "subnets"}, + {name: "security_groups_by_virtual_network", table: "security_groups"}, + } { + var indexDef string + err = conn.QueryRow(ctx, ` + select indexdef + from pg_indexes + where indexname = $1 + and tablename = $2 + `, tc.name, tc.table).Scan(&indexDef) + Expect(err).ToNot(HaveOccurred()) + Expect(indexDef).To(ContainSubstring("(data -> 'spec'::text) ->> 'virtual_network'::text")) + Expect(indexDef).To(ContainSubstring("WHERE (deletion_timestamp = '1970-01-01 00:00:00+00'::timestamp with time zone)")) + } + }) + + It("Prevents soft-deleting a virtual network referenced by a subnet", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + insertTenant(ctx) + + _, err = conn.Exec(ctx, + `insert into virtual_networks (id, tenant, data) + values ('vn-1', 'test-tenant', '{}')`) + Expect(err).ToNot(HaveOccurred()) + + _, err = conn.Exec(ctx, + `insert into subnets (id, tenant, data) + values ('subnet-1', 'test-tenant', $1::jsonb)`, + `{"spec":{"virtual_network":"vn-1"}}`) + Expect(err).ToNot(HaveOccurred()) + + _, err = conn.Exec(ctx, + `update virtual_networks set deletion_timestamp = now() where id = 'vn-1'`) + Expect(err).To(HaveOccurred()) + var pgErr *pgconn.PgError + Expect(errors.As(err, &pgErr)).To(BeTrue()) + Expect(pgErr.Code).To(Equal("Z0003")) + Expect(pgErr.Message).To(ContainSubstring("vn-1")) + Expect(pgErr.Message).To(ContainSubstring("Subnet")) + }) + + It("Prevents soft-deleting a virtual network referenced by a security group", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + insertTenant(ctx) + + _, err = conn.Exec(ctx, + `insert into virtual_networks (id, tenant, data) + values ('vn-2', 'test-tenant', '{}')`) + Expect(err).ToNot(HaveOccurred()) + + _, err = conn.Exec(ctx, + `insert into security_groups (id, tenant, data) + values ('sg-1', 'test-tenant', $1::jsonb)`, + `{"spec":{"virtual_network":"vn-2"}}`) + Expect(err).ToNot(HaveOccurred()) + + _, err = conn.Exec(ctx, + `update virtual_networks set deletion_timestamp = now() where id = 'vn-2'`) + Expect(err).To(HaveOccurred()) + var pgErr *pgconn.PgError + Expect(errors.As(err, &pgErr)).To(BeTrue()) + Expect(pgErr.Code).To(Equal("Z0003")) + Expect(pgErr.Message).To(ContainSubstring("vn-2")) + Expect(pgErr.Message).To(ContainSubstring("SecurityGroup")) + }) + + It("Reports the count of referencing subnets", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + insertTenant(ctx) + + _, err = conn.Exec(ctx, + `insert into virtual_networks (id, tenant, data) + values ('vn-3', 'test-tenant', '{}')`) + Expect(err).ToNot(HaveOccurred()) + + for i := range 3 { + _, err = conn.Exec(ctx, + `insert into subnets (id, tenant, data) + values ($1, 'test-tenant', $2::jsonb)`, + fmt.Sprintf("subnet-%d", i), + fmt.Sprintf(`{"spec":{"virtual_network":"vn-3","ipv4_cidr":"10.0.%d.0/24"}}`, i)) + Expect(err).ToNot(HaveOccurred()) + } + + _, err = conn.Exec(ctx, + `update virtual_networks set deletion_timestamp = now() where id = 'vn-3'`) + Expect(err).To(HaveOccurred()) + var pgErr *pgconn.PgError + Expect(errors.As(err, &pgErr)).To(BeTrue()) + Expect(pgErr.Code).To(Equal("Z0003")) + Expect(pgErr.Message).To(ContainSubstring("3 Subnet")) + }) + + It("Allows soft-deleting a virtual network with no child references", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + insertTenant(ctx) + + _, err = conn.Exec(ctx, + `insert into virtual_networks (id, tenant, data) + values ('vn-4', 'test-tenant', '{}')`) + Expect(err).ToNot(HaveOccurred()) + + _, err = conn.Exec(ctx, + `update virtual_networks set deletion_timestamp = now() where id = 'vn-4'`) + Expect(err).ToNot(HaveOccurred()) + }) + + It("Allows soft-deleting a virtual network when child subnets are already deleted", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + insertTenant(ctx) + + _, err = conn.Exec(ctx, + `insert into virtual_networks (id, tenant, data) + values ('vn-5', 'test-tenant', '{}')`) + Expect(err).ToNot(HaveOccurred()) + + _, err = conn.Exec(ctx, + `insert into subnets (id, tenant, data) + values ('subnet-del', 'test-tenant', $1::jsonb)`, + `{"spec":{"virtual_network":"vn-5"}}`) + Expect(err).ToNot(HaveOccurred()) + + _, err = conn.Exec(ctx, + `update subnets set deletion_timestamp = now() where id = 'subnet-del'`) + Expect(err).ToNot(HaveOccurred()) + + _, err = conn.Exec(ctx, + `update virtual_networks set deletion_timestamp = now() where id = 'vn-5'`) + Expect(err).ToNot(HaveOccurred()) + }) + + It("Creates the subnet virtual_network insert trigger", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + + var count int + err = conn.QueryRow(ctx, ` + select + count(*) + from + information_schema.triggers + where + trigger_name = 'check_subnet_virtual_network_ref' and + event_object_table = 'subnets' and + action_timing = 'BEFORE' and + event_manipulation = 'INSERT' + `).Scan(&count) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(1)) + }) + + It("Prevents creating a subnet referencing a non-existent virtual network", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + insertTenant(ctx) + + _, err = conn.Exec(ctx, + `insert into subnets (id, tenant, data) + values ('subnet-ref', 'test-tenant', $1::jsonb)`, + `{"spec":{"virtual_network":"no-such-vn"}}`) + Expect(err).To(HaveOccurred()) + var pgErr *pgconn.PgError + Expect(errors.As(err, &pgErr)).To(BeTrue()) + Expect(pgErr.Code).To(Equal("Z0002")) + Expect(pgErr.Message).To(ContainSubstring("no-such-vn")) + }) + + It("Prevents creating a subnet referencing a soft-deleted virtual network", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + insertTenant(ctx) + + _, err = conn.Exec(ctx, + `insert into virtual_networks (id, tenant, data) + values ('vn-deleted', 'test-tenant', '{}')`) + Expect(err).ToNot(HaveOccurred()) + _, err = conn.Exec(ctx, + `update virtual_networks set deletion_timestamp = now() where id = 'vn-deleted'`) + Expect(err).ToNot(HaveOccurred()) + + _, err = conn.Exec(ctx, + `insert into subnets (id, tenant, data) + values ('subnet-ref-2', 'test-tenant', $1::jsonb)`, + `{"spec":{"virtual_network":"vn-deleted"}}`) + Expect(err).To(HaveOccurred()) + var pgErr *pgconn.PgError + Expect(errors.As(err, &pgErr)).To(BeTrue()) + Expect(pgErr.Code).To(Equal("Z0002")) + Expect(pgErr.Message).To(ContainSubstring("vn-deleted")) + }) + + It("Creates the security group virtual_network insert trigger", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + + var count int + err = conn.QueryRow(ctx, ` + select + count(*) + from + information_schema.triggers + where + trigger_name = 'check_security_group_virtual_network_ref' and + event_object_table = 'security_groups' and + action_timing = 'BEFORE' and + event_manipulation = 'INSERT' + `).Scan(&count) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(1)) + }) + + It("Prevents creating a security group referencing a non-existent virtual network", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + insertTenant(ctx) + + _, err = conn.Exec(ctx, + `insert into security_groups (id, tenant, data) + values ('sg-ref-missing', 'test-tenant', $1::jsonb)`, + `{"spec":{"virtual_network":"no-such-vn"}}`) + Expect(err).To(HaveOccurred()) + var pgErr *pgconn.PgError + Expect(errors.As(err, &pgErr)).To(BeTrue()) + Expect(pgErr.Code).To(Equal("Z0002")) + Expect(pgErr.Message).To(ContainSubstring("no-such-vn")) + }) + + It("Prevents creating a security group referencing a soft-deleted virtual network", func(ctx context.Context) { + err := tool.Migrate(ctx, 55) + Expect(err).ToNot(HaveOccurred()) + insertTenant(ctx) + + _, err = conn.Exec(ctx, + `insert into virtual_networks (id, tenant, data) + values ('vn-sg-deleted', 'test-tenant', '{}')`) + Expect(err).ToNot(HaveOccurred()) + _, err = conn.Exec(ctx, + `update virtual_networks set deletion_timestamp = now() where id = 'vn-sg-deleted'`) + Expect(err).ToNot(HaveOccurred()) + + _, err = conn.Exec(ctx, + `insert into security_groups (id, tenant, data) + values ('sg-ref', 'test-tenant', $1::jsonb)`, + `{"spec":{"virtual_network":"vn-sg-deleted"}}`) + Expect(err).To(HaveOccurred()) + var pgErr *pgconn.PgError + Expect(errors.As(err, &pgErr)).To(BeTrue()) + Expect(pgErr.Code).To(Equal("Z0002")) + Expect(pgErr.Message).To(ContainSubstring("vn-sg-deleted")) + }) +}) diff --git a/internal/servers/compute_instances_server_test.go b/internal/servers/compute_instances_server_test.go index 66708d64f..6f3eb1ad7 100644 --- a/internal/servers/compute_instances_server_test.go +++ b/internal/servers/compute_instances_server_test.go @@ -87,7 +87,23 @@ var _ = Describe("Compute instances server", func() { Build() Expect(err).ToNot(HaveOccurred()) - // Create a test subnet for all tests to use: + // Create a test virtual network and subnet for all tests to use: + vnDao, err := dao.NewGenericDAO[*privatev1.VirtualNetwork](). + SetLogger(logger). + SetTenancyLogic(tenancy). + Build() + Expect(err).ToNot(HaveOccurred()) + + vn := privatev1.VirtualNetwork_builder{ + Id: "test-vnet", + Metadata: privatev1.Metadata_builder{ + Tenant: auth.SharedTenant, + }.Build(), + }.Build() + + _, err = vnDao.Create().SetObject(vn).Do(ctx) + Expect(err).ToNot(HaveOccurred()) + subnetsDao, err := dao.NewGenericDAO[*privatev1.Subnet](). SetLogger(logger). SetTenancyLogic(tenancy). diff --git a/internal/servers/private_compute_instances_server_test.go b/internal/servers/private_compute_instances_server_test.go index d3c09a046..811775328 100644 --- a/internal/servers/private_compute_instances_server_test.go +++ b/internal/servers/private_compute_instances_server_test.go @@ -36,7 +36,23 @@ var _ = Describe("Private compute instances server", func() { BeforeEach(func() { var err error - // Create a default test subnet for tests that don't explicitly create one: + // Create a default test virtual network and subnet for tests that don't explicitly create one: + vnDao, err := dao.NewGenericDAO[*privatev1.VirtualNetwork](). + SetLogger(logger). + SetTenancyLogic(tenancy). + Build() + Expect(err).ToNot(HaveOccurred()) + + vn := privatev1.VirtualNetwork_builder{ + Id: "test-vnet", + Metadata: privatev1.Metadata_builder{ + Tenant: auth.SharedTenant, + }.Build(), + }.Build() + + _, err = vnDao.Create().SetObject(vn).Do(ctx) + Expect(err).ToNot(HaveOccurred()) + subnetsDao, err := dao.NewGenericDAO[*privatev1.Subnet](). SetLogger(logger). SetTenancyLogic(tenancy). diff --git a/internal/servers/private_virtual_networks_server_test.go b/internal/servers/private_virtual_networks_server_test.go index d54b35d2a..bbefee118 100644 --- a/internal/servers/private_virtual_networks_server_test.go +++ b/internal/servers/private_virtual_networks_server_test.go @@ -1363,4 +1363,214 @@ var _ = Describe("Private virtual networks server", func() { Expect(err.Error()).To(ContainSubstring("immutable")) }) }) + + Describe("Deletion validation", func() { + var ( + vnServer *PrivateVirtualNetworksServer + subnetDao *dao.GenericDAO[*privatev1.Subnet] + sgDao *dao.GenericDAO[*privatev1.SecurityGroup] + ) + + BeforeEach(func() { + var err error + vnServer, err = NewPrivateVirtualNetworksServer(). + SetLogger(logger). + SetAttributionLogic(attribution). + SetTenancyLogic(tenancy). + Build() + Expect(err).ToNot(HaveOccurred()) + + subnetDao, err = dao.NewGenericDAO[*privatev1.Subnet](). + SetLogger(logger). + SetTenancyLogic(tenancy). + Build() + Expect(err).ToNot(HaveOccurred()) + + sgDao, err = dao.NewGenericDAO[*privatev1.SecurityGroup](). + SetLogger(logger). + SetTenancyLogic(tenancy). + Build() + Expect(err).ToNot(HaveOccurred()) + }) + + createVirtualNetwork := func() *privatev1.VirtualNetwork { + nc := createNetworkClass(ctx, privatev1.NetworkClassState_NETWORK_CLASS_STATE_READY) + createResp, err := vnServer.Create(ctx, privatev1.VirtualNetworksCreateRequest_builder{ + Object: privatev1.VirtualNetwork_builder{ + Metadata: privatev1.Metadata_builder{ + Tenant: auth.SharedTenant, + }.Build(), + Spec: privatev1.VirtualNetworkSpec_builder{ + Ipv4Cidr: proto.String("10.0.0.0/16"), + NetworkClass: nc.GetId(), + Region: "us-west-1", + }.Build(), + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + return createResp.GetObject() + } + + setVirtualNetworkReady := func(vn *privatev1.VirtualNetwork) { + vnDao, err := dao.NewGenericDAO[*privatev1.VirtualNetwork](). + SetLogger(logger). + SetTenancyLogic(tenancy). + Build() + Expect(err).ToNot(HaveOccurred()) + vn.SetStatus(privatev1.VirtualNetworkStatus_builder{ + State: privatev1.VirtualNetworkState_VIRTUAL_NETWORK_STATE_READY, + }.Build()) + _, err = vnDao.Update().SetObject(vn).Do(ctx) + Expect(err).ToNot(HaveOccurred()) + } + + It("allows deletion when no child resources exist", func() { + vn := createVirtualNetwork() + + _, err := vnServer.Delete(ctx, privatev1.VirtualNetworksDeleteRequest_builder{ + Id: vn.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + It("blocks deletion when a Subnet references the VirtualNetwork", func() { + vn := createVirtualNetwork() + setVirtualNetworkReady(vn) + + subnet := privatev1.Subnet_builder{ + Metadata: privatev1.Metadata_builder{ + Tenant: auth.SharedTenant, + }.Build(), + Spec: privatev1.SubnetSpec_builder{ + VirtualNetwork: vn.GetId(), + Ipv4Cidr: proto.String("10.0.1.0/24"), + }.Build(), + }.Build() + _, err := subnetDao.Create().SetObject(subnet).Do(ctx) + Expect(err).ToNot(HaveOccurred()) + + _, err = vnServer.Delete(ctx, privatev1.VirtualNetworksDeleteRequest_builder{ + Id: vn.GetId(), + }.Build()) + Expect(err).To(HaveOccurred()) + status, ok := grpcstatus.FromError(err) + Expect(ok).To(BeTrue()) + Expect(status.Code()).To(Equal(grpccodes.FailedPrecondition)) + Expect(err.Error()).To(ContainSubstring("Subnet")) + Expect(err.Error()).To(ContainSubstring(vn.GetId())) + }) + + It("blocks deletion when a SecurityGroup references the VirtualNetwork", func() { + vn := createVirtualNetwork() + + sg := privatev1.SecurityGroup_builder{ + Metadata: privatev1.Metadata_builder{ + Tenant: auth.SharedTenant, + }.Build(), + Spec: privatev1.SecurityGroupSpec_builder{ + VirtualNetwork: vn.GetId(), + }.Build(), + }.Build() + _, err := sgDao.Create().SetObject(sg).Do(ctx) + Expect(err).ToNot(HaveOccurred()) + + _, err = vnServer.Delete(ctx, privatev1.VirtualNetworksDeleteRequest_builder{ + Id: vn.GetId(), + }.Build()) + Expect(err).To(HaveOccurred()) + status, ok := grpcstatus.FromError(err) + Expect(ok).To(BeTrue()) + Expect(status.Code()).To(Equal(grpccodes.FailedPrecondition)) + Expect(err.Error()).To(ContainSubstring("SecurityGroup")) + Expect(err.Error()).To(ContainSubstring(vn.GetId())) + }) + + It("blocks deletion when both Subnets and SecurityGroups reference the VirtualNetwork", func() { + vn := createVirtualNetwork() + setVirtualNetworkReady(vn) + + subnet := privatev1.Subnet_builder{ + Metadata: privatev1.Metadata_builder{ + Tenant: auth.SharedTenant, + }.Build(), + Spec: privatev1.SubnetSpec_builder{ + VirtualNetwork: vn.GetId(), + Ipv4Cidr: proto.String("10.0.1.0/24"), + }.Build(), + }.Build() + _, err := subnetDao.Create().SetObject(subnet).Do(ctx) + Expect(err).ToNot(HaveOccurred()) + + sg := privatev1.SecurityGroup_builder{ + Metadata: privatev1.Metadata_builder{ + Tenant: auth.SharedTenant, + }.Build(), + Spec: privatev1.SecurityGroupSpec_builder{ + VirtualNetwork: vn.GetId(), + }.Build(), + }.Build() + _, err = sgDao.Create().SetObject(sg).Do(ctx) + Expect(err).ToNot(HaveOccurred()) + + _, err = vnServer.Delete(ctx, privatev1.VirtualNetworksDeleteRequest_builder{ + Id: vn.GetId(), + }.Build()) + Expect(err).To(HaveOccurred()) + status, ok := grpcstatus.FromError(err) + Expect(ok).To(BeTrue()) + Expect(status.Code()).To(Equal(grpccodes.FailedPrecondition)) + Expect(err.Error()).To(ContainSubstring("Subnet")) + }) + + It("allows deletion after all child resources are removed", func() { + vn := createVirtualNetwork() + + sg := privatev1.SecurityGroup_builder{ + Metadata: privatev1.Metadata_builder{ + Tenant: auth.SharedTenant, + }.Build(), + Spec: privatev1.SecurityGroupSpec_builder{ + VirtualNetwork: vn.GetId(), + }.Build(), + }.Build() + sgResp, err := sgDao.Create().SetObject(sg).Do(ctx) + Expect(err).ToNot(HaveOccurred()) + + _, err = sgDao.Delete().SetId(sgResp.GetObject().GetId()).Do(ctx) + Expect(err).ToNot(HaveOccurred()) + + _, err = vnServer.Delete(ctx, privatev1.VirtualNetworksDeleteRequest_builder{ + Id: vn.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + It("reports the correct count of referencing Subnets", func() { + vn := createVirtualNetwork() + setVirtualNetworkReady(vn) + + for i := range 3 { + subnet := privatev1.Subnet_builder{ + Metadata: privatev1.Metadata_builder{ + Tenant: auth.SharedTenant, + }.Build(), + Spec: privatev1.SubnetSpec_builder{ + VirtualNetwork: vn.GetId(), + Ipv4Cidr: proto.String(fmt.Sprintf("10.0.%d.0/24", i)), + }.Build(), + }.Build() + _, err := subnetDao.Create().SetObject(subnet).Do(ctx) + Expect(err).ToNot(HaveOccurred()) + } + + _, err := vnServer.Delete(ctx, privatev1.VirtualNetworksDeleteRequest_builder{ + Id: vn.GetId(), + }.Build()) + Expect(err).To(HaveOccurred()) + status, ok := grpcstatus.FromError(err) + Expect(ok).To(BeTrue()) + Expect(status.Code()).To(Equal(grpccodes.FailedPrecondition)) + Expect(err.Error()).To(ContainSubstring("3 Subnet")) + }) + }) })