Skip to content

[Bug]: config.yaml is rewritten in place, and an interrupted save can silently empty or truncate the inventory #337

Description

@VirSanctus

What happened?

Saving config.yaml is neither atomic nor durable, and a damaged file is then accepted without complaint on the next load. Together those can lose an inventory.

A save is not durable. PhysicalTextFileStore.WriteAllTextAsync calls File.WriteAllTextAsync, which never flushes to disk, so a save that has returned successfully can still be lost or left partial if the machine loses power shortly afterwards.

A save is not atomic. File.WriteAllTextAsync opens with FileMode.Create, so the file is truncated before any bytes go back. Sampling the size during one rpk servers add on a 960 KB config (30,000 resources) catches it at 0 and 8192 on most runs, plus one intermediate size. The window is short: about a millisecond per save on my machine, so a process being killed inside it is unlikely. The realistic paths to a damaged file are power loss (where the missing flush is what bites) and a write that fails part way, such as running out of space, which leaves the truncated file behind.

The only backups are the .bak.<timestamp> copies taken during schema migrations, plus the optional Git integration when it is configured. BackupOriginalAsync writes through the same unflushed, in-place path, so the one existing safety net is written unsafely too.

A damaged file loads without an error, and the next save makes it permanent.

Truncating that config at 20 offsets gives three outcomes (on this synthetic file, where every entry is exactly 32 bytes, so the mix depends on the entry layout):

Outcome Count What the user sees
Plausible smaller inventory 11 Up to 29,687 of 30,000 resources, no error, exit 0
Empty inventory 7 Hardware (0), no error, exit 0
Unhandled exception 2 YamlDotNet stack trace, SIGABRT, exit 134

The first outcome is the most common and the most dangerous, because nothing about the file looks wrong. A file cut at an entry boundary is byte for byte a valid smaller config. Cut mid-name, the last entry survives with a truncated name: at one offset the 256th server loads as srv, which could collide with a real resource.

In the empty case the file still held 255 complete entries:

$ rpk summary
Breakdown
├── Hardware (0)
├── Systems (0)
└── Services (0)
    └── IP Addresses: 0
$ echo $?
0

One unreadable entry discards the whole file, not just that entry. The next save writes the reduced state back:

$ rpk servers add brand-new
Server 'brand-new' added.
$ wc -c config.yaml
71 config.yaml

8,192 bytes holding 255 recoverable resources became 71 bytes, with no backup.

The CLI says nothing: exit 0, empty stderr, because LoadAsync runs in CliBootstrap.RegisterInternals before any logging provider is registered, and before config.SetExceptionHandler is installed, which is also why the crash outcome escapes as a raw .NET exception with a core dump rather than a formatted message. The Web app does log it (fail: DocMigrator.Yaml.YamlMigrationDeserializer[0] Failed to deserialize YamlRoot) but still serves an empty inventory with no warning in the UI. I confirmed that against the 2.1.0 image.

Until the first write, the surviving bytes are still visible on the /yaml page, so a user who notices can recover by hand.

Proposal

Two halves of one failure chain. They would be two PRs, and neither covers the whole problem on its own.

1. Treat a swallowed deserialization failure as fatal.

Today the load goes: read the file, hand it to the migration deserializer, build a YamlRoot. When that last step throws, the exception is caught, logged and turned into nothing, which becomes an empty YamlRoot. LoadAsync then clears the in-memory resources and connections and adds nothing back, and the app carries on as though the inventory really were empty. That is how 255 valid entries became Hardware (0) with exit 0 above.

The change would be to stop rather than continue: report that the config looks damaged and refuse to load it, so nothing overwrites the file and the user can still recover from it.

The distinction between "does it parse" and "did it load cleanly" matters here. The 8,192-byte cut ends in - kind: Se, which is perfectly valid YAML, just a string. The failure happens one step later, mapping that entry onto a Resource. A syntax check would wave it through, so the guard has to key on the deserialization failure itself.

This half is cheap, portable, testable in the existing ./Tests style, and covers corruption from any source, including a bad hand edit. It catches the empty and crash outcomes. It cannot catch a file cut at an entry boundary, because nothing in that file says entries are missing, so it does not help with the most common outcome. This continues what #155 set out to do for migrations.

2. Write atomically, and flush. Write a temp file in the same directory, flush it to disk, then rename over config.yaml. This is the only half that prevents the silently shrunken inventory, and the flush is what makes a completed save survive power loss. Doing it inside PhysicalTextFileStore.WriteAllTextAsync also covers the /yaml editor's save (YamlFileComponent.razor:169) and the migration backup.

Two things I am not sure about, because they touch how people mount the config:

It would also need to clean up the temp file on failure: when GIT_TOKEN is set the Web app stages every non-ignored change in the config directory, so a leftover file would be committed. The .bak.<timestamp> backups already have that property.

On testing: the atomic write is hard to test the way this project prefers. Forcing a crash at the right moment is not deterministic, and permission or disk-full tricks behave differently across platforms. A fake ITextFileStore would exercise the stand-in rather than the real write path. I would cover what is observable (content correct after a write, no leftover temp files) and rely on the rename guarantee for the rest. The load-side guard is straightforward to test: a config with an entry that cannot be deserialized should produce an error rather than a silently reduced inventory.

Happy to implement either or both, in whichever order you prefer.

Where does this occur?

Both / Not sure

How do you run RackPeek?

Docker

RackPeek version

2.1.0 (reproduced with a CLI built from the RackPeek-2.1.0 tag, rpk --version reporting v2.1.0; the Web UI check used the Docker image built from the same code)

Steps to reproduce (optional)

RPK_YAML_DIR must be absolute: a relative value resolves against the binary's directory, not the working directory, and silently creates a fresh empty config there.

# 1. a large config, 30k resources, about 960 KB
export CFG=$(mktemp -d)
{ echo "version: 3"; echo "resources:";
  for i in $(seq -w 0 29999); do echo "- kind: Server"; echo "  name: srv$i"; done;
  echo "connections: []"; } > "$CFG/config.yaml"

# 2. sample the size while one save runs
#    (if you see no intermediate sizes, raise the loop count)
( for i in $(seq 1 60000); do stat -c %s "$CFG/config.yaml"; done > /tmp/sizes.txt ) &
RPK_YAML_DIR="$CFG" rpk servers add srv99999
wait
sort -n /tmp/sizes.txt | uniq -c      # 0 and 8192 appear on most runs

# 3. a cut that loads as empty
export BAD=$(mktemp -d)
head -c 8192 "$CFG/config.yaml" > "$BAD/config.yaml"   # 255 complete entries remain
RPK_YAML_DIR="$BAD" rpk summary                        # Hardware (0), exit 0, nothing on stderr
RPK_YAML_DIR="$BAD" rpk servers add brand-new          # succeeds
wc -c "$BAD/config.yaml"                               # 71

# 4. the more common outcome: a cut in the middle of a name
export PARTIAL=$(mktemp -d)
head -c 8208 "$CFG/config.yaml" > "$PARTIAL/config.yaml"
RPK_YAML_DIR="$PARTIAL" rpk summary                    # Hardware (256), no error
RPK_YAML_DIR="$PARTIAL" rpk servers get srv            # the 256th entry, name truncated to "srv"

# 5. a cut at an entry boundary is indistinguishable from a smaller config
export BOUNDARY=$(mktemp -d)
head -c 8182 "$CFG/config.yaml" > "$BOUNDARY/config.yaml"
RPK_YAML_DIR="$BOUNDARY" rpk summary                   # Hardware (255), valid YAML, nothing to detect

Activity

  1. added 2 commits that reference this issue on Sep 27, 2026
  2. VirSanctus commented on Sep 28, 2026

    @VirSanctus
    ContributorAuthor

    @Timmoth thanks for #343. Step 3 of the reproduction above still ends with a 71-byte config.yaml on current staging (5f99c51): rpk summary now exits 5 with "nothing has been changed", but by then the file has already been replaced with an empty config, so rpk servers add brand-new succeeds. The only .bak left holds the empty file too (both commands ran within the same second), so the inventory is gone.

    A damaged v4 config is refused and left untouched, as intended. The difference is that the step 3 file says version: 3, so it takes the migration path, and that path saves before the new check runs:

    • ResourceYamlMigrationService.DeserializeAsync turns a failed deserialize into new YamlRoot() (line 35) and passes it to postMigrationAction (line 38), which is SaveRootAsync. That writes version: 0 / resources: [] / connections: [] before the root.Version <= 0 check in LoadUnderLockAsync (line 292) throws.
    • The migration writes a .bak of the original first, but backup names have one-second resolution. A second load in the same second migrates the empty file and overwrites that .bak with it. This happens with two quick CLI commands, and in Docker when the first page load comes within a second of container start.

    The existing test a_file_that_is_not_a_rackpeek_config_is_rejected already hits this path: hello: world is rewritten to the empty version: 0 config, but the test only checks the output. Adding an assertion that the file is unchanged makes it fail on staging. Skipping the post-migration save when Deserialize returns null lets the version check fire first, and the rest of the suite, including the legacy version-less migration test, still passes.

    Also, on the single-file bind mount mentioned in the issue (-v ./config.yaml:/app/config/config.yaml): on staging every save now fails with IOException: Device or resource busy from the rename, and with a config from 2.1.0 the migration save on load fails too, so / returns 500. 2.1.0 saved fine with the same mount. The fallback suggested in the issue (a direct write when the rename fails) is one way to keep that setup working. Directory mounts, which is what the docs show, are unaffected.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions