Skip to content

fix(dashmate)!: stop read-only commands rewriting config and clobbering concurrent updates - #4248

Open
shumkov wants to merge 4 commits into
v4.2-devfrom
fix/dashmate/4242-readonly-config-clobber
Open

fix(dashmate)!: stop read-only commands rewriting config and clobbering concurrent updates#4248
shumkov wants to merge 4 commits into
v4.2-devfrom
fix/dashmate/4242-readonly-config-clobber

Conversation

@shumkov

@shumkov shumkov commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

Closes #4242.

Config's constructor delegates to setOptions(), which marks the config changed — so every config is dirty the moment it is read off disk. BaseCommand.finally() persists the whole config file whenever anything is dirty, so every successful command, including pure readers like dashmate config get and dashmate core cli, rewrote config.json from its own load-time snapshot.

That turns any overlapping pair of commands into a lost update:

  1. A reader loads the config at T0.
  2. dashmate config set …docker.image X loads, writes, prints success and exits 0 at T1.
  3. The reader finishes and writes its T0 snapshot at T2.

The setting silently reverts even though config set reported success. Reported from a real rolling masternode image-pin update, where some nodes kept the old image digest.

Two things made this hard to spot:

  • In single-process use the rewrite is byte-identical (verified against a real 58 KB, 4-config config.json), so it never shows up outside real concurrency.
  • The worst window is not a CLI command at all — scripts/helper.js reads config once at startup and writes it back on certificate renewal months later. Its guard is even commented "Persist config if it was migrated", and this bug made it fire on every startup.

What was done?

Hydration is no longer a mutation. Config's constructor resets the flag after the initial setOptions(). ConfigFile already did exactly this; Config was just missing it. Callers that build a config which must reach disk (createConfigFile, createConfig) already mark it explicitly.

The dirty flag now means one thing. writeConfigTemplates() no longer calls config.markAsSaved() — rendering templates is not persisting config.json. The repository clears that state instead, and only after the write succeeds (previously it was cleared before the I/O, so a throwing write left configs claiming to be saved).

Writes are atomic. write-file-atomic, which preserves the existing file mode — config.json holds masternode privateKey, Core RPC password and ZeroSSL apiKey.

Stale writes are refused, not merged. The repository remembers what it last observed on disk and, under a short proper-lockfile lock held only across compare-and-replace, refuses to write if that changed. The lock is deliberately not held for command duration — that would serialize every invocation behind a multi-minute start. Because the lock lives at the repository seam, the helper daemon and the SSL renewal paths inherit it without going through BaseCommand.

Merging concurrent edits was considered and rejected: a silently mis-merged config is worse than the bug. On refusal the in-memory state is parked at config.json.rejected-<stamp>-<pid>-<n> (mode 0600) and named in the error, so material generated during a command — operator keys, a spork key, a miner address — is never lost. Lock-release failures are contained so they can never replace that error with a message about lock bookkeeping.

Service templates are rendered only for configs that actually changed. Previously every command re-rendered and rewrote all of them — 40 files for a four-config home directory, on config get as much as on setup. That was never intentional: hydration marked every config changed, so the existing "changed configs only" filter matched all of them.

This is safe because upgrading Dashmate records the new version in the config file even when no migration appliesgetConfigFormatVersion returns max(newestMigrationKey, packageVersion), and migrateConfigFile stamps it regardless of whether any migration matched. read() then marks every config changed because the recorded version moved, so a release that edits a template still reaches every node on its next command. That property is load-bearing and was untested; it is pinned now.

How Has This Been Tested?

183 dashmate unit tests pass; eslint clean on changed files. Every behavioural change was written test-first and observed failing against the unfixed code:

Test Before After
Hydrated config is clean expected true to be false
Read-only command leaves the file untouched
Stale write refused instead of clobbering expected undefined to exist
Concurrent first-run write refused
Deleted config file not resurrected
Saved flags cascade only after a successful write
Waits for a lock held by another process expected 13 to be at least 350
Version recorded on upgrade with no applicable migration expected '98.0.0' to equal '99.0.0'

Two test-design traps were found and avoided:

  • Asserting file content is unchanged would pass against the buggy code, because re-serializing an unchanged config is byte-identical. The read-only test asserts the write does not happen (mtime + inode).
  • The lock test spawns a real second process holding the lock. Stubbing the lock out makes it fail (write completes in 13ms instead of waiting 700ms), so it pins the lock itself rather than the staleness comparison.

Local E2E runpackages/dashmate/test/e2e/localNetwork.spec.js, 5/5 passing in 30 minutes: setup → start → restart → stop → reset on a real local network. This is the job CI skips for a dashmate-only diff, and it is the coverage that matters most here: those are the commands that actually mutate config, so setup generating spork/operator keys and reset restoring defaults both exercised the new write path. No .rejected-* files were produced across a full lifecycle.

The design and the diff were both reviewed by independent cross-model passes, which caught two P1 bugs in the first implementation (concurrent first-run writes still clobbering; a deleted config file being silently resurrected) and the lock-release masking issue. All are fixed and covered above.

Breaking Changes

A command that would previously have silently overwritten a concurrent configuration change now fails with a non-zero exit and ConfigFileConflictError (code: 'DASHMATE_CONFIG_FILE_CONFLICT'). Automation that relied on last-writer-wins will now see failures where it previously saw silent data loss. The refused configuration is written to the .rejected-* file named in the error.

Two documented limits (packages/dashmate/docs/config/index.md):

  • Cooperative. A pre-fix Dashmate process — including a helper started before an upgrade — can still overwrite a newer one. Upgrade everything and restart the node.
  • Local filesystems only. Not claimed for a home directory on NFS.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

🤖 Generated with Claude Code

…ng concurrent updates

`Config`'s constructor delegated to `setOptions()`, which marks the config
changed, so every config was dirty the moment it was read off disk.
`BaseCommand.finally()` persists the whole config file whenever anything is
dirty, so every successful command — including pure readers like `config get`
and `core cli` — rewrote `config.json` from its own load-time snapshot.

That turns any overlapping pair of commands into a lost update: a reader loads
at T0, `config set` writes and exits 0 at T1, and the reader writes its stale
snapshot at T2. The setting silently reverts even though `config set` reported
success. It is invisible in single-process use because re-serializing an
unchanged config is byte-identical.

- Hydrating a config no longer marks it changed. Callers that build a config
  which must reach disk already mark it explicitly.
- Rendering service templates no longer clears persistence state; the
  repository clears it, and only after the write actually succeeds.
- Writes are atomic (`write-file-atomic`, preserving file mode — `config.json`
  holds masternode keys, RPC passwords and SSL API keys).
- A write is refused when the file changed on disk since it was read, under a
  short `proper-lockfile` lock held only across compare-and-replace, never for
  the duration of a command. The refused state is parked next to `config.json`
  so material generated during the command is never lost.

The lock is cooperative and scoped to local filesystems; both limits are
documented for operators.

BREAKING CHANGE: a command that would have silently overwritten a concurrent
configuration change now fails with a non-zero exit and
`ConfigFileConflictError`. Automation that relied on the previous
last-writer-wins behaviour will now see failures where it previously saw
silent data loss.

Tests would have caught this in CI:
  hydrated config is clean                    ✖ before → ✔ after
  stale write refused, not clobbering         ✖ before → ✔ after
  concurrent first-run write refused          ✖ before → ✔ after
  deleted config file not resurrected         ✖ before → ✔ after
  saved flags cascade only after a write      ✖ before → ✔ after
  waits for a lock held by another process    ✖ before → ✔ after

The lock test spawns a real second process and fails without the lock (write
completes in 13ms instead of waiting 700ms), so it pins the lock rather than
the staleness comparison.

Closes #4242

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shumkov
shumkov requested a review from QuantumExplorer as a code owner July 28, 2026 14:49
@github-actions github-actions Bot added this to the v4.2.0 milestone Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Dashmate now keeps hydrated configurations clean, renders templates independently of configuration change tracking, and protects config.json writes with cross-process locking, baseline comparison, atomic persistence, and rejected snapshots. Tests cover migration, persistence, read-only behavior, and concurrency.

Changes

Dashmate configuration safety

Layer / File(s) Summary
Hydration and rendering lifecycle
packages/dashmate/src/config/Config.js, packages/dashmate/src/oclif/command/BaseCommand.js, packages/dashmate/scripts/helper.js, packages/dashmate/src/templates/writeConfigTemplatesFactory.js, packages/dashmate/test/unit/config/Config.spec.js
Hydrated configs no longer become changed automatically; template rendering uses the configs captured before persistence, without marking them saved.
Locked and conflict-aware persistence
packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js, packages/dashmate/src/config/errors/ConfigFileConflictError.js, packages/dashmate/package.json, .pnp.cjs, packages/dashmate/docs/config/index.md
Config writes acquire a lock, compare on-disk bytes with the read baseline, atomically write matching files, and park conflicting updates in rejected snapshots; dependencies and concurrency documentation are updated.
Persistence and migration validation
packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js, packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js
Tests cover hydration, migration stamping, read-only persistence behavior, JSON output, repeated writes, locking, stale-write rejection, deletion races, and recovery after reload.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DashmateCommand
  participant ConfigFileJsonRepository
  participant ConfigFile
  participant configjson
  participant RejectedSnapshot
  DashmateCommand->>ConfigFileJsonRepository: load and mutate ConfigFile
  DashmateCommand->>ConfigFileJsonRepository: write changed configuration
  ConfigFileJsonRepository->>ConfigFileJsonRepository: acquire lock and read current bytes
  ConfigFileJsonRepository->>configjson: compare current bytes with baseline
  alt no conflict
    ConfigFileJsonRepository->>configjson: atomically write JSON
    ConfigFileJsonRepository-->>DashmateCommand: mark configuration saved
  else conflict detected
    ConfigFileJsonRepository->>RejectedSnapshot: save rejected JSON
    ConfigFileJsonRepository-->>DashmateCommand: raise ConfigFileConflictError
  end
Loading

Possibly related PRs

Suggested reviewers: quantumexplorer, thepastaclaw

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code adds clean hydration, write locking/conflict detection, and tests matching #4242's requirements.
Out of Scope Changes check ✅ Passed All changes support config write safety, hydration, docs, tests, or required dependencies; no unrelated edits stand out.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main Dashmate fix for avoiding read-only config rewrites and concurrent update clobbering.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dashmate/4242-readonly-config-clobber

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🧹 Nitpick comments (3)
packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js (3)

9-9: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hardcoded format version will drift.

'4.1.0' duplicates the production config format version; when it bumps, these tests seed a file that no longer matches the schema/migration expectations. Prefer importing the same constant/source the repository uses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js`
at line 9, Replace the hardcoded CURRENT_FORMAT_VERSION in
ConfigFileJsonRepository.spec.js with the production config format version
constant or source used by ConfigFileJsonRepository, so tests always seed files
using the current schema version.

297-300: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Hot busy-wait spins a core for up to 10s on failure.

fs.existsSync in a tight loop pegs a CPU in CI when the child fails to start. Reuse an Atomics.wait-based sleep between polls.

♻️ Suggested tweak
       const deadline = Date.now() + 10000;
       while (!fs.existsSync(lockPath) && Date.now() < deadline) {
-        // busy-wait
+        Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js`
around lines 297 - 300, Replace the tight polling loop around lockPath in the
ConfigFileJsonRepository test with an Atomics.wait-based sleep between
fs.existsSync checks, preserving the existing 10-second deadline and success
condition. Ensure the polling still exits promptly when the lock appears while
avoiding continuous CPU usage.

273-319: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Child process may leak if an assertion throws.

Any failing expect between Lines 302-316 aborts the test before the exit listener path completes, leaving the spawned child (and the lock dir) around until its own timer fires. A child.kill() in an afterEach/try-finally makes the failure mode deterministic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js`
around lines 273 - 319, Ensure the spawned child in the “should wait for a lock
held by another process before writing” test is always cleaned up when an
assertion or repository operation fails. Wrap the synchronous test flow in
try/finally or add equivalent teardown that kills the child and removes any
remaining lock directory, while preserving the existing exit callback and
success assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/dashmate/docs/config/index.md`:
- Around line 192-197: Add the `text` language identifier to the fenced code
block containing the configuration overwrite warning in the documentation, while
preserving the warning text unchanged.

In `@packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js`:
- Around line 174-198: Update the lock acquisition and release flow around
`#acquireLock` so proper-lockfile uses a non-throwing onCompromised handler and
release failures are caught in finally. Preserve the original operation or
ConfigFileConflictError when release() reports ERELEASED, and prevent
compromised-lock refresh errors from becoming uncaught asynchronous exceptions.

---

Nitpick comments:
In
`@packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js`:
- Line 9: Replace the hardcoded CURRENT_FORMAT_VERSION in
ConfigFileJsonRepository.spec.js with the production config format version
constant or source used by ConfigFileJsonRepository, so tests always seed files
using the current schema version.
- Around line 297-300: Replace the tight polling loop around lockPath in the
ConfigFileJsonRepository test with an Atomics.wait-based sleep between
fs.existsSync checks, preserving the existing 10-second deadline and success
condition. Ensure the polling still exits promptly when the lock appears while
avoiding continuous CPU usage.
- Around line 273-319: Ensure the spawned child in the “should wait for a lock
held by another process before writing” test is always cleaned up when an
assertion or repository operation fails. Wrap the synchronous test flow in
try/finally or add equivalent teardown that kills the child and removes any
remaining lock directory, while preserving the existing exit callback and
success assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d46df355-b215-4071-93e5-830b41f4f559

📥 Commits

Reviewing files that changed from the base of the PR and between ed4116b and b7a53e3.

⛔ Files ignored due to path filters (3)
  • .yarn/cache/proper-lockfile-npm-4.1.2-a140a3c928-000a4875f5.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/write-file-atomic-npm-5.0.1-52283db6ee-648efddba5.zip is excluded by !**/.yarn/**, !**/*.zip
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (11)
  • .pnp.cjs
  • packages/dashmate/docs/config/index.md
  • packages/dashmate/package.json
  • packages/dashmate/scripts/helper.js
  • packages/dashmate/src/config/Config.js
  • packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js
  • packages/dashmate/src/config/errors/ConfigFileConflictError.js
  • packages/dashmate/src/oclif/command/BaseCommand.js
  • packages/dashmate/src/templates/writeConfigTemplatesFactory.js
  • packages/dashmate/test/unit/config/Config.spec.js
  • packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js
💤 Files with no reviewable changes (1)
  • packages/dashmate/src/templates/writeConfigTemplatesFactory.js

Comment thread packages/dashmate/docs/config/index.md Outdated
Comment thread packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js
shumkov and others added 2 commits July 28, 2026 23:14
`proper-lockfile`'s `release()` reports ERELEASED/ENOTACQUIRED when the lock
was compromised or already gone. Calling it bare in `finally` let that replace
the outcome the caller needs — an operator hitting a concurrent write would
have been told "Lock is already released" instead of being given the conflict
and the path their configuration was parked at. Release failures are now
contained; a lock that cannot be released goes stale and is reclaimed by the
next writer.

The library's default `onCompromised` rethrows, and it runs from a refresh
timer rather than the caller's stack, so a lock compromised mid-write would
have taken the whole process down. Replaced with a handler that does not
throw: the critical section lasts milliseconds, and the byte comparison
against the baseline — not the lock — is what actually prevents a lost update.

Also labels a fenced block in the config docs (MD040).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing its child

The poll waiting for the other process to take the lock was a tight
`fs.existsSync` loop, which pegs a CI core for the full ten-second timeout
whenever the child fails to start. It now sleeps between polls.

The spawned lock holder was only cleaned up on the success path, so a failing
assertion left it running — still owning the lock directory — after the test
that created it had gone. It is tracked and killed in `afterEach` instead, so
the failure mode is deterministic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shumkov

shumkov commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Dispositions for the three nitpicks from the CodeRabbit review, for the record.

Fixed in 235801568c:

  • Hot busy-wait spins a core for up to 10s on failure — valid, and the worse failure mode is on a CI runner where the child never starts. The poll waiting for the other process to take the lock now sleeps between attempts instead of spinning.
  • Child process may leak if an assertion throws — valid. The spawned lock holder was only cleaned up on the success path, so a failing assertion left it running and still owning the lock directory after its test was gone. It is now tracked and killed in afterEach, making the failure mode deterministic.

Declining, with reasoning:

  • Hardcoded '4.1.0' format version will drift — the concern is that the seeded file stops matching schema/migration expectations when the real version bumps. It cannot: these tests inject an identity migration, so migratedConfigFileData.configFormatVersion === originConfigVersion holds for any value, and the schema constrains the field's type rather than its value. The literal is deliberately arbitrary test data, not a mirror of the production constant — importing the real one would couple the fixture to a value the test does not depend on, and would make the migration-path test (which uses '9.9.9') inconsistent with its sibling. Happy to revisit if a reviewer disagrees.

Every command re-rendered and rewrote all service templates — 40 files for a
four-config home directory, on `dashmate config get` as much as on `setup`.
That was never intentional: hydration marked every config changed, so the
existing "changed configs only" filter matched all of them. Fixing hydration
left the filter correct but the rendering unconditional, which kept half of
the original side effect alive.

Rendering only what changed is safe because upgrading Dashmate records the new
version in the config file even when no migration applies, and
`ConfigFileJsonRepository.read()` marks every config changed when that recorded
version moved. So a release that edits a template still reaches every node on
its next command, without paying for it on every unrelated command.

That property was load-bearing and untested, so it is pinned now: removing the
version stamp from `migrateConfigFile` fails the new test with
`expected '98.0.0' to equal '99.0.0'`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thepastaclaw

thepastaclaw commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit e23dbdf)
Canonical validated blockers: 3

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Preliminary review — Codex only

The compare-and-replace write path prevents stale writers from overwriting config.json, but the new dirty-state lifecycle is not safely integrated with existing SSL and template workflows. Intermediate SSL saves can discard pending render work, completed JSON writes can permanently consume the only template-upgrade signal, and the long-lived helper cannot recover after another process updates the file. These are three blocking regressions.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js`:
- [BLOCKING] packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js:194-195: Intermediate SSL saves discard pending template renders
  Clearing every nested `Config` flag here breaks callers that save during a command and rely on `BaseCommand.finally()` to render afterward. The ZeroSSL pipeline updates `enabled`, `provider`, and the certificate ID and writes the `ConfigFile` at `obtainZeroSSLCertificateTaskFactory.js:145-150`; its remaining tasks only write certificate files, so nothing marks the config changed again. `BaseCommand.finally()` consequently sees a clean file and skips rendering, which can leave `config.json` switched from self-signed to ZeroSSL while Envoy still has the old listener configuration. The Let's Encrypt save task re-dirties the selected config, but its intermediate write still clears migration-triggered flags on every other config, suppressing their required first-command-after-upgrade renders. Preserve pending-template state across intermediate persistence, or explicitly render the captured pending configs after these writes.

In `packages/dashmate/src/oclif/command/BaseCommand.js`:
- [BLOCKING] packages/dashmate/src/oclif/command/BaseCommand.js:119-130: A template failure permanently consumes the upgrade marker
  `BaseCommand.finally()` commits the new config format version and clears all dirty flags before rendering any service template. If rendering or writing a template throws, or the process terminates after the config write, `config.json` remains stamped with the current version while one or more generated files remain stale. The next process hydrates every config as clean, and migration no longer marks them because the recorded version already matches, so the failed and unattempted templates are not retried automatically. This defeats the upgrade invariant on which render-only-changed behavior relies. Keep a durable pending-render indication until all affected templates have been written successfully, or provide an equivalent recovery mechanism.

In `packages/dashmate/scripts/helper.js`:
- [BLOCKING] packages/dashmate/scripts/helper.js:93: The helper cannot recover after another process updates config.json
  The helper reads `config.json` only once and retains that repository, `ConfigFile`, and selected `Config` for its entire lifetime. Under the new compare-and-replace contract, any later Dashmate command that changes the shared file invalidates the helper's baseline, including changes to an unrelated config. Certificate renewal then mutates the stale objects and writes through the stale repository, producing `ConfigFileConflictError`. Both renewal schedulers catch failures and schedule subsequent work with the same objects without re-reading, so every future renewal write remains unable to commit. Because the error is swallowed, the `unless-stopped` helper container stays alive rather than restarting and reloading, allowing gateway certificate renewal to remain broken until an external restart. Reload and reselect the current config before renewal mutations, or explicitly abandon and reload the helper state after a conflict.

Comment on lines +194 to +195
configFile.markAsSaved();
configFile.getAllConfigs().forEach((config) => config.markAsSaved());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Intermediate SSL saves discard pending template renders

Clearing every nested Config flag here breaks callers that save during a command and rely on BaseCommand.finally() to render afterward. The ZeroSSL pipeline updates enabled, provider, and the certificate ID and writes the ConfigFile at obtainZeroSSLCertificateTaskFactory.js:145-150; its remaining tasks only write certificate files, so nothing marks the config changed again. BaseCommand.finally() consequently sees a clean file and skips rendering, which can leave config.json switched from self-signed to ZeroSSL while Envoy still has the old listener configuration. The Let's Encrypt save task re-dirties the selected config, but its intermediate write still clears migration-triggered flags on every other config, suppressing their required first-command-after-upgrade renders. Preserve pending-template state across intermediate persistence, or explicitly render the captured pending configs after these writes.

source: ['codex']

Comment on lines 119 to +130
configFileRepository.write(configFile);

/**
* @var {writeConfigTemplates} writeConfigTemplates
*/
const writeConfigTemplates = this.container.resolve('writeConfigTemplates');

configFile.getAllConfigs()
.filter((config) => config.isChanged())
.forEach(writeConfigTemplates);
// Re-rendering only what changed is safe because upgrading Dashmate
// stamps the new version into the config file even when no migration
// applies, which marks every config changed - so a release that edits
// a template still reaches every node on its next command.
changedConfigs.forEach(writeConfigTemplates);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: A template failure permanently consumes the upgrade marker

BaseCommand.finally() commits the new config format version and clears all dirty flags before rendering any service template. If rendering or writing a template throws, or the process terminates after the config write, config.json remains stamped with the current version while one or more generated files remain stale. The next process hydrates every config as clean, and migration no longer marks them because the recorded version already matches, so the failed and unattempted templates are not retried automatically. This defeats the upgrade invariant on which render-only-changed behavior relies. Keep a durable pending-render indication until all affected templates have been written successfully, or provide an equivalent recovery mechanism.

source: ['codex']

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.

Dashmate read-only commands rewrite config and can clobber concurrent updates

2 participants