Skip to content

Commit 475ed48

Browse files
authored
Prevent deletion of current droplet with foreign key (#3972)
Add foreign key from apps.droplet_guid to droplets.guid. MySQL only: ensure that the collation of the foreign key column (apps.droplet_guid) matches the referenced column (droplets.guid). Remove invalid current droplet relations (i.e. guid does not exist) before creating the foreign key constraint. Add tests for ForeignKeyConstraintViolation errors.
1 parent e07403a commit 475ed48

File tree

5 files changed

+139
-2
lines changed

5 files changed

+139
-2
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
def get_column_definition(table, column)
2+
fetch("SHOW FULL COLUMNS FROM `#{table}`;").all.find { |c| c[:Field] == column }
3+
end
4+
5+
def verify_no_other_modification(definition_before, definition_after)
6+
definition_after.each do |key, value|
7+
raise "Column definition '#{key}' was changed from '#{definition_before[key]}' to '#{value}'" unless value == definition_before[key]
8+
end
9+
end
10+
11+
Sequel.migration do
12+
up do
13+
if foreign_key_list(:apps).none? { |fk| fk[:name] == :fk_apps_droplet_guid }
14+
self[:apps].exclude(droplet_guid: self[:droplets].select(:guid)).exclude(droplet_guid: nil).update(droplet_guid: nil)
15+
16+
if database_type == :mysql
17+
# Ensure that the collation of the foreign key column (apps.droplet_guid) matches the referenced column (droplets.guid)
18+
guid_definition = get_column_definition('droplets', 'guid')
19+
droplet_guid_definition = get_column_definition('apps', 'droplet_guid')
20+
21+
unless droplet_guid_definition[:Collation] == guid_definition[:Collation]
22+
run("ALTER TABLE `apps` MODIFY COLUMN `droplet_guid` #{droplet_guid_definition[:Type]} COLLATE #{guid_definition[:Collation]};")
23+
24+
changed_droplet_guid_definition = get_column_definition('apps', 'droplet_guid')
25+
raise 'Collation was not changed!' unless changed_droplet_guid_definition[:Collation] == guid_definition[:Collation]
26+
27+
verify_no_other_modification(droplet_guid_definition.except(:Collation), changed_droplet_guid_definition.except(:Collation))
28+
end
29+
end
30+
31+
alter_table :apps do
32+
add_foreign_key [:droplet_guid], :droplets, key: :guid, name: :fk_apps_droplet_guid
33+
end
34+
end
35+
end
36+
37+
down do
38+
if foreign_key_list(:apps).any? { |fk| fk[:name] == :fk_apps_droplet_guid }
39+
alter_table :apps do
40+
drop_foreign_key [:droplet_guid], name: :fk_apps_droplet_guid
41+
end
42+
end
43+
end
44+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
require 'spec_helper'
2+
require 'migrations/helpers/migration_shared_context'
3+
4+
RSpec.describe "migration to add foreign key on column 'droplet_guid' in table 'apps'", isolation: :truncation, type: :migration do
5+
include_context 'migration' do
6+
let(:migration_filename) { '20240904132000_add_foreign_key_apps_droplet_guid.rb' }
7+
end
8+
9+
describe 'apps table' do
10+
after do
11+
db[:apps].delete
12+
end
13+
14+
context 'before adding the foreign key' do
15+
it 'allows inserts with a droplet_guid that does not exist' do
16+
expect { db[:apps].insert(guid: 'app_guid', droplet_guid: 'not_exists') }.not_to raise_error
17+
end
18+
end
19+
20+
context 'after adding the foreign key' do
21+
it 'prevents inserts with a droplet_guid that does not exist' do
22+
Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true)
23+
24+
expect { db[:apps].insert(guid: 'app_guid', droplet_guid: 'not_exists') }.to raise_error(Sequel::ForeignKeyConstraintViolation)
25+
end
26+
27+
it 'removed references to not existing droplets' do
28+
db[:droplets].insert(guid: 'droplet_guid', state: 'some_state')
29+
db[:apps].insert(guid: 'app_guid', droplet_guid: 'droplet_guid')
30+
db[:apps].insert(guid: 'another_app_guid', droplet_guid: 'not_exists')
31+
32+
Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true)
33+
34+
expect(db[:apps].where(guid: 'app_guid').get(:droplet_guid)).to eq('droplet_guid')
35+
expect(db[:apps].where(guid: 'another_app_guid').get(:droplet_guid)).to be_nil
36+
end
37+
end
38+
end
39+
end

spec/unit/actions/app_assign_droplet_spec.rb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ module VCAP::CloudController
2525
let(:message) { { 'droplet_guid' => droplet_guid } }
2626

2727
before do
28-
app_model.add_droplet_by_guid(droplet_guid)
2928
allow(ProcessCreateFromAppDroplet).to receive(:new).with(user_audit_info).and_return(process_create_from_app_droplet)
3029
allow(process_create_from_app_droplet).to receive(:create).with(app_model)
3130

@@ -86,6 +85,21 @@ module VCAP::CloudController
8685
end
8786
end
8887

88+
context 'when the droplet gets deleted in parallel (race condition)' do
89+
before do
90+
allow(app_model).to receive(:lock!).and_wrap_original do |original_method|
91+
droplet.destroy
92+
original_method.call
93+
end
94+
end
95+
96+
it 'raises an error' do
97+
expect do
98+
app_assign_droplet.assign(app_model, droplet)
99+
end.to raise_error AppAssignDroplet::InvalidDroplet, 'Unable to assign current droplet. Ensure the droplet exists and belongs to this app.'
100+
end
101+
end
102+
89103
context 'when we fail to create missing processes' do
90104
before do
91105
allow(process_create_from_app_droplet).to receive(:create).and_raise(ProcessCreateFromAppDroplet::ProcessTypesNotFound, 'some message')
@@ -98,7 +112,7 @@ module VCAP::CloudController
98112
end
99113
end
100114

101-
context 'when we fail to create missing processes' do
115+
context 'when we fail to create missing sidecars' do
102116
before do
103117
allow(process_create_from_app_droplet).to receive(:create).and_raise(SidecarSynchronizeFromAppDroplet::ConflictingSidecarsError, 'some message')
104118
end

spec/unit/actions/droplet_delete_spec.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,40 @@ module VCAP::CloudController
7171
expect(job.guid).not_to be_nil
7272
end
7373

74+
context 'when droplet is referenced as current droplet by an app' do
75+
before do
76+
droplet.app.update(droplet_guid: droplet.guid)
77+
end
78+
79+
it 'raises an UnprocessableEntity error' do
80+
expect do
81+
droplet_delete.delete([droplet])
82+
end.to raise_error do |error|
83+
expect(error).to be_a(CloudController::Errors::ApiError)
84+
expect(error.name).to eq('UnprocessableEntity')
85+
expect(error.message).to match(/^The droplet is currently used.*/)
86+
end
87+
end
88+
89+
it 'does not delete the droplet' do
90+
expect do
91+
expect { droplet_delete.delete([droplet]) }.to raise_error(CloudController::Errors::ApiError)
92+
end.not_to(change(DropletModel, :count))
93+
end
94+
95+
it 'does not create an audit event' do
96+
expect(Repositories::DropletEventRepository).not_to receive(:record_delete)
97+
98+
expect { droplet_delete.delete([droplet]) }.to raise_error(CloudController::Errors::ApiError)
99+
end
100+
101+
it 'does not schedule a blobstore delete job' do
102+
expect do
103+
expect { droplet_delete.delete([droplet]) }.to raise_error(CloudController::Errors::ApiError)
104+
end.not_to(change(Delayed::Job, :count))
105+
end
106+
end
107+
74108
context 'when the droplet does not have a blobstore key' do
75109
before do
76110
allow(droplet).to receive(:blobstore_key).and_return(nil)

spec/unit/models/runtime/app_model_spec.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,12 @@ module VCAP::CloudController
277277
app_model.droplet = droplet
278278
expect(app_model).to be_valid
279279
end
280+
281+
it 'raises a ValidationFailed error in case the given droplet_guid does not exist' do
282+
expect do
283+
app_model.update(droplet_guid: 'not-existing')
284+
end.to raise_error(Sequel::ValidationFailed, /droplet presence/)
285+
end
280286
end
281287
end
282288

0 commit comments

Comments
 (0)