fix!: rebuild the unit test suite, document lib/, fix the bugs it found - #245
Conversation
a8c37b0 to
0493ad4
Compare
ramereth
left a comment
There was a problem hiding this comment.
Overall: strong PR — the test-suite rewrite is a big upgrade and most of the bug-fix claims verified cleanly when checked against Ruby itself, fog-openstack 1.1.5, Excon, and the openstacksdk docs. CI is green on 3.1–4.0. A handful of things need fixing before merge (inline comments; summary below), plus one release-process question.
Verified correct (checked against upstream sources, not just read)
ssl_ca_fileviaconnection_optionsis the right mechanism: fog-openstack passesconnection_optionsto both the Keystone token request and the service connection (core.rb), andssl_ca_fileis in Excon'sVALID_CONNECTION_KEYS.- The removed
env[:ssl_verify_peer]branch was genuinely dead — nothing inENV_VAR_MAPever produced that key. Array#firstignores blocks,Etc.getpwuidraisesArgumentErrorfor a uid with no passwd entry, and"5" > 0raises — all three fixes are legitimate.- The clouds.yaml claims (search paths, secure.yaml merge,
OS_CLIENT_CONFIG_FILE/OS_CLIENT_SECURE_FILE,verify/cacert) match the openstacksdk configuration docs. - The README option tables match
Fog::OpenStack::Compute.recognized(I ran it), and the dead-option claims (no_ssh_tcp_check,no_ssh_tcp_check_sleep,pre_create_command) check out by grep.
Must fix (details inline)
- README boot-from-volume example:
volume_device_nameis not a key fog-openstack reads — regression vs the current README'sdevice_name. wait_for_volume: usevolumes.get(vol_id)rather than.findover one list page; the newRuntimeErrors also escapecreate's Fog/Excon rescue as raw backtraces.timeout_value: bareInteger()octal footgun, plus unwrappedArgumentError/TypeErrorraised after the volume already exists (leak).- The
Regexp#match?(nil)"regression" isn't real — it returnsfalse, it does not raise. The PR description bullet and the spec comment need correcting. - Spec isolation: the secure.yaml search path is unpinned, so a developer's real
~/.config/openstack/secure.yaml//etc/openstackcan leak into the suite; andopenstack_version_specviolates the "never read outsideDir.mktmpdir" invariant this PR itself adds to AGENTS.md. - A few tests are vacuous or pin impossible states (the
parse_ipsmutation test,fog_server'swait_forstub).
Versioning / release
This PR deliberately breaks three behaviors: a missing user_data file now raises, a non-Array security_groups now raises, and — easy to miss — a stale OS_CACERT/cacert that was previously parsed-then-dropped will now actually be handed to Excon, which can start failing TLS for users who never noticed it was set. I agree with all three, but main is at 7.0.1 and the commit headlines carry no conventional-commit type, so Release Please can't classify them. They should ride a fix!:/feat!: (→ 8.0.0), or we consciously accept them as fixes with prominent release notes — needs a decision before merge, and the PR title/squash commit needs a conventional type either way.
On the three inert options you flagged for a decision (no_ssh_tcp_check, no_ssh_tcp_check_sleep, pre_create_command): leaving them untouched in this PR was the right call — I'll follow up separately on removing vs. wiring them.
🤖 Review compiled with Claude Code
Rebuild the test suite around one spec file per lib file, document every
method in lib/ with YARD, and fix the bugs the new coverage exposed.
Tests
- Replace the 1442-line monolith with per-module specs mirroring lib/.
- Enable verify_partial_doubles, random ordering, aggregate_failures and
disable_monkey_patching. Fog *model* objects now use instance_double;
Fog *service* objects cannot, since Fog defines their methods
dynamically at instantiation, and spec/support/fog_doubles.rb says so.
- Replace the File.exist? stubs in the clouds.yaml specs with real files
in a Dir.mktmpdir, so the search-path logic is actually exercised, and
pin Dir.pwd, Dir.home and /etc/openstack so a real clouds.yaml or
secure.yaml on the developer's machine can never be read -- which also
keeps a real password out of any RSpec diff.
- Replace ENV wholesale with an OS_*-free hash, so a developer's own
OpenStack environment cannot leak into a run. The helper that does it
now lives in the shared context rather than being duplicated.
- Model Fog's wait_for contract in the doubles instead of returning a
canned value: the readiness block is evaluated in the model's own
context and a block that never goes truthy raises TimeoutError. The
canned version meant get_ip's readiness predicate was never executed by
any example while SimpleCov still counted it covered.
- Stub every sleep. 141 examples in 20.19s becomes 360 in 0.31s.
- Add SimpleCov reporting to coverage/, with no enforced threshold. It is
on in CI and opt-in locally via `rake coverage` or COVERAGE=1, so a
single-file run does not pay for instrumentation. Line coverage is
100%; the five uncovered branches are all `require x unless defined?(X)`
guards.
Bug fixes
- volume: `volumes.first { |x| x.id == vol_id }` silently ignored its
block, so creation waited on whichever volume happened to be listed
first rather than the one just created. Fetch it by id with
volumes.get, which is a direct GET and so is correct at any volume
count -- a listing returns only one page.
- volume: get_bdm mutated config[:block_device_mapping] in place, so a
retried create saw an already-stripped hash. Work on a copy.
- volume: coerce creation_timeout/attach_timeout, which YAML parses as
Strings when quoted; "5" > 0 raised ArgumentError. Parse in base 10 so
"010" is ten seconds rather than eight, validate both before the Cinder
volume is created rather than after, and report a bad value as
ActionFailed naming the key instead of a raw backtrace plus a leaked
volume.
- volume: raise Kitchen::ActionFailed rather than RuntimeError, which
create's `rescue Fog::Errors::Error, Excon::Errors::Error` does not
catch and so surfaced as a raw backtrace.
- config: server_name_prefix called gsub! on its argument, rewriting
config[:server_name_prefix] in place and raising FrozenError on a
frozen literal.
- config: Etc.getpwuid raises for a uid with no passwd entry rather than
returning nil, so the "nologin" fallback was dead code and the
container case crashed. Rescue it.
- config: require etc and socket instead of relying on transitive loads.
- config: derive the per-component name budgets from
MAX_SERVER_NAME_LENGTH so the 63-character limit is maintained by the
code rather than only described by a constant nothing read.
- networking: guard the pool lookup in attach_ip_from_pool and the named
network in get_ip. Both indexed straight into [0], turning a typo into
a NoMethodError on nil instead of a usable message.
- networking: parse_ips used select!, and Array(x) returns x itself when
x is already an Array, so it filtered the Fog server model's own
address lists in place. Use select.
- networking: free_ip_from_pool built and compacted a whole throwaway
array while holding IP_POOL_LOCK just to take the first element. Stop
at the first free address.
- openstack: guard the floating IP release in destroy the same way. An
address Neutron had already reclaimed took the whole destroy down and
stranded the server.
- openstack: ssl_ca_file is an Excon connection option, not a Fog one,
so a CA bundle from OS_CACERT or a clouds.yaml cacert entry was parsed
and then dropped. Pass it through connection_options.
- server_helper: a missing user_data file silently became nil, booting a
server without the cloud-init the operator asked for. Raise instead.
- server_helper: a security_groups value that was not an Array was
silently dropped, booting into the default group. Raise instead.
- server_helper: validate the whole config before get_bdm runs. get_bdm
is the only step that creates a resource, and a volume it created was
orphaned when a later purely-local check raised, since the new volume's
id lived only in the discarded server definition.
- server_helper: compare resource ids as strings, since Nova returns
integer flavor ids on some deployments.
- clouds: the SSL ternary read env[:ssl_verify_peer], which ENV_VAR_MAP
can never produce. Remove the dead branch; report the file by name on
a YAML syntax error or a non-mapping document, and report a named
cloud entry that is present but is not a mapping rather than carrying
on with nil credentials and failing later inside Keystone.
- clouds: Dir.home raises when HOME is unset and the uid has no passwd
entry, so with OS_CLOUD set the search crashed before /etc/openstack --
the one location such a container is likely to have -- was tried.
- clouds: use YAML.safe_load_file now that Ruby 3.1 is the floor.
Docs
- YARD tags on all 57 methods and 18 constants (100% documented).
- Add .yardopts plus `rake yard` and `rake yard_stats`. Neither is wired
into rake default or CI, per the no-gating requirement.
- Fix the README's stale `rake spec` / `rake rubocop` instructions.
BREAKING CHANGE: three behaviours change in ways that can fail a converge
that previously succeeded.
* A `user_data` file that does not exist now raises ActionFailed. It was
previously read as nil, booting the server without the cloud-init the
operator asked for and saying nothing about it.
* A `security_groups` value that is not an Array now raises ActionFailed.
It was previously dropped, booting the server into the default security
group rather than the requested one.
* `ssl_ca_file` -- from OS_CACERT or a clouds.yaml `cacert` entry -- is
now actually handed to Excon. It was previously parsed and then
discarded, so a stale or wrong CA path had no effect. Deployments
carrying one will now fail TLS until it is corrected or removed.
Two smaller changes worth noting, neither of which should break a working
converge:
* image_ref/flavor_ref: a purely numeric ref now matches an id before a
name. On clouds where Nova returns Integer ids the id branch could not
match a String ref, so a resource *named* "2" won. A flavor named "2"
alongside a different flavor with id 2 now resolves to the latter,
which is what the documented id-first contract says should happen.
* A block_device_mapping timeout that is not a number now raises
ActionFailed naming the key, and is checked before the Cinder volume is
created rather than after it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README pointed at kitchen.ci for all configuration and documented only clouds.yaml, which read as a changelog entry for that feature rather than as documentation. There was no path from "I have an OpenStack account" to "I have a running instance". - Add a quick start that goes end to end: get credentials into your shell, verify them with the openstack CLI first, find an image and flavor, write a minimal kitchen.yml, run kitchen test. - Add a full configuration reference, grouped by task rather than alphabetical. Every option was derived from what the code actually reads -- `diagnose.keys` plus `Fog::OpenStack::Compute.recognized` plus `required_server_settings` -- and the tables are diffed against that set, so there are no invented options and no real ones missing. - Call out the settings people reach for that this driver does not own: `username`, `ssh_key` and `port` belong to the transport, and `no_ssh_tcp_check`, `no_ssh_tcp_check_sleep` and `pre_create_command` are accepted but have no effect. - Add worked examples for floating IPs, multiple networks, cloud-init, booting from a volume, and a cloud behind a private CA. - Add a troubleshooting table keyed on the actual error strings the driver raises, each verified to exist in lib/. - Name OS_CLIENT_SECURE_FILE, which was described but never spelled out. - Add a contents list, and expand the development section with the real rake tasks. - Use `device_name` in the boot-from-volume example. fog-openstack reads exactly :delete_on_termination, :device_name, :volume_id and :volume_size out of a block device mapping, so `volume_device_name` would have been sent as device_name => nil and the volume attached wherever the hypervisor chose. Verified: markdownlint clean, all 10 internal anchors resolve, every external link returns 200. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0493ad4 to
0daa30c
Compare
|
Rebased on Versioning — this is 8.0.0Decided rather than deferred. The PR title is now The footer names all three breaks, including the The five "must fix" items
Two corrections worth flagging
The The suite was checked by mutation testingSince the theme of the review was tests that pass without proving anything, coverage alone didn't seem like enough. 40 deliberate mutations were applied to That also turned up something the Left for a follow-upThe ingest-side coercion layer in Also still untouched by agreement: VerificationOrder-independent across 5 seeds, and verified clean against a planted 🤖 Generated with Claude Code |
Rebuilds the test suite around one spec file per lib file, documents every method in
lib/with YARD, and fixes the bugs the new coverage exposed.Release
This ships as 8.0.0. Three behaviours change in ways that can fail a converge which previously succeeded, so the PR title carries
fix!:and the commit carries aBREAKING CHANGE:footer — Release Please will pick up the major bump from either.user_datapointing at a nonexistent fileActionFailedsecurity_groupsset to a bare StringActionFailedssl_ca_file(OS_CACERT/ clouds.yamlcacert)That last one deserves a release note of its own: a stale or wrong CA path that was previously ignored is now enforced, so a deployment carrying one will start failing TLS on upgrade until it is corrected or removed.
Two smaller changes, neither of which should break a working converge:
image_ref/flavor_ref: a purely numeric ref now matches an id before a name. On clouds where Nova returns Integer ids the id branch could never match a String ref, so a resource named"2"won. A flavor named"2"alongside a different flavor with id2now resolves to the latter — which is what the documented id-first contract says should happen.block_device_mappingtimeout that is not a number now raisesActionFailednaming the key, and is checked before the Cinder volume is created rather than after.Tests
openstack_spec.rbmonolith is split so the spec layout mirrorslib/.verify_partial_doublesis on, along with random ordering,aggregate_failures, anddisable_monkey_patching!. Fog model objects useinstance_double; Fog service objects can't, because Fog definesservers/list_networks/etc. dynamically at instantiation —spec/support/fog_doubles.rbdocuments why rather than silently downgrading.File.exist?stubs in the clouds.yaml specs, viaDir.mktmpdir. The search-path logic is now actually exercised.ENVis replaced wholesale with anOS_*-free hash, and the clouds.yaml specs additionally pinDir.pwd,Dir.homeand/etc/openstack. Verified by running the suite against a planted~/.config/openstack/secure.yamland exportedOS_CLOUD/OS_USERNAME/OS_CACERT: 360 pass, and no planted secret reaches the output.wait_fordoubles follow Fog's contract — the readiness block is evaluated in the model's own context, and a block that never goes truthy raisesFog::Errors::TimeoutError.countdownandwait_fordoing real wall-clock waits.coverage/with no enforced threshold. On in CI, opt-in locally viarake coverageorCOVERAGE=1, so a single-file run doesn't pay for instrumentation. The five uncovered branches are allrequire x unless defined?(X)guards.Verified order-independent across 5 seeds.
The suite was checked by mutation testing
Coverage says which lines ran, not whether anything would notice them breaking. So 40 deliberate mutations were applied to
lib/and the suite re-run against each: 38 were killed, and the 2 survivors are provably equivalent mutants (return if merged.empty?, whose removal cannot change behaviour because an emptymergedimplies an emptycc; and one guard whose surviving form was then made distinguishable and is now killed too).That exercise earned its keep — it caught a genuinely untested guard.
single.name && regex.match?(single.name)looked interchangeable withregex.match?(single.name.to_s), and is, for every regex that doesn't match the empty string. For/.*/it isn't: theto_sform silently selects an unnamed Neutron network. There is now an example pinning that.Bugs fixed
Each has a regression test, marked with a
# Regression:comment explaining the failure mode. Every one of those comments was re-checked against the pre-PR code in this round.volume.rbvolumes.first { |x| x.id == vol_id }—Array#firstsilently ignores blocks. Creation waited on whichever volume was listed first, not the one just created. Now fetched by id withvolumes.get, a direct GET, so it is correct at any volume count — a listing returns only one page.get_bdmmutatedconfig[:block_device_mapping]in place, so a retriedcreatesaw an already-stripped hash.creation_timeout/attach_timeoutare Strings when quoted in YAML;"5" > 0raisedArgumentError. Parsed in base 10 so"010"is ten seconds and not eight, validated before the Cinder volume exists, and reported asActionFailednaming the key.Kitchen::ActionFailed, whichcreate'srescue Fog::Errors::Error, Excon::Errors::Erroractually catches.config.rbserver_name_prefixcalledgsub!on its argument — rewroteconfig[:server_name_prefix]in place, and raisedFrozenErroron a frozen literal.Etc.getpwuidraises for a uid with no passwd entry rather than returning nil, so the"nologin"fallback was dead code and the container case crashed.EtcandSocketwere used without being required.MAX_SERVER_NAME_LENGTH, so the 63-character limit is maintained by the code rather than described by a constant nothing read.networking.rb/openstack.rb[0]["id"]on a Neutron response — the floating IP pool lookup, the named-network lookup, and the floating IP release indestroy. All three now raiseActionFailedwith the offending name, and the release path warns and continues.parse_ipsusedselect!, andArray(x)returnsxitself whenxis already an Array — so it filtered the Fog server model's own address lists in place.free_ip_from_poolbuilt and compacted a whole throwaway array while holdingIP_POOL_LOCKjust to take the first element.ssl_ca_fileis an Excon connection option, not a Fog one. See the release table above.server_helper.rbuser_datafile silently becamenil. Now raises. (Breaking — see above.)security_groupsvalue that wasn't an Array was silently dropped. Now raises. (Breaking — see above.)get_bdmruns.get_bdmis the only step increate_serverthat creates a resource, and a volume it created was orphaned when a later purely-local check raised — the new volume's id lived only in the server definition being discarded, sokitchen destroycould never reach it.clouds.rbenv[:ssl_verify_peer], whichENV_VAR_MAPcan never produce. Dead branch removed.Dir.homeraises whenHOMEis unset and the uid has no passwd entry. WithOS_CLOUDset, the search crashed before/etc/openstack— the one location such a container is likely to have — was tried.YAML.safe_load_filenow that Ruby 3.1 is the floor.Docs
yard statsreports 100% documented..yardoptsuses--private: the driver's public surface is onlycreate/destroy, so default YARD would render a two-method site.rake yard,rake yard_statsandrake coverageadded. None is wired intorake defaultor CI, per the no-gating requirement.README
Rewritten for someone arriving new: a quick start that goes end to end, a full configuration reference grouped by task, worked examples, and a troubleshooting table keyed on the actual error strings the driver raises.
Every option was extracted from
diagnose.keys+Fog::OpenStack::Compute.recognized+required_server_settings, and the finished tables were diffed back against that set — no invented options, and all 59 real ones either tabled or named in prose.Three options that accept input and do nothing
All three are documented as ineffective rather than changed, since fixing them is a behavior change:
no_ssh_tcp_checkdefault_config, read nowhere inlib/.no_ssh_tcp_check_sleeppre_create_commandDriver::Base, but this driver overridescreatewithout calling it orsuper.Left alone in this PR by agreement — @ramereth is following up separately on removing vs. wiring them.
Verification
Review round 1
All 27 inline comments addressed. Highlights:
volume_device_name→device_namein the boot-from-volume example. Confirmed againstfog-openstack-1.1.5/compute/requests/create_server.rb: the v1 mapping reads exactly:delete_on_termination,:device_name,:volume_id,:volume_size.wait_for_volumeusesvolumes.get(vol_id), passed the already-built service object so there's no second Keystone round-trip, and raisesActionFailedrather thanRuntimeError.timeout_valueparses base 10, wrapsArgumentError/TypeErrorintoActionFailednaming the key, and both timeouts are validated before the Cinder create. One correction: the suggestedInteger(value, 10)can't be applied literally — a base argument is only legal for a String, soInteger(30, 10)raisesbase specified for non string value. The existing suite caught it immediately. It's nowvalue.is_a?(String) ? Integer(value, 10) : Integer(value).Regexp#match?(nil)claim was wrong and is gone. Verified:/ubuntu/.match?(nil) # => false. The comment and the PR description bullet both said it raisedTypeError; neither does now. The guard stays, on the unnamed-networks rationale — and mutation testing then showed it is load-bearing for a regex matching"", which now has its own example.OS_CLIENT_SECURE_FILEis pinned, andDir.pwd/Dir.home//etc/openstackare pinned too — a nonexistent env path alone doesn't block the search, it just falls through. Verified against a planted secure.yaml.openstack_version_speckeeps its two repo-root reads, which are now the documented exemption in AGENTS.md and CONTRIBUTING rather than a contradiction of them, and each skips when the file is absent so a packaged gem still passes.parse_ipsexample asserted on a.dup; it now asserts on the object passed in — andparse_ipswas indeed mutating, so the code changed too. TheSTRING_CONFIG_KEYSexample recomputed the constant's own defining expression; it now pins a literal list.fog_doubles'wait_forruns the caller's block and raisesTimeoutErrorwhen it never goes truthy, andaddressesis consistent withpublic_ip_addresses. Two knock-on findings: theget_iptimeout test now drives the real predicate instead of stubbingwait_forto raise, and the block'ssleep(1)was reachingKernel#sleepon the server (the driver'ssleepstub never applied, becausewait_forinstance_execs on the model) — the suite briefly went from 0.31s to 12.4s until that was stubbed on the right object.attach_ip_from_pool's test description now says what it guarantees:get_ipreuses the value;destroyrebuilds the address from the server and:public_ip_orderand never readsconfig[:floating_ip].findinstead ofmap+compactunderIP_POOL_LOCK;extract_cloudraises on a present-but-non-mapping entry;Dir.homeguarded;YAML.safe_load_file;MAX_PREFIX_LENGTHderived;@return [void]ondisable_ssl_validation; SimpleCov guarded behindCI/COVERAGE;stub_envhoisted into the shared context; the clouds tmpdir made lazy; theOS_INSECUREphrasing corrected.Not done, by agreement: the ingest-side coercion layer in
clouds.rb. The drift is real and reproduces —identity_api_version: 3becomes"3"inconfig(whatkitchen diagnoseprints) but"v3"at the Fog boundary, andopenstack_tenant/openstack_tenant_idare inFOG_STRING_SETTINGSbut notSTRING_CONFIG_KEYS. Consolidating it changesdiagnoseoutput, which is a separable behaviour change, and this PR is already large. Happy to take it as a follow-up.Attribution: deliberate. The OSU copyright on the new spec files matches the six
lib/files that already carry it onmain.🤖 Generated with Claude Code