Skip to content

fix(activate): fail when an /etc file could not be written - #551

Open
ndam-hexagon wants to merge 11 commits into
numtide:mainfrom
ndam-hexagon:fix/etc-activation-failures
Open

ndam-hexagon wants to merge 11 commits into
numtide:mainfrom
ndam-hexagon:fix/etc-activation-failures

Conversation

@ndam-hexagon

@ndam-hexagon ndam-hexagon commented Sep 18, 2026 •

Copy link
Copy Markdown

system-manager switch exits 0 even when it failed to write a file under /etc. The failure is only a warning, so the generation is marked active while /etc still has the old content.

Now those failures are collected during the walk and reported at the end, so one bad file still doesn't stop the others from landing. Files we deliberately leave alone stay non-fatal: an unmanaged path without replaceExisting, and the distro units under /etc/systemd/system we can't own, keep warning with a zero exit. Only a failed write counts.

Five bugs found on the way, one commit each:

  • A directory sitting at a managed target was skipped forever: remove_file gives EISDIR, and renaming it into the backup slot leaves something that can't be restored. Now an empty directory is removed and the file created normally; a non-empty one is an error, not a recursive delete under /etc.
  • backup_existing_file used fs::rename, which overwrites. If a .system-manager-backup was already there (e.g. after losing the state file), the real original was destroyed and deactivation put our own file back instead.
  • A managed target missing from disk was always re-filed as a plain file, even if the old state had it backed up. Deactivation only restores backed-up files, so the original was stranded.
  • The create_dir_all result was ignored, so a parent that can't be created showed up later as a confusing ENOENT.
  • A warning printed the target in the source slot: Can't link/copy /etc/nix/nix.conf to : Is a directory.

The tests run create_etc_files against a temp /etc, so they don't need the cgroups and auto-allocate-uids features the container tests need. Four fail without the fixes. The two skip cases pass either way on purpose, to pin down what must not fail activation.

No existing issue covers this as far as I could find; happy to open one if you want the link.

🤖 Generated with Claude Code

@Mic92
Mic92 force-pushed the fix/etc-activation-failures branch from 91055de to 2bda917 Compare September 18, 2026 22:55

@jfroche jfroche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should also handle the failed configured files/dir and retain their previous state. Otherwise they are treated as removed from the configuration, removed from the persisted state and will be considered unmanaged on the next activation.

final_state.write_to_file(state_file)?;

if let Err(e) = tmp_result {
return Err(e.into());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also handle tmp failure the same way.
I did something similar in #378 maintaining a list of error.
Maybe we could merge this feature here ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Held off on this one. With the early bail above, there is no longer a list of etc errors running alongside the tmp error to merge; the only accumulation left lives inside the etc walk itself. Tmp handling is untouched here.

A shared error list across etc/tmp/services still seems like the right shape, but #378 already has it, and growing a second mechanism here would just make that merge harder. Happy to rebase on top of #378 once it lands.

@numtide numtide deleted a comment from mergify Bot Sep 21, 2026
jeremy added a commit to jeremy/system-manager that referenced this pull request Oct 12, 2026
…failed

An activation in which systemd refused a job, userborn.service failed or
the services phase ended early (a job timeout, a D-Bus error) logged the
error, wrote the state and exited 0. A caller judging the switch, a
person or an unattended upgrade, then took a half-applied profile for an
applied one.

The control flow is unchanged: every unit is still attempted and the
state file still records what was applied. Only the result changes:

- exit 1 (ActivationFailure::Incomplete) when part of the profile wasn't
  applied, naming each part: a job systemd refused (the TODO in
  for_each_unit), including for sysinit-reactivation.target; a failed
  userborn.service (a oneshot's job finishes whether or not it
  succeeded, so its result is checked); a services phase that returned
  a partial result;
- exit 4 (ActivationFailure::UnitsFailed) when everything was applied
  and managed units are in the failed state afterwards, naming them, as
  NixOS' switch-to-configuration does, so a caller can tell the two
  apart and judge the second against what was failing before.

Stopping a unit systemd no longer has loaded (one that had exited, whose
file the new profile dropped) fails with NoSuchUnit. That isn't a
refusal, there is nothing to stop, so it is now logged at debug instead
of as an error.

/etc is left as it is: numtide#551 covers it.
@jeremy

jeremy commented Oct 12, 2026

Copy link
Copy Markdown

We hit the same bug running system-manager unattended (an upgrade loop that switches, checks health and rolls back), and wrote a fix for /etc before finding this PR. Yours goes further, so I've dropped ours and opened #582 for the part you don't touch: refused systemd jobs, a failed userborn.service, and exit 4 for managed units that are failed after an otherwise complete switch, as NixOS' switch-to-configuration does. The two touch different code. Merged together they conflict only where both add a mod tests to the end of activate.rs.

Your tests run create_etc_files against a temp /etc. If an end-to-end check would help, we have a container test (testFlake/container-tests/) that runs bin/activate and checks the exit status. Adapted to your semantics, it would cover:

  • a non-empty directory at a managed target: exit 1, the error names the path, the directory is left alone, and the profile's other /etc entries are still written;
  • with the path freed, the same profile exits 0 and the target holds the managed content;
  • an unmanaged file at an entry without replaceExisting: exit 0, with the file untouched.

I'm happy to push it to your branch or open it as a follow-up once this lands, whichever you prefer.

One question about the last case. We made an unmanaged file in the way exit 1 rather than warn. Our reasoning was that the entry wasn't applied, and a copy entry in the same spot was already an error at the file level. #378's VM test expects a failure there too. I can see the case for keeping it a warning: a distribution image that ships a file at a path a module manages without replaceExisting would go from a warning to a failed activation. Was that the reason, or is there another case you had in mind? Either way, it would help a caller if the warning stayed easy to find in the output, since the exit status won't flag it.

ndam-hexagon and others added 9 commits October 13, 2026 14:46
The format string reads as a source/target pair but only ever received
the target and the error, so the target landed in the source slot:
"Can't link/copy /etc/nix/nix.conf to : Is a directory (os error 21)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A directory at a managed path was reported as a per-file warning and
skipped, because fs::remove_file is EISDIR on a directory and
backup_existing_file's rename puts an unrestorable directory in the
backup slot. Activation then reported success with /etc untouched, and
every later run took the same branch, so it never healed.

Clear the directory before choosing how to replace the target, but only
where we already intend to replace it, so the "never clobber an
unmanaged path" guard keeps its current meaning. An empty directory is
dropped and the target is created normally; a non-empty one is an error
rather than a recursive delete under /etc driven by a state file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fs::rename silently clobbers its destination, so a target that is not in
the state file but already has a .system-manager-backup next to it, as
happens after the state is lost, destroyed the original the backup was
holding. Deactivation then "restored" our own file over the top.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Result was dropped on the floor, so a parent that cannot be created
(notably a dangling symlink, where create_dir_all returns EEXIST) only
surfaced later as a confusing ENOENT from the symlink or copy that
followed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A managed target that is missing from disk was re-created and filed
unconditionally under the plain-files set, even when the previous state
had it as backed up. Deactivation restores only from the backed-up set,
so the original was orphaned in its .system-manager-backup forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A per-file failure under /etc was logged as a warning and then dropped,
so activation reported success while the file it could not write stayed
as it was, with nothing on the machine able to tell. That is the whole
"success proves nothing" surface: the switch exits 0 and the operator
has no signal at all.

Collect the per-file failures during the walk and fail at the end,
keeping the warnings and keeping the rest of the walk going, so one bad
file still does not stop the others from landing.

Deliberate skips stay non-fatal, which is the distinction that makes
this safe: an unmanaged path without replaceExisting, and the distro
units under /etc/systemd/system that we knowingly cannot own, are
choices rather than failures and go on warning with a zero exit. Only an
attempted write that failed counts. Aborting the walk outright, when the
entry list or the static env cannot be read, now fails too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Exercises create_etc_files against a temporary /etc, so the behaviour is
checked without the cgroups and auto-allocate-uids features the
container tests need.

Four of these fail without the preceding fixes. The two skip cases pass
either way on purpose: they pin the lenient half of the contract, that a
path we choose not to clobber is not a failure, which is what keeps the
new non-zero exit from firing on the distro units under
/etc/systemd/system that we knowingly cannot own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A target we failed to write was absent from the new state, so it fell into
the deletion set: we removed the file we had just failed to replace, and
forgot it was ever managed, leaving the next activation to treat it as
unmanaged. Carry it over from the old state instead.

The failure list now holds the same absolute paths as the state, which the
lookup needs, and which also makes the warnings point at a real file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collecting per-file failures instead of aborting the walk meant activation
carried on to restart services on top of configuration we knew we had
failed to write. Persist the state and fail before that point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ndam-hexagon
ndam-hexagon force-pushed the fix/etc-activation-failures branch from 166ee72 to 03f5fdb Compare October 13, 2026 12:47
@ndam-hexagon

Copy link
Copy Markdown
Author

We hit the same bug running system-manager unattended (an upgrade loop that switches, checks health and rolls back), and wrote a fix for /etc before finding this PR. Yours goes further, so I've dropped ours and opened #582 for the part you don't touch: refused systemd jobs, a failed userborn.service, and exit 4 for managed units that are failed after an otherwise complete switch, as NixOS' switch-to-configuration does. The two touch different code. Merged together they conflict only where both add a mod tests to the end of activate.rs.

Your tests run create_etc_files against a temp /etc. If an end-to-end check would help, we have a container test (testFlake/container-tests/) that runs bin/activate and checks the exit status. Adapted to your semantics, it would cover:

* a non-empty directory at a managed target: exit 1, the error names the path, the directory is left alone, and the profile's other `/etc` entries are still written;

* with the path freed, the same profile exits 0 and the target holds the managed content;

* an unmanaged file at an entry without `replaceExisting`: exit 0, with the file untouched.

I'm happy to push it to your branch or open it as a follow-up once this lands, whichever you prefer.

One question about the last case. We made an unmanaged file in the way exit 1 rather than warn. Our reasoning was that the entry wasn't applied, and a copy entry in the same spot was already an error at the file level. #378's VM test expects a failure there too. I can see the case for keeping it a warning: a distribution image that ships a file at a path a module manages without replaceExisting would go from a warning to a failed activation. Was that the reason, or is there another case you had in mind? Either way, it would help a caller if the warning stayed easy to find in the output, since the exit status won't flag it.

That is actually an oversight on my end. On main copy_file returned an error for this case and the symlink branch only warned, and I made both warn to line them up. Should have gone the other way.

Feel free to push the container test onto my branch. In the meantime, I dropped the no-replace entry from existing-files.nix : activation now stops before restarting services, so that test would have lost its systemd coverage. Your file is arguably the better place for that case.

I added a CHANGELOG entry since this change would now be a breaking change.

#582 LGTM

@jeremy

jeremy commented Oct 14, 2026

Copy link
Copy Markdown

Thanks! I don't have push access to your fork, so the container test is on mine: c395fee, branch jeremy/551-container-test, on top of your 03f5fdb. To take it: git pull https://github.com/jeremy/system-manager jeremy/551-container-test. It adds testFlake/container-tests/etc-activation-failures.nix. It's written against the branch as it stands at 03f5fdb and runs bin/activate end to end:

  • A non-empty directory at a target the entry may replace (replaceExisting): exit 1, and the error names the path. The directory and its contents are left alone, and the profile's other entries are still written.
  • The same profile once the path is free: exit 0. The target links to the managed content, and no backup is made.
  • An unmanaged file at an entry without replaceExisting, both a linked entry and a copied one: the file is left alone, a warning names it, and the exit is 0. Deactivating leaves the file too.

The last case encodes the warning as it is today: the CHANGELOG entry and the existing-files.nix change you mentioned aren't on the branch yet. When you make it an error, change UNMANAGED_FILE_EXIT = 0 to 1; the warning-text assertions should still match if the message stays the same. Two other places assume the warning:

  • testFlake/vm-tests/example.nix activates with an unmanaged /etc/foo_test in the way through vm.succeed(...) and then greps for the message. With an error, that becomes a vm.fail(...) (or an exit-status check).
  • existing-files.nix's no-replace-test entry, which you said you'd dropped.

I ran it on your branch plus this commit on GitHub's hosted runners. It passes on Debian 13 and Ubuntu 24.04 and 26.04, and the rest of the container suite was green there too (served from the numtide cache for 03f5fdb). Ubuntu 22.04 I couldn't check: on those runners a 22.04 container never finishes booting (logind and networkd time out), for every test, whatever the branch. Your nixbot run will cover it.

On how the two PRs fit together: once #551 and #582 are both in, an /etc failure here and #582's ActivationFailure::Incomplete both exit 1. Your early return before the services phase also keeps #582's unit checks from running on a half-written /etc, which is what we'd want.

@jfroche

jfroche commented Oct 14, 2026

Copy link
Copy Markdown
Member

Thank you both @ndam-hexagon and @jeremy for all your work on this. It really proves error handling needs some love.

I would merge this one before #582

After that, and based on @picnoir recommendation in #378 (review) , I will try to reduce the possibility to face these /etc issues using pre-flight checks. home-manager is also doing this.

I would also like to prevent write error using the idea behind atomicSymlink from nixpkgs. For the moment we remove the file then try to symlink, we should write to a temporary target and rename, it would make the operation more atomic. Ideal would be what @r-vdp described in #212 but that one is much larger.

ndam-hexagon and others added 2 commits October 15, 2026 10:18
Co-authored-by: Jean-François Roche <jfroche@pyxel.be>
Co-authored-by: Jean-François Roche <jfroche@pyxel.be>

This branch has not been deployed

No deployments
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.

3 participants