fix(stages): recover if /var/lib/cloud/instance is a directory - #6980
Open
maburlik wants to merge 1 commit into
Open
fix(stages): recover if /var/lib/cloud/instance is a directory#6980maburlik wants to merge 1 commit into
maburlik wants to merge 1 commit into
Conversation
maburlik
force-pushed
the
fix/meru-6210-instance-symlink-eisdir
branch
from
August 5, 2026 20:20
b8cd903 to
2b2bc84
Compare
maburlik
marked this pull request as ready for review
August 5, 2026 20:21
Several places in Init unconditionally call util.del_file(paths.instance_link), a plain os.unlink(). paths.instance_link (/var/lib/cloud/instance by default) is only ever supposed to be a symlink into instances/<iid>, or absent -- but it has been observed in the wild as a real directory instead, which makes os.unlink() raise an uncaught IsADirectoryError, aborting the entire boot stage before any further modules can run. This is a known bug class, not a new one: - canonicalGH-3710 / LP:#1883903 (2020): cc_final_message wrote into a path under the not-yet-created instance_link via write_file(..., ensure_dir_exists= True) (the default), silently promoting it to a directory via os.makedirs(). Fixed for that one call site in cloud-init 20.3 (PR canonical#445) by passing ensure_dir_exists=False. - canonicalGH-4282 (2023, cloud-init 23.1.2): the identical IsADirectoryError recurred, years after the above fix, via a different call path (purge_cache()). Never reproduced by a maintainer; closed not_planned in 2024 for lack of a reproduction, without a fix. - The general anti-pattern is still present today in at least one other module (cc_snap.py's snapd.assertions write also uses get_ipath_cur() + write_file() without ensure_dir_exists=False), so treating this as fully swept by the 2020 fix is not safe, and new occurrences elsewhere in the module set (or from something entirely outside cloud-init's control) remain possible. Rather than continuing to chase every individual writer that could theoretically promote this path (an open-ended search, as the 2020 fix's narrow, single-module scope demonstrates), this hardens every call site in Init that actually consumes the "instance_link is a symlink or absent" invariant, right before it relies on it: - Init.purge_cache(rm_instance_lnk=True) -- the call path canonicalGH-4282 crashed in. - Init._reflect_cur_instance() -- the call path matching the traceback reported in the new issue this fix addresses (see below), reached on every boot via both the init-local and init stages. - Init._get_data_source(), at both of its own instance_link removals: once after a fresh find_source() succeeds, and once in the no-cached-fallback exception path. This method runs earlier in a boot than the two call sites above (fetch() happens before instancify()), so on current main it is actually the first place this invariant would be violated against in practice; it wasn't shaped this way yet in the 22.3.4 release this fix was originally scoped against; found while rebasing onto current main. All four sites now go through one new helper, Init._remove_stale_instance_link(): if instance_link is found to be a real directory, log a warning (so the underlying cause remains visible/ greppable) and remove it recursively so it can be recreated, instead of crashing. This does not address whatever causes the promotion in the first place -- it makes cloud-init resilient to it regardless of cause, which is the only place in the codebase that can make that guarantee for the whole class of bug. Added regression tests that reproduce the exact reported traceback against an unpatched _reflect_cur_instance()/purge_cache() (verified locally: both fail with IsADirectoryError: [Errno 21] Is a directory: '.../instance' without this change, matching the field reports byte for byte), confirm all four call sites are healed by it (the _get_data_source() sites via lighter call-site "is it wired to the tested helper" tests, since exercising them end-to-end would require mocking the whole datasource-discovery machinery), and add baseline coverage for the normal (absent or valid-symlink, including a dangling-symlink edge case) cases, none of which had any direct test coverage before this change. Prompted by a recurring guest-side failure observed downstream on cloud-init 22.3.4: an intermittent (roughly 1-2% of individual VM boots) IsADirectoryError in the init stage, always on the heaviest-resource-footprint VM configuration in a repeated boot test sweep, reproduced under more than one VMM backend. No first-party code writing into this path was found anywhere in the reporting environment's own guest-side stack, which is consistent with this being (at least in part) the unresolved upstream defect class documented above rather than something introduced downstream. A safety review of the fix itself found one real gap, since fixed: util.del_dir() (shutil.rmtree()) has no filesystem-boundary awareness, so if instance_link were ever a mount point rather than an ordinary directory, it would have recursed into and deleted the mounted filesystem's *contents* before failing on the mount point itself -- a strictly larger blast radius than the crash this fix replaces. Added an os.path.ismount() guard so that case falls through to the original, non-destructive del_file() path instead (reproducing the same IsADirectoryError as before, for that one case, rather than deleting anything). Verified independently (and pinned with a regression test) that shutil.rmtree() itself already safely refuses to operate on a top-level symlink and never follows symlinks nested *inside* the directory being removed into their targets, so a stray symlink planted inside this path cannot cause anything outside of it to be deleted. Validated: full unit test suite passes with zero regressions relative to an unmodified baseline (5742 passed baseline vs. 5754 with this change -- exactly the 12 tests added here); black, isort, and pylint (this repo's currently pinned versions) all clean, pylint 10.00/10. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a8ee3a17-fe0f-46bf-9958-77abc2c084e5
maburlik
force-pushed
the
fix/meru-6210-instance-symlink-eisdir
branch
from
August 5, 2026 20:36
2b2bc84 to
d007224
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proposed Commit Message
Additional Context
Filed as GH-6979 first, with the full symptom writeup (traceback,
status.json, and a structural observation about when in the boot thedirectory-promotion must occur, since
init-local's own_reflect_cur_instance()call always succeeds moments before thecrashing one). This PR is the candidate fix offered at the end of that
issue.
While investigating, I found the same "unguarded
write_file()underthe not-yet-created
instance_link" anti-pattern that caused GH-3710still exists today in
cc_snap.py'ssnapd.assertionswrite (it nevergot the
ensure_dir_exists=Falsefixcc_final_messagereceived in20.3). Not fixed here since it's an independent, narrower issue and I
have no evidence it's actually implicated in GH-6979 specifically --
flagging it here in case a maintainer wants a separate small PR for it,
or wants it folded into this one instead.
This PR intentionally does not identify or fix whatever causes the
directory promotion in the first place -- seeing that mechanism was
already effectively out of scope for GH-4282's own multi-year, never-
resolved attempt at the same question. It makes cloud-init resilient
to the symptom regardless of cause, at the one place in the codebase
that can make that guarantee for the whole bug class.
Safety review of the removal itself: the new helper uses
shutil.rmtree()(viautil.del_dir()) rather thanos.unlink()forthe directory case, so I specifically checked it can't be tricked into
deleting more than intended. Confirmed independently that
rmtree()already refuses to operate on a top-level symlink and never follows
symlinks nested inside the directory being removed into their
targets (only the symlink entries themselves are unlinked) -- both
pinned with regression tests now. Also found and fixed one real gap:
rmtree()has no filesystem-boundary awareness, so ifinstance_linkwere ever a mount point rather than an ordinarydirectory, it would have recursed into and deleted the mounted
filesystem's contents before failing on the mount point itself -- a
strictly larger blast radius than the crash this PR replaces. Added an
os.path.ismount()guard so that case falls through to the originaldel_file()behavior instead (the sameIsADirectoryErroras before,for that one case, rather than deleting anything).
Test Steps
Added regression tests in
tests/unittests/test_stages.py(
TestInit_ReflectCurInstance) that:(verified locally: both
_reflect_cur_instance()andpurge_cache(rm_instance_lnk=True)fail withIsADirectoryError: [Errno 21] Is a directory: '.../instance'without this change,matching the field report byte for byte) and confirm the fix heals
them.
_remove_stale_instance_link) directly andin isolation: real-directory (heals + warns), valid symlink including
a dangling one (silent, unchanged behavior), and absent (no-op).
_get_data_source()call sites via lighter "is it wired tothe tested helper" tests (mocking the datasource-discovery machinery
those call sites depend on, rather than re-deriving full filesystem
behavior that's already covered above).
direct test coverage before this change.
healed directory survives untouched, and a (mocked) mount point at
instance_linkis never recursed into.Run with:
tox -e py3 -- tests/unittests/test_stages.py -v -k ReflectCurInstanceFull suite comparison against an unmodified
main: 5754 passed withthis change vs. 5742 passed on baseline -- exactly the 12 tests added
here, zero regressions.
black,isort, andpylint(this repo'scurrently pinned versions) all clean;
pylint10.00/10.Merge type