Skip to content

Commit 913eb59

Browse files
authored
fix: track created disks in the state file so an interrupt cannot orphan them (#160)
Standalone disks were recorded in an instance variable, and the cleanup that deletes them hangs off `rescue => e`, which catches StandardError. Interrupt is not a StandardError, so Ctrl-C during a create -- the most common way a create ends early -- skipped cleanup entirely and left a billable disk behind with no record of it anywhere: disks: tk-datadisk-...-extra-disk 20GB (no users) state: --- {} kitchen destroy -> Finished destroying (0m0.00s) Record the disks in the Test Kitchen state file instead, which is written whatever happens, and have `destroy` delete anything still recorded once the instance is gone. This mirrors what the instance itself already does. Two supporting changes are needed to make that reachable: - Pin the zone before any billable resource exists. In region mode it is chosen at random, so cleanup would not otherwise know where to look. - Record the server name before the insert request is issued rather than after it returns. Live testing turned up a case where GCE created the instance but the interrupted request never returned, so the state file had the disk but not the instance holding it, and cleanup failed with resourceInUseByAnotherResource. Confirmed against a real project: an interrupted create is now fully reclaimed by `kitchen destroy`, leaving no instances and no disks.
1 parent d353a02 commit 913eb59

2 files changed

Lines changed: 117 additions & 27 deletions

File tree

lib/kitchen/driver/gce.rb

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -197,16 +197,22 @@ def create(state)
197197

198198
server_name = generate_server_name
199199

200+
# Pin the zone before anything billable exists. In region mode it is
201+
# picked at random, so cleanup would otherwise have no way to know
202+
# where to look for the disks created below.
203+
state[:zone] = zone
204+
205+
# Record the name before the request goes out, not after it returns.
206+
# GCE starts billing as soon as the insert is accepted, and it may
207+
# accept one whose response never reaches us -- an interrupted or
208+
# timed-out request can leave an instance running that the state file
209+
# knows nothing about, holding disks that cleanup then cannot delete.
210+
# `destroy` copes with a name that never became an instance.
211+
state[:server_name] = server_name
212+
200213
info("Creating GCE instance <#{server_name}> in project #{project}, zone #{zone}...")
201214
operation = connection.insert_instance(project, zone, create_instance_object(server_name))
202215

203-
# GCE starts billing for the instance as soon as the insert is
204-
# accepted, so record it before waiting on the operation. Anything that
205-
# goes wrong from here on can then be torn down by the rescue below,
206-
# and by `kitchen destroy` if the process does not survive to run it.
207-
state[:server_name] = server_name
208-
state[:zone] = zone
209-
210216
wait_for_operation(operation)
211217

212218
state[:hostname] = ip_address_for(server_instance(server_name))
@@ -231,30 +237,38 @@ def create(state)
231237
raise
232238
end
233239

234-
# Destroys the GCE instance recorded in the state file.
240+
# Destroys the GCE instance recorded in the state file, together with any
241+
# standalone disks an earlier create left behind.
242+
#
243+
# An instance that no longer exists in GCE is treated as already
244+
# destroyed, but the state file is still cleared: a create that fails
245+
# after `insert_instance` records a server that may never have come into
246+
# being, and leaving the name behind would make {#create}'s idempotency
247+
# guard skip every subsequent retry.
235248
#
236-
# Does nothing when the state file records no server. An instance that no
237-
# longer exists in GCE is treated as already destroyed, but the state file
238-
# is still cleared: a create that fails after `insert_instance` records a
239-
# server that may never have come into being, and leaving the name behind
240-
# would make {#create}'s idempotency guard skip every subsequent retry.
249+
# Disks are deleted after the instance, which holds them until it is
250+
# gone. A create interrupted before it reached `insert_instance` records
251+
# no server at all, so they are cleaned up whether or not there is one.
241252
#
242253
# @param state [Hash] the Test Kitchen state hash, mutated in place to
243-
# remove `:server_name`, `:hostname` and `:zone`
254+
# remove `:server_name`, `:hostname`, `:zone` and `:created_disks`
244255
# @return [void]
245256
def destroy(state)
246257
@state = state
247258
server_name = state[:server_name]
248-
return if server_name.nil?
249259

250-
if server_exist?(server_name)
251-
info("Destroying GCE instance <#{server_name}>...")
252-
wait_for_operation(connection.delete_instance(project, zone, server_name))
253-
info("GCE instance <#{server_name}> destroyed.")
254-
else
255-
info("GCE instance <#{server_name}> does not exist - assuming it has been already destroyed.")
260+
unless server_name.nil?
261+
if server_exist?(server_name)
262+
info("Destroying GCE instance <#{server_name}>...")
263+
wait_for_operation(connection.delete_instance(project, zone, server_name))
264+
info("GCE instance <#{server_name}> destroyed.")
265+
else
266+
info("GCE instance <#{server_name}> does not exist - assuming it has been already destroyed.")
267+
end
256268
end
257269

270+
delete_created_disks
271+
258272
state.delete(:server_name)
259273
state.delete(:hostname)
260274
state.delete(:zone)
@@ -1041,21 +1055,28 @@ def create_attached_disk(unique_disk_name, disk_config)
10411055
attached_disk
10421056
end
10431057

1044-
# Names of the standalone disks this driver created during the current
1045-
# action, tracked so they can be cleaned up if creation fails.
1058+
# Names of the standalone disks this driver has created, held in the Test
1059+
# Kitchen state file rather than in memory.
1060+
#
1061+
# These disks bill from the moment GCE creates them, which is before the
1062+
# instance exists, and the driver's own cleanup cannot be relied on to
1063+
# remove them: `Interrupt` is not a `StandardError`, so pressing Ctrl-C
1064+
# during a create skips the rescue entirely. Test Kitchen writes the
1065+
# state file whatever happens, so recording the names there is what makes
1066+
# a later `kitchen destroy` able to find them.
10461067
#
10471068
# @return [Array<String>] the created disk names
10481069
def created_disk_names
1049-
@created_disk_names ||= []
1070+
state[:created_disks] ||= []
10501071
end
10511072

1052-
# Deletes every standalone disk created during a failed create, so a
1053-
# partial run does not leave billable disks behind.
1073+
# Deletes every standalone disk this driver created, so a partial run
1074+
# does not leave billable disks behind.
10541075
#
10551076
# @return [void]
10561077
def delete_created_disks
10571078
created_disk_names.each { |disk_name| delete_disk(disk_name) }
1058-
created_disk_names.clear
1079+
state.delete(:created_disks)
10591080
end
10601081

10611082
# Deletes a standalone persistent disk, tolerating one that is already

spec/kitchen/driver/gce/disk_build_spec.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,75 @@ def built_disks
345345
end
346346
end
347347

348+
# An interrupted `kitchen create` never reaches the driver's own cleanup:
349+
# Interrupt is not a StandardError, so the rescue does not run. The only
350+
# thing that survives is the state file, so anything already billable has to
351+
# be recorded there rather than in memory.
352+
describe "recording standalone disks in the state file" do
353+
let(:driver_config) do
354+
{ disks: { boot: { boot: true }, data: { disk_size: 50 } } }
355+
end
356+
357+
it "records the zone before creating anything billable" do
358+
allow_successful_create
359+
allow(compute).to receive(:insert_disk).and_raise(Interrupt)
360+
state = {}
361+
362+
expect { driver.create(state) }.to raise_error(Interrupt)
363+
364+
expect(state[:zone]).to eq("test-zone-1a")
365+
end
366+
367+
it "records a standalone disk as soon as GCE has created it" do
368+
allow_successful_create
369+
driver.state = state = {}
370+
371+
driver.create_attached_disk("tk-test-1-data", disk_size: 50)
372+
373+
expect(state[:created_disks]).to eq(["tk-test-1-data"])
374+
end
375+
376+
# GCE may well create the instance even when the response never reaches
377+
# us, so the name has to be recorded before the request goes out. Without
378+
# it, cleanup finds a disk it cannot delete because an instance it does not
379+
# know about is still holding it.
380+
it "records the server name before the insert request is issued" do
381+
allow_successful_create
382+
allow(driver).to receive(:generate_server_name).and_return("tk-test-1")
383+
allow(compute).to receive(:insert_instance).and_raise(Interrupt)
384+
state = {}
385+
386+
expect { driver.create(state) }.to raise_error(Interrupt)
387+
388+
expect(state[:server_name]).to eq("tk-test-1")
389+
end
390+
391+
it "leaves an interrupted create's disk recorded for a later destroy to find" do
392+
allow_successful_create
393+
allow(driver).to receive(:generate_server_name).and_return("tk-test-1")
394+
allow(compute).to receive(:insert_instance).and_raise(Interrupt)
395+
state = {}
396+
397+
expect { driver.create(state) }.to raise_error(Interrupt)
398+
399+
expect(state[:created_disks]).to eq(["tk-test-1-data"])
400+
end
401+
402+
it "deletes disks an interrupted create left behind, even with no server recorded" do
403+
allow(compute).to receive(:get_disk).and_return(ComputeApi.disk)
404+
allow(compute).to receive(:get_zone_operation).and_return(ComputeApi.operation)
405+
state = { zone: "test-zone-1a", created_disks: ["tk-test-1-data"] }
406+
407+
expect(compute).to receive(:delete_disk)
408+
.with("test-project", "test-zone-1a", "tk-test-1-data")
409+
.and_return(ComputeApi.operation)
410+
411+
driver.destroy(state)
412+
413+
expect(state).to be_empty
414+
end
415+
end
416+
348417
describe "#delete_created_disks" do
349418
it "does nothing when no standalone disks were created" do
350419
expect(compute).not_to receive(:delete_disk)

0 commit comments

Comments
 (0)