Skip to content

Commit 192b716

Browse files
tas50claude
andauthored
feat: report live instance status to Test Kitchen 4 (#167)
Test Kitchen 4 added a `status(state)` hook on Kitchen::Driver::Base for `kitchen list --live` and the `kitchen status` alias. Drivers that do not implement it inherit "unknown", which is what this one reported. Ask GCE instead. The state file records only what the last action did, and that goes stale in both directions: a preemptible instance GCE has reclaimed still reads as "Created", and an instance deleted outside Test Kitchen leaves behind a server name that no longer resolves. Both now show up immediately, which matters here because the README recommends preemptible instances to keep costs down. Reports GCE's own state -- running, terminated, provisioning, suspended -- plus "not created" when the state file names no server and "not found" when it names one that is gone, with a message saying so. Earlier Test Kitchen versions never call the method, so this is safe across the supported >= 3.0 range; the suite passes against 3.0.0 as well. Declares the `time` gem, which is no longer a default gem on Ruby 4.0 and which Test Kitchen requires without declaring. Confirmed against a real project through all four states: not created, running, terminated after a stop, and not found after an out-of-band delete. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1e139db commit 192b716

4 files changed

Lines changed: 158 additions & 0 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,29 @@ cinc kitchen verify # run your tests
133133
cinc kitchen destroy # delete the instance
134134
```
135135

136+
### Checking whether an instance is still there
137+
138+
`kitchen list` reports what the last action did, which is not the same as what
139+
GCE currently holds. On Test Kitchen 4 or later, `--live` asks GCE directly:
140+
141+
```sh
142+
cinc kitchen list --live
143+
```
144+
145+
```text
146+
Instance Driver ... Last Action Live Status
147+
baseline-ubuntu-2204 Google Compute (GCE) ... Created terminated
148+
```
149+
150+
The reported state is GCE's own — `running`, `terminated`, `provisioning`,
151+
`suspended` and so on — plus `not created` when nothing has been launched and
152+
`not found` when the state file names an instance that no longer exists.
153+
154+
This is worth knowing about if you use `preemptible: true`, since GCE can
155+
reclaim the instance at any time and Test Kitchen's own record will still say
156+
`Created`. `kitchen list --live --json` includes the instance name and the time
157+
of the check.
158+
136159
## Configuration
137160

138161
All options below are set under the `driver:` key in `kitchen.yml`.

kitchen-google.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Gem::Specification.new do |s|
2323
s.add_dependency "date", ">= 3.2"
2424
s.add_dependency "json", ">= 2.5"
2525
s.add_dependency "securerandom", ">= 0.1"
26+
s.add_dependency "time", ">= 0.1"
2627
s.add_dependency "timeout", ">= 0.2"
2728

2829
s.required_ruby_version = ">= 3.1"

lib/kitchen/driver/gce.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
require_relative "gce_version"
2222
require_relative "gce/windows_password"
2323
require "securerandom" unless defined?(SecureRandom)
24+
require "time" unless defined?(Time.iso8601)
2425
require "timeout" unless defined?(Timeout)
2526

2627
module Kitchen
@@ -259,6 +260,64 @@ def destroy(state)
259260
state.delete(:zone)
260261
end
261262

263+
# Reports whether the instance recorded in the state file is actually
264+
# running, by asking GCE rather than trusting the state file.
265+
#
266+
# Test Kitchen 4 calls this for `kitchen list --live` and the `kitchen
267+
# status` alias. Earlier versions never call it, so implementing it is
268+
# safe across the whole supported range.
269+
#
270+
# The state file records only what the last action did, which goes stale
271+
# in both directions: a preemptible instance GCE has reclaimed still
272+
# reads as "Created", and an instance deleted outside Test Kitchen leaves
273+
# a server name behind that no longer resolves.
274+
#
275+
# @param state [Hash] the Test Kitchen state hash
276+
# @return [Hash] the status, in the shape `Kitchen::Instance` expects
277+
def status(state)
278+
@state = state
279+
server_name = state[:server_name]
280+
281+
return status_report(live: false, state_name: "not created") if server_name.nil?
282+
283+
gce_status = server_instance(server_name).status.to_s.downcase
284+
285+
status_report(
286+
live: gce_status == "running",
287+
state_name: gce_status,
288+
resource_id: server_name
289+
)
290+
rescue Google::Apis::ClientError => e
291+
debug("API error: #{e.message}")
292+
293+
status_report(
294+
live: false,
295+
state_name: "not found",
296+
resource_id: server_name,
297+
message: "Instance #{server_name} is recorded in the state file but no longer exists " \
298+
"in project #{project}, zone #{zone}."
299+
)
300+
end
301+
302+
# Builds a status hash in the shape `Kitchen::Instance` expects.
303+
#
304+
# @param live [Boolean] whether the instance is up and usable
305+
# @param state_name [String] the state to display
306+
# @param resource_id [String, nil] the GCE instance name
307+
# @param message [String, nil] anything the user should know
308+
# @return [Hash] the status
309+
# @api private
310+
def status_report(live:, state_name:, resource_id: nil, message: nil)
311+
{
312+
live: live,
313+
state: state_name,
314+
source: "driver",
315+
resource_id: resource_id,
316+
message: message,
317+
checked_at: Time.now.utc.iso8601,
318+
}.compact
319+
end
320+
262321
# Whether the deprecated single-boot-disk options are configured.
263322
#
264323
# @return [Boolean] true if any of `autodelete_disk`, `disk_size` or

spec/kitchen/driver/gce/lifecycle_spec.rb

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,81 @@
266266
end
267267
end
268268

269+
# Test Kitchen 4 asks the driver whether the instance is actually alive, for
270+
# `kitchen list --live` and the `kitchen status` alias. Drivers that do not
271+
# answer inherit "unknown" from Kitchen::Driver::Base.
272+
describe "#status" do
273+
# Kitchen::Instance#driver_status checks the arity before calling, and
274+
# silently reports "unknown" for a status method that takes no state. A
275+
# correct-looking implementation with the wrong signature is never called.
276+
it "accepts the Kitchen state hash" do
277+
expect(driver.method(:status).arity).to eq(1)
278+
end
279+
280+
it "reports nothing created when the state file records no server" do
281+
expect(compute).not_to receive(:get_instance)
282+
283+
expect(driver.status({})).to include(live: false, state: "not created")
284+
end
285+
286+
context "with a running instance" do
287+
before do
288+
allow(compute).to receive(:get_instance).and_return(ComputeApi.instance(status: "RUNNING"))
289+
end
290+
291+
it "reports it as live" do
292+
expect(driver.status(server_name: "tk-test-1", zone: "test-zone-1a"))
293+
.to include(live: true, state: "running")
294+
end
295+
296+
it "identifies the instance it asked about" do
297+
expect(driver.status(server_name: "tk-test-1", zone: "test-zone-1a"))
298+
.to include(resource_id: "tk-test-1")
299+
end
300+
301+
it "records when it checked, as an RFC 3339 timestamp" do
302+
checked_at = driver.status(server_name: "tk-test-1", zone: "test-zone-1a")[:checked_at]
303+
304+
expect { Time.iso8601(checked_at) }.not_to raise_error
305+
end
306+
307+
it "asks the zone recorded in the state file, not the configured one" do
308+
expect(compute).to receive(:get_instance)
309+
.with("test-project", "recorded-zone", "tk-test-1")
310+
.and_return(ComputeApi.instance)
311+
312+
driver.status(server_name: "tk-test-1", zone: "recorded-zone")
313+
end
314+
end
315+
316+
# A preemptible instance GCE has reclaimed still exists, and the state file
317+
# still says "Created". Reporting its real state is the point of the hook.
318+
context "with an instance that exists but is not running" do
319+
before do
320+
allow(compute).to receive(:get_instance).and_return(ComputeApi.instance(status: "TERMINATED"))
321+
end
322+
323+
it "reports GCE's own state and does not call it live" do
324+
expect(driver.status(server_name: "tk-test-1", zone: "test-zone-1a"))
325+
.to include(live: false, state: "terminated")
326+
end
327+
end
328+
329+
context "with a server recorded that no longer exists" do
330+
before { allow(compute).to receive(:get_instance).and_raise(ComputeApi.client_error) }
331+
332+
it "reports it as gone rather than raising" do
333+
expect(driver.status(server_name: "tk-gone", zone: "test-zone-1a"))
334+
.to include(live: false, state: "not found")
335+
end
336+
337+
it "says the state file is out of date" do
338+
expect(driver.status(server_name: "tk-gone", zone: "test-zone-1a")[:message])
339+
.to match(/tk-gone/)
340+
end
341+
end
342+
end
343+
269344
describe "#generate_server_name" do
270345
it "derives a name from the Test Kitchen instance name" do
271346
expect(driver.generate_server_name).to match(/\Atk-default-ubuntu-2204-[0-9a-f]{6}\z/)

0 commit comments

Comments
 (0)