Skip to content

CFE-90: reconcile storage mount options when they drift (opt-in remount)#6222

Open
nickanderson wants to merge 5 commits into
cfengine:masterfrom
nickanderson:CFE-90/master_mount_options
Open

CFE-90: reconcile storage mount options when they drift (opt-in remount)#6222
nickanderson wants to merge 5 commits into
cfengine:masterfrom
nickanderson:CFE-90/master_mount_options

Conversation

@nickanderson

@nickanderson nickanderson commented Jul 9, 2026

Copy link
Copy Markdown
Member

Storage/mount promises only applied mount_options to the initial mount and (if edit_fstab is enabled) the fstab entry. A filesystem already mounted with different options was never corrected, because mount -a skips already-mounted filesystems. This PR adds opt-in reconciliation of a live mount when its options drift from the promise, and along the way fixes several long-standing storage-promise bugs (CFE-1539, CFE-1863, CFE-2350, CFE-3366).

Opt-in live remount reconciliation (CFE-90, CFE-1864)

New mount body attributes:

  • remount (default false) — reconcile a live mount's options when they differ. When false, a mounted filesystem with the correct source is kept regardless of option drift (backwards compatible; options still drive the initial mount and, with edit_fstab, the fstab entry).
  • remount_methods (default { remount }) — mechanisms tried in order, re-reading the live mount after each (the kernel returns success from a remount even when it silently ignores NFS-negotiated options). Defaults to the non-disruptive in-place remount only; the disruptive unmount_mount (which tears the filesystem down and back up) is opt-in, and is required to change options a live remount cannot — e.g. NFS-negotiated vers=/rsize=, or the server.
  • remount_timeout — protect against a hung or unreachable server.

Option comparison (live mount). Only the options the promise names are enforced; any option it does not name is left unpoliced — its provenance is unknown (kernel-negotiated like vers=/rsize=/proto=/sec=, or added by a prior manual mount -o, indistinguishable). The promise is first resolved with util-linux "last wins" semantics — exactly as mount -o applies a list, a later option overrides an earlier conflicting one, so defaults,ro is a read-only mount and ro,rw is rw. Each surviving option must then hold on the live mount: its inverse absent, and it either present or a default-on flag. Inverse pairs (noatime/relatime, hard/soft, ro/rw, sync/async, generic no<opt>/<opt>) and tcp/udpproto= aliases are recognized; an option the promise specifies (including a negotiated one such as rsize=8192) must be present. A correctly-mounted filesystem converges instead of being reported changed every run, and an override (ro shadowing an earlier rw) is logged at verbose.

The defaults pseudo-option. defaults (= rw,suid,dev,exec,auto,nouser,async, per mount(8)) is never echoed by the kernel, so it's expanded to its checkable components rw,suid,dev,exec,async and subjected to the same last-wins resolution. It holds unless one of the violating negatives is present — ro (vs rw), nosuid/nodev/noexec (vs suid/dev/exec), or sync (vs async) — and a later explicit option overrides the matching component (so defaults,ro requires read-only, defaults,nosuid allows nosuid). auto/nouser are fstab / mount-permission concepts, not runtime state, so they aren't enforced. When reconciling a drifted defaults mount in place, the remount command likewise uses rw,suid,dev,exec,async (util-linux doesn't apply the options implied by a bare defaults on a remount), and mount's own last-wins applies any trailing override — so a mount that drifted to ro/nosuid/etc. is restored non-disruptively rather than only via unmount_mount.

fstab maintenance for already-mounted filesystems (CFE-1539)

Previously a filesystem already mounted with the correct source was reported "mounted as promised" and fstab was never consulted, so a missing fstab entry was not restored and an options change was not written until the mount happened to be redone. VerifyInFstab now runs on the mounted-correctly path too (when edit_fstab => "true"), deliberately independent of the opt-in live remount: keeping fstab correct is the documented behavior of mount_options. fstab option comparison uses strcmp because option order matters.

Surgical single-filesystem mount, not mount -a (CFE-1863)

A storage promise for a not-yet-mounted filesystem used to arm mount -a (mount -va on Linux), which mounts every unmounted fstab entry — unrelated devices and foreign filesystem types included — as a side effect of a single promise. The not-mounted path now mounts just the promised filesystem surgically (VerifyMount), then persists it to fstab. The mount -a mechanism (MountAll) is retained only for the explicit mountfilesystems agent-control attribute, which still means "mount everything in fstab".

Target a specific mount on unmount (CFE-2350)

An unmount promise that named mount_source/mount_server was logged as "probably an error", and the server was never used to pick which mount to act on — so you couldn't unmount one specific mount (e.g. from a server being migrated away) without affecting others. The bogus warning is removed, and the server (host) is now part of the "mounted correctly" identity check, gated on remount or unmount so it only engages when the promise opts into disruptive mount management. An unmount promise that finds a different filesystem at the mount point leaves it — and its fstab entry — untouched, and LiveMountConverged treats the server as identity so a remount-in-place that can't change it escalates to unmount_mount.

Correct dry-run / warn reporting (CFE-3366)

The mount, unmount and remount outcomes are based on the promise action (MakingInternalChanges) rather than a bare !DONTDO. A dry-run (-n) or warn promise now reports WARN without defining promise_repaired, so dependent promises no longer fire on a no-op run.

Supporting mount-info fixes

  • GetFstabEntryOptions returned the fstab type field instead of the options field (spurious rewrite every run).
  • ReplaceFstabEntry leaked the previous entry string.
  • Foreign-filesystem detection is restored by keeping the fstype separate from the kernel-resolved options (options vs raw_opts).
  • The mount point directory is again created unconditionally before mounting.
  • "device busy" interruptions are logged at LOG_LEVEL_ERR (the outcome is INTERRUPTED), and leaked options strings on the error paths are freed.

Testing

Unit coverage in tests/unit/nfs_test.c (option subset matching, inverse/alias pairs, the defaults negative-violation check, contradiction detection, and the defaultsrw,suid,dev,exec,async remount expansion), runnable unprivileged via make -C tests/unit check. The behavioral NFS reconcile/escalation, fstab maintenance, and surgical single-filesystem mount — which need root and a real NFS server — are covered by the system-testing PR and were exercised against a loopback NFS export during development.

Commits

  1. Added opt-in remount reconciliation for storage mount options (CFE-90, CFE-1864, CFE-3366)
  2. Added unit tests for mount option matching and contradiction detection
  3. Maintained fstab entries for already-mounted filesystems (CFE-1539)
  4. Mounted a single filesystem surgically instead of running mount -a (CFE-1863)
  5. Allowed mount promises to target a specific server (CFE-2350)

References

  • mount -a skipping already-mounted filesystems, and defaults = rw,suid,dev,exec,auto,nouser,asyncmount(8)
  • NFS options negotiated by client/server (reported in /proc/mounts) and NFS-specific options not modifiable on remount — nfs(5)

Resolves CFE-90 (and its duplicate CFE-1864), CFE-1539 (fstab maintenance), CFE-1863 (mount -a scope), CFE-2350 (target a specific mount on unmount), and CFE-3366 (dry-run/warn defining promise_repaired) for storage mount promises.

Ticket: https://northerntech.atlassian.net/browse/CFE-90

Together with: https://github.com/cfengine/system-testing/pull/693

Comment thread cf-agent/nfs.c Fixed
Comment thread cf-agent/nfs.c Fixed
Comment thread cf-agent/nfs.c Fixed
Comment thread cf-agent/nfs.c Fixed
Comment thread cf-agent/nfs.c Fixed
Comment thread cf-agent/verify_storage.c Fixed
Comment thread cf-agent/verify_storage.c Fixed
Comment thread cf-agent/verify_storage.c Fixed
Comment thread cf-agent/verify_storage.c Fixed
Comment thread cf-agent/verify_storage.c Fixed
@nickanderson

Copy link
Copy Markdown
Member Author

@cf-bottom jenkins please

nickanderson added a commit to nickanderson/core that referenced this pull request Jul 9, 2026
- Remove unnecessary NULL guards before free() in DeleteMountInfo().
  free(NULL) is a C-standard no-op; bare calls match the surrounding
  convention (FreeOptionsList, VerifyMount, etc.).

- Add assert(a != NULL) to LiveMountConverged, ReconcileMountOptions,
  FileSystemMountedCorrectly, and VerifyMountPromise to resolve the
  15 null-dereference warnings.  This follows the existing convention
  in VerifyInFstab, VerifyMount, and VerifyUnmount which already had
  these asserts.
@nickanderson

Copy link
Copy Markdown
Member Author

@cf-bottom jenkins please

@cf-bottom

Copy link
Copy Markdown

@nickanderson

Copy link
Copy Markdown
Member Author

@cf-bottom jenkins please

@cf-bottom

Copy link
Copy Markdown

@nickanderson

Copy link
Copy Markdown
Member Author

@cf-bottom jenkins please

@cf-bottom

Copy link
Copy Markdown

nickanderson added a commit to nickanderson/core that referenced this pull request Jul 11, 2026
- Remove unnecessary NULL guards before free() in DeleteMountInfo().
  free(NULL) is a C-standard no-op; bare calls match the surrounding
  convention (FreeOptionsList, VerifyMount, etc.).

- Add assert(a != NULL) to LiveMountConverged, ReconcileMountOptions,
  FileSystemMountedCorrectly, and VerifyMountPromise to resolve the
  15 null-dereference warnings.  This follows the existing convention
  in VerifyInFstab, VerifyMount, and VerifyUnmount which already had
  these asserts.
@nickanderson
nickanderson force-pushed the CFE-90/master_mount_options branch from 9de8d09 to 4aedfe0 Compare July 11, 2026 16:47
@nickanderson

Copy link
Copy Markdown
Member Author

@cf-bottom jenkins please

@cf-bottom

Copy link
Copy Markdown

@nickanderson
nickanderson force-pushed the CFE-90/master_mount_options branch 5 times, most recently from cfab7e5 to bbfbe27 Compare July 12, 2026 21:53
@nickanderson

Copy link
Copy Markdown
Member Author

@cf-bottom jenkins please

@cf-bottom

Copy link
Copy Markdown

@nickanderson
nickanderson force-pushed the CFE-90/master_mount_options branch 3 times, most recently from ed434fe to 0669916 Compare July 13, 2026 01:18
@nickanderson
nickanderson requested a review from larsewi July 13, 2026 13:23
@nickanderson
nickanderson marked this pull request as ready for review July 13, 2026 13:23
@nickanderson

Copy link
Copy Markdown
Member Author

@cf-bottom jenkins please

@cf-bottom

Copy link
Copy Markdown

@nickanderson
nickanderson removed the request for review from larsewi July 14, 2026 03:17
@nickanderson
nickanderson marked this pull request as draft July 14, 2026 03:17
@nickanderson
nickanderson force-pushed the CFE-90/master_mount_options branch 2 times, most recently from c71e312 to c8ab003 Compare July 14, 2026 13:03
@nickanderson
nickanderson requested a review from larsewi July 14, 2026 13:14
@nickanderson
nickanderson marked this pull request as ready for review July 14, 2026 17:37
@nickanderson

Copy link
Copy Markdown
Member Author

@cf-bottom jenkins please.

@cf-bottom

Copy link
Copy Markdown

Comment thread cf-agent/nfs.c Outdated
* option and its bare form (e.g. noexec/exec). */
{
/* A "no"-prefixed option vs its bare form, in either direction. */
if ((strncmp(a, "no", 2) == 0) && (strcmp(a + 2, b) == 0))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Use StringEqual()/StringNEqual()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in ab145c1: switched to StringEqual/StringEqualN here (and the other new comparisons in this file).

Comment thread cf-agent/nfs.c Outdated
/* Parse comma-separated options string into an array of individual option strings.
* Returns a newly allocated array of char* pointers, terminated by NULL.
* Caller must free the array itself (not the individual strings). */
static char **ParseOptionsList(const char *opts)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

See if you can use

Seq *SeqStringFromString(const char *str, char delimiter);

or

char **String2StringArray(const char *str, char separator);
void FreeStringArray(char **strs);

instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in ab145c1: replaced the hand-rolled ParseOptionsList/FreeOptionsList with SeqStringFromString.

Comment thread cf-agent/nfs.c Outdated
free(arr);
}

static bool InverseOptions(const char *a, const char *b)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe ConflictingOptions would be a better name for this function?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done: renamed to ConflictingOptions in ab145c1.

Comment thread cf-agent/nfs.c Outdated
Comment on lines +311 to +321
for (int i = 0; arr[i] != NULL; i++)
{
const char *tok = (strcmp(arr[i], "defaults") == 0)
? "rw,suid,dev,exec,async" : arr[i];
int w = snprintf(buf + n, sizeof(buf) - n, "%s%s", (i > 0) ? "," : "", tok);
if ((w < 0) || ((size_t) w >= sizeof(buf) - n))
{
break; /* option list longer than CF_BUFSIZE; stop appending */
}
n += (size_t) w;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider using

char *StringJoin(const Seq *seq, const char *sep);

instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in ab145c1: builds a Seq and joins it with StringJoin instead of the manual snprintf append.

Comment thread cf-agent/nfs.c Outdated
Comment on lines +348 to +352
static const char *const comps[] = { "rw", "suid", "dev", "exec", "async" };
for (size_t c = 0; c < sizeof(comps) / sizeof(comps[0]); c++)
{
SeqAppend(eff, xstrdup(comps[c]));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This you could one line with something like

Seq *eff = SeqStringFromString("rw,suid,dev,exec,async", ",");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in ab145c1: SeqStringFromString("rw,suid,dev,exec,async", ',') appended via SeqAppendSeq + SeqSoftDestroy.

Comment thread cf-agent/nfs.c Outdated
/* Last wins: skip this option if a later one overrides it (its inverse)
* or repeats it. */
bool overridden = false;
for (size_t j = i + 1; j < SeqLength(eff); j++)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not a big deal, but calling SeqLength() once before the loop is more efficient then every iteration of the loop.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in ab145c1: SeqLength is taken once into a local before the loop (here and in the option-matching helper).

Comment thread cf-agent/nfs.c Outdated
}
if (StringEqual(a->mount.mount_type, "panfs"))
{
snprintf(comm, CF_BUFSIZE, "%s -t panfs -o %s %s%s %s", CommandArg0(VMOUNTCOMM[VSYSTEMHARDCLASS]), opts, host, rmountpt, mountpt);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It would be nice if you check the returned value of snprintf. Check that the buffer was not truncated. If truncation is impossible, then at least an assert would be nice to test that assumption in DEBUG builds.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in ab145c1: the mount/unmount/remount command builds now use NDEBUG_UNUSED int ret = snprintf(...); assert(ret >= 0 && ret < CF_BUFSIZE);, matching the fstab builders in this file.

Comment thread cf-agent/nfs.c Outdated

free(line);
}
else if ((strstr(line, "busy")) || (strstr(line, "Busy")))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please use explicit comparisons as described in CONTRIBUTING.md. E.g. strstr(line, "busy") != NULL

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in ab145c1: both busy checks now use strstr(line, "busy") != NULL.

Comment thread cf-agent/verify_storage.c Outdated
if (a->mount.editfstab)
/* CFE-90: distinguish "not mounted at all" from "mounted, but not as
* promised" (wrong source, or drifted options with remount enabled). */
for (size_t i = 0; i < SeqLength(GetGlobalMountedFSList()); i++)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider calling GetGlobalMountedFSList only once. And consider moving SeqLength out of the loop condition to avoid it being called every iteration like mentioned before.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — GetGlobalMountedFSList() is now called once into a local and SeqLength is hoisted out of the loop condition.

While here: this file uses raw strcmp throughout (no StringEqual anywhere). I switched the new strcmps to StringEqual in nfs.c per your other comment, but left verify_storage.c alone to match its own style. Convert them here too while I'm in this PR, or leave as-is?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't see that this was fixed. Maybe it ended up in the wrong commit?

nickanderson added a commit to nickanderson/core that referenced this pull request Jul 22, 2026
Addressed larsewi's review on PR cfengine#6222 (no behavior change; nfs_test still
passes):

- Replaced hand-rolled option parsing/joining with SeqStringFromString and
  StringJoin, and strcmp/strncmp with StringEqual/StringEqualN.
- Renamed InverseOptions to ConflictingOptions.
- Hoisted SeqLength and GetGlobalMountedFSList out of loops.
- Checked snprintf for truncation and used explicit != NULL comparisons.

Ticket: CFE-90
Changelog: None

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nickanderson
nickanderson requested a review from larsewi July 22, 2026 17:40

@larsewi larsewi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks! Some minor nit picks. It would also be nice if you could amend the fixes from the last commit to the relevant commits that introduced them. It would make it easier for me to review this PR commit for commit. It's already very large / difficult to review / hard to keep all the context in my head.

Comment thread cf-agent/nfs.c Outdated
Comment on lines +184 to +187
static bool ConflictingOptions(const char *a, const char *b)
/* True if 'a' and 'b' are mutually exclusive mount options that cannot both
* hold: ro/rw, hard/soft, sync/async, noatime/relatime, or a "no"-prefixed
* option and its bare form (e.g. noexec/exec). */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Put docs above the function signature and use the doxygen (double star) syntax. This way editors will show this text when you hover over the symbol name.

Suggested change
static bool ConflictingOptions(const char *a, const char *b)
/* True if 'a' and 'b' are mutually exclusive mount options that cannot both
* hold: ro/rw, hard/soft, sync/async, noatime/relatime, or a "no"-prefixed
* option and its bare form (e.g. noexec/exec). */
/**
* True if 'a' and 'b' are mutually exclusive mount options that cannot both
* hold: ro/rw, hard/soft, sync/async, noatime/relatime, or a "no"-prefixed
* option and its bare form (e.g. noexec/exec).
*/
static bool ConflictingOptions(const char *a, const char *b)

@nickanderson nickanderson Jul 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — moved the docs above the signature and switched to /**. Applied the same to the other new helpers in this file (OptionPresent, RemountOptionString, OptionsSubsetMatches, LiveMountConverged, ReconcileMountOptions) for consistency.

Comment thread cf-agent/nfs.c Outdated
return false;
}

static bool InversePresent(const char *opt, const Seq *actual)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This should probably also be renamed, since the other one was?

@nickanderson nickanderson Jul 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — renamed to ConflictingOptionPresent.

Comment thread cf-agent/nfs.c Outdated
Comment on lines +1407 to +1418
NDEBUG_UNUSED int ret;
if (ropts != NULL)
{
ret = snprintf(comm, CF_BUFSIZE, "%s -o remount,%s %s",
CommandArg0(VMOUNTCOMM[VSYSTEMHARDCLASS]), ropts, name);
}
else
{
ret = snprintf(comm, CF_BUFSIZE, "%s -o remount %s",
CommandArg0(VMOUNTCOMM[VSYSTEMHARDCLASS]), name);
}
assert(ret >= 0 && ret < CF_BUFSIZE);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would move the ret declaration and put the assert inside the if branches. This way the ret variable goes out of scope afterwards. Furthermore, you will see which branch triggered the assertion. And you don't save any lines of code by having it outside.

@nickanderson nickanderson Jul 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — ret is now declared and asserted inside each branch.

Comment thread cf-agent/verify_storage.c Outdated
for (size_t i = 0; i < n_mounted; i++)
{
Mount *mp = SeqAt(mounted_fs, i);
if (mp != NULL && mp->mounton != NULL && strcmp(name, mp->mounton) == 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (mp != NULL && mp->mounton != NULL && strcmp(name, mp->mounton) == 0)
if (mp != NULL && mp->mounton != NULL && StringEqual(name, mp->mounton))

@nickanderson nickanderson Jul 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done. Per my earlier question, I used StringEqual on the PR-touched lines here and left the other (untouched) strcmps in this file alone.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yepp, I would keep untouched code alone for the sake of the git history.

@nickanderson
nickanderson force-pushed the CFE-90/master_mount_options branch from ab145c1 to c1922c9 Compare July 23, 2026 15:55
@nickanderson

nickanderson commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

Thanks! Nits addressed, and the trailing fixup commit is gone — its changes, the new nits, and the CodeQL NULL-guards are all folded into the commits that introduced the code, so the PR is now five clean commits that read commit-by-commit. Each commit builds and nfs_test passes.

nickanderson and others added 5 commits July 23, 2026 22:05
Previously mount_options only affected the initial fstab write and the
initial mount; a filesystem already mounted with the wrong options was
never corrected, because `mount -a` skips already-mounted filesystems.
This adds opt-in reconciliation of a live mount when its options drift
from the promise.

New mount body attributes:
- remount (default false): manage the options of an already-mounted
  filesystem. When false a mounted filesystem with the correct source is
  considered kept regardless of option drift (backwards compatible;
  options still drive the initial mount and, with edit_fstab, the fstab
  entry).
- remount_methods (default { remount }): ordered mechanisms tried in turn,
  re-reading the live mount after each to verify it satisfies the promise.
  Defaults to the non-disruptive in-place remount only; the disruptive
  unmount_mount (which tears the filesystem down and back up, interrupting
  anything using it) is opt-in and is required to change options a live
  remount cannot, e.g. NFS-negotiated vers=/rsize= or the server. The
  kernel returns success from a remount even when it silently ignores
  NFS-negotiated options, so the resulting state is verified rather than
  the command exit status trusted.
- remount_timeout: bounds the unmount/mount path against a hung or
  unreachable server.

When reconciliation is enabled the live mount is corrected first; if
edit_fstab is set the fstab entry is then updated regardless of the live
outcome (recording intent moves the system closer to the desired state,
and the live result is reported as its own promise outcome).

The promise is matched against the live mount by first resolving it with
util-linux "last wins" semantics - exactly as `mount -o` applies a list, a
later option overrides an earlier conflicting one (so "defaults,ro" is a
read-only mount and "ro,rw" is rw) - and then checking each surviving option.
Only the options the promise names are enforced; any option it does not name
is left unpoliced, because its provenance is unknown (kernel-negotiated like
vers=/rsize=, or added by a prior manual mount, indistinguishable). An option
the promise does specify - including a negotiated one such as rsize=8192 -
must be present in the live mount, with its inverse absent. Inverse pairs
(noatime/relatime, hard/soft, ro/rw, sync/async, generic no<opt>/<opt>) and
tcp/udp<->proto= aliases are recognized. The "defaults" pseudo-option (never
echoed by the kernel) expands to its checkable components rw,suid,dev,exec,
async (mount(8): defaults = rw,suid,dev,exec,auto,nouser,async; auto/nouser
are fstab/permission concepts, not runtime state, so they are not enforced) -
so "defaults" holds unless ro/nosuid/nodev/noexec/sync is present, and a later
explicit option can override any of those components. A correctly-mounted
filesystem thus converges instead of being reported changed every run. When a
later option overrides an earlier conflicting one, that is logged at verbose.

When reconciling via an in-place remount, "defaults" is likewise expanded to
"rw,suid,dev,exec,async" in the mount command (util-linux does not apply the
options implied by a bare "defaults" on a remount), so a filesystem that has
drifted to read-only (or nosuid, etc.) is restored in place rather than only
via the disruptive unmount_mount; mount's own last-wins then applies any
trailing override (e.g. "defaults,ro" remounts as ...,ro).

Mount and unmount side effects are gated on the promise action
(MakingInternalChanges), not a bare !DONTDO: a dry-run (-n) or
action_policy => "warn" promise now reports WARN without defining the
classes body's promise_repaired set, so a dependent promise keyed on
those classes no longer fires on a no-op run (CFE-3366). A live remount
is a real side effect outside the simulate sandbox, so the normal-mode
gate is used.

Also corrects the mount-info handling this relies on:
- GetFstabEntryOptions returned the fstab type field instead of the
  options field, causing a spurious fstab rewrite and reported change
  every run.
- ReplaceFstabEntry leaked the previous entry string.
- The mounted-FS scan no longer dropped the fstype used by
  IsForeignFileSystem; fstype and kernel-resolved options are now stored
  separately (options vs raw_opts).
- Restored unconditional creation of the mount point directory before
  mounting, and stopped arming `mount -a` for an already-mounted
  filesystem.
- "device busy" interruptions are logged at LOG_LEVEL_ERR (the outcome is
  INTERRUPTED, cf. RecordInterruption), and the leaked options strings on
  the error paths are freed.
- VerifyUnmount consulted feof() after cf_pclose() had already closed the
  stream (a use-after-close): a silent, successful unmount then logged a
  bogus "Unable to read output of unmount command" and returned before the
  success cfPS(), reporting NOOP with no promise_repaired class instead of a
  change. feof() is now captured before cf_pclose() invalidates the stream.
  The unmount_mount remount method depends on this outcome being correct.

References for the kernel/OS behavior asserted in the option-matching and
convergence code: mount(8) (`mount -a` skips already-mounted filesystems;
`defaults` = rw,suid,dev,exec,auto,nouser,async) and nfs(5) (options
negotiated by client and server are reported in /proc/mounts; NFS-specific
options cannot be changed by a remount).

Ticket: CFE-90
Ticket: CFE-1864
Ticket: CFE-3366
Changelog: Title

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers OptionsSubsetMatches: named-option-only enforcement (kernel-added
options ignored), order independence for non-conflicting options, inverse
pairs (noatime/relatime, ro/rw, hard/soft, sync/async, generic no<opt>/<opt>),
tcp/udp<->proto= aliases, the "defaults" expansion (held unless
ro/nosuid/nodev/noexec/sync is present), and the "last wins" resolution of
conflicting options (defaults,ro -> read-only; ro,rw -> rw; a later explicit
option overrides a "defaults" component). Also covers RemountOptionString
(expanding "defaults" to rw,suid,dev,exec,async for the remount command). Runs
unprivileged in CI via "make -C tests/unit check"; the behavioral mount/remount
test belongs in the system-testing repo (needs root + a real NFS server).

Ticket: CFE-90
Changelog: None

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A storage promise whose filesystem was already mounted with the correct
source reported "mounted as promised" and never touched fstab, so a
missing fstab entry was not restored and an options change was not written
to fstab (CFE-1539). VerifyInFstab now also runs on the mounted-correctly
path (when edit_fstab is set), independent of the opt-in live 'remount':
keeping fstab correct is the documented behavior of mount_options, while
remounting a live filesystem stays the disruptive, opt-in part gated by
'remount'.

The fstab options comparison is left exact (order-sensitive): fstab option
order is significant for duplicated/conflicting options, and the earlier
GetFstabEntryOptions fix already stops the every-run rewrites.

Ticket: CFE-1539
Changelog: Title
A storage promise for a not-yet-mounted filesystem armed CF_MOUNTALL, so
CFEngine ran 'mount -a'/'mount -va' at the end of the pass.  That mounts
every unmounted entry in fstab - unrelated devices and foreign filesystem
types included - as a side effect of a single promise (CFE-1863).  The
not-mounted path now mounts just the promised filesystem with VerifyMount
(the existing surgical primitive), then persists it to fstab when
edit_fstab is set.

CF_MOUNTALL / MountAll are kept for the explicit 'mountfilesystems' agent
control, which still means "mount everything in fstab".

Ticket: CFE-1863
Changelog: Title
An unmount promise that named mount_source/mount_server was warned about as
"probably an error", and the server was never used to select which mount
to act on - so a policy could not unmount one specific mount (e.g. from a
server being migrated away) without affecting others (CFE-2350).

The bogus warning is removed, and the server (host) is now part of the
"mounted correctly" identity check, gated on remount or unmount so it only
engages when the promise opts into disruptive mount management.  An unmount
promise that finds a *different* filesystem at the mount point now leaves it
- and its fstab entry - untouched.  LiveMountConverged likewise treats the
server as part of identity, so a remount-in-place that cannot change the
server escalates to unmount_mount.

Ticket: CFE-2350
Changelog: Title
@nickanderson
nickanderson force-pushed the CFE-90/master_mount_options branch from c1922c9 to a5e6f18 Compare July 24, 2026 03:05
Comment thread cf-agent/nfs.c
static bool OptionPresent(const char *opt, const Seq *actual)
{
const size_t len = SeqLength(actual);
for (size_t a = 0; a < len; a++)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The convention is to use i for the index variable, then j, k, etc. for nested loops.

Comment thread cf-agent/nfs.c

Seq *in = SeqStringFromString(opts, ',');
const size_t len = SeqLength(in);
Seq *out = SeqNew(len + 4, free);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Adding four to the initial capacity does not help. I guess the thought was that you expect at most one occurrences of defaults. We add five options below (rw,suid,dev,exec,async) while removing one (defaults). Hence, we need four more slots. However, the five options below replaces the defaults option as a single element in the sequence. Thus, the final capacity is the exact length of the original sequence.

Furthermore, instead of creating two sequences, you should use

void SeqSet(Seq *set, size_t index, void *item);

on the original one to mutate it in place.

Comment thread cf-agent/nfs.c
Comment on lines +306 to +322
/* Expand "defaults" in place, preserving order for the last-wins pass. */
Seq *eff = SeqNew(16, free);
const size_t n_promised = SeqLength(promised);
for (size_t p = 0; p < n_promised; p++)
{
const char *opt = SeqAt(promised, p);
if (StringEqual(opt, "defaults"))
{
Seq *comps = SeqStringFromString("rw,suid,dev,exec,async", ',');
SeqAppendSeq(eff, comps);
SeqSoftDestroy(comps); /* eff now owns the expanded components */
}
else
{
SeqAppend(eff, xstrdup(opt));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can't you use the

static char *RemountOptionString(const char *opts);

function above? It appears to be doing the exact same thing.

It should probably be renamed to OptionStringExpandDefaults() if you decide re-use it.

Comment thread cf-agent/nfs.c Outdated
{
/* CFE-90: Entry exists - check if options differ and update if needed */
char *existing_opts = GetFstabEntryOptions(mountpt);
if (existing_opts != NULL && strcmp(existing_opts, opts) != 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Use StringEqual() here

Comment thread cf-agent/nfs.c
Comment on lines -621 to +849
cfPS(ctx, LOG_LEVEL_INFO, PROMISE_RESULT_INTERRUPTED, pp, a, "The device under '%s' cannot be removed from '%s'",
cfPS(ctx, LOG_LEVEL_ERR, PROMISE_RESULT_INTERRUPTED, pp, a, "The device under '%s' cannot be removed from '%s'",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems like it belongs in a separate commit?

Comment thread cf-agent/verify_storage.c Outdated
for (size_t i = 0; i < SeqLength(GetGlobalMountedFSList()); i++)
{
Mount *mp = SeqAt(GetGlobalMountedFSList(), i);
if (mp != NULL && mp->mounton != NULL && strcmp(name, mp->mounton) == 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Prefer StringEqual()

Comment thread cf-agent/verify_storage.c Outdated
if (a->mount.editfstab)
/* CFE-90: distinguish "not mounted at all" from "mounted, but not as
* promised" (wrong source, or drifted options with remount enabled). */
for (size_t i = 0; i < SeqLength(GetGlobalMountedFSList()); i++)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't see that this was fixed. Maybe it ended up in the wrong commit?

Comment thread tests/unit/nfs_test.c
s = RemountOptionString("defaults,noatime");
assert_true(strcmp(s, "rw,suid,dev,exec,async,noatime") == 0); free(s);
/* Everything else is passed through unchanged and order-preserved. */
s = RemountOptionString("rw,noatime"); assert_true(strcmp(s, "rw,noatime") == 0); free(s);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

StringEqual()

Comment thread tests/unit/nfs_test.c
/* "defaults" expands to its checkable positives so an in-place remount
* restores a mount that drifted to ro/nosuid/etc (util-linux does not honor
* the options implied by "defaults" on a remount). */
s = RemountOptionString("defaults");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Remember to free memory

Comment thread cf-agent/verify_storage.c
* promise opts in via remount or unmount. */
if ((a->mount.remount || a->mount.unmount)
&& (a->mount.mount_server != NULL)
&& ((mp->host == NULL) || (strcmp(mp->host, a->mount.mount_server) != 0)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

StringEqual()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

5 participants