Skip to content

fix(stages): recover if /var/lib/cloud/instance is a directory - #6980

Open
maburlik wants to merge 1 commit into
canonical:mainfrom
maburlik:fix/meru-6210-instance-symlink-eisdir
Open

fix(stages): recover if /var/lib/cloud/instance is a directory#6980
maburlik wants to merge 1 commit into
canonical:mainfrom
maburlik:fix/meru-6210-instance-symlink-eisdir

Conversation

@maburlik

@maburlik maburlik commented Aug 5, 2026

Copy link
Copy Markdown

Proposed Commit Message

fix(stages): recover if /var/lib/cloud/instance is a directory

/var/lib/cloud/instance is only ever supposed to be a symlink into
instances/<iid>, or absent. Several places in Init unconditionally
call util.del_file() on it -- a plain os.unlink() -- which raises an
uncaught IsADirectoryError if the path is ever found to be a real
directory instead, aborting the boot stage before any further
modules can run.

This exact symptom has recurred more than once (GH-3710 /
LP:#1883903, fixed narrowly for one call site in 20.3; GH-4282, an
unresolved recurrence closed for lack of a reproduction). Rather
than chasing every possible writer that could promote this path,
this hardens every call site in Init that consumes the "symlink or
absent" invariant (purge_cache(), _get_data_source(), and
_reflect_cur_instance()) through one shared helper: if the path is
found to be a directory, log a warning and remove it recursively so
it can be recreated, instead of crashing.

The removal is scoped defensively: it never touches a mount point
(falls back to the original, non-destructive del_file() for that one
case instead of recursing into it), and shutil.rmtree() itself already
guarantees it won't follow a top-level symlink or any symlink nested
inside the directory being removed into its target.

Fixes GH-6979

Additional Context

Filed as GH-6979 first, with the full symptom writeup (traceback,
status.json, and a structural observation about when in the boot the
directory-promotion must occur, since init-local's own
_reflect_cur_instance() call always succeeds moments before the
crashing one). This PR is the candidate fix offered at the end of that
issue.

While investigating, I found the same "unguarded write_file() under
the not-yet-created instance_link" anti-pattern that caused GH-3710
still exists today in cc_snap.py's snapd.assertions write (it never
got the ensure_dir_exists=False fix cc_final_message received in
20.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() (via util.del_dir()) rather than os.unlink() for
the 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 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 PR replaces. Added an
os.path.ismount() guard so that case falls through to the original
del_file() behavior instead (the same IsADirectoryError as before,
for that one case, rather than deleting anything).

Test Steps

Added regression tests in tests/unittests/test_stages.py
(TestInit_ReflectCurInstance) that:

  • Reproduce the exact reported traceback against the unpatched code
    (verified locally: both _reflect_cur_instance() and
    purge_cache(rm_instance_lnk=True) fail with IsADirectoryError: [Errno 21] Is a directory: '.../instance' without this change,
    matching the field report byte for byte) and confirm the fix heals
    them.
  • Cover the shared helper (_remove_stale_instance_link) directly and
    in isolation: real-directory (heals + warns), valid symlink including
    a dangling one (silent, unchanged behavior), and absent (no-op).
  • Cover both _get_data_source() call sites via lighter "is it wired to
    the 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).
  • Add baseline coverage for the normal cases, none of which had any
    direct test coverage before this change.
  • Pin the two safety properties above: a nested symlink inside the
    healed directory survives untouched, and a (mocked) mount point at
    instance_link is never recursed into.

Run with:

tox -e py3 -- tests/unittests/test_stages.py -v -k ReflectCurInstance

Full suite comparison against an unmodified main: 5754 passed with
this change vs. 5742 passed on baseline -- exactly the 12 tests added
here, zero regressions. black, isort, and pylint (this repo's
currently pinned versions) all clean; pylint 10.00/10.

Merge type

  • Squash merge using "Proposed Commit Message"
  • Rebase and merge unique commits. Requires commit messages per-commit each referencing the pull request number (#<PR_NUM>)

@maburlik maburlik changed the title fix(stages): heal /var/lib/cloud/instance if it is a directory, not a symlink fix(stages): recover if /var/lib/cloud/instance is a directory Aug 5, 2026
@maburlik
maburlik force-pushed the fix/meru-6210-instance-symlink-eisdir branch from b8cd903 to 2b2bc84 Compare August 5, 2026 20:20
@maburlik
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
maburlik force-pushed the fix/meru-6210-instance-symlink-eisdir branch from 2b2bc84 to d007224 Compare August 5, 2026 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant