Skip to content

localenv: keep every pre-run pyproject.toml via timestamped backups - #6268

Open
rugpanov wants to merge 4 commits into
mainfrom
dbconnect/setup-local-backup-no-clobber
Open

localenv: keep every pre-run pyproject.toml via timestamped backups#6268
rugpanov wants to merge 4 commits into
mainfrom
dbconnect/setup-local-backup-no-clobber

Conversation

@rugpanov

@rugpanov rugpanov commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Changes

environments setup-local rewrites the managed regions of a project's pyproject.toml on each run. Previously it backed the file up to pyproject.toml.bak once (the pristine pre-first-sync original) and never refreshed it. So if a user edited a managed region between syncs — requires-python, the databricks-connect pin, [tool.uv], or [tool.databricks.environment] — the next run overwrote that edit and no backup captured it: .bak still held the older original, leaving the pre-run state unrecoverable.

This change makes the backup capture every pre-run state:

  • Before any run that will actually change pyproject.toml, the current file is backed up. The first backup keeps the canonical pyproject.toml.bak name (the permanent pristine original); once that exists, each later modifying run writes a distinct pyproject.toml.<UTC-timestamp>.bak, with a -N suffix on a same-second collision — so no prior state is ever clobbered.
  • A no-op re-run (merged output already on disk) writes no backup, so an idempotent re-sync neither rewrites pyproject.toml nor clutters the directory.
  • Backups are written with O_CREATE|O_EXCL (writeNew): creation is atomic and fails rather than overwriting, so a write can never destroy an earlier backup and two runs can't both claim one name. A partially written file (e.g. ENOSPC mid-write) is removed so it can't masquerade as a complete backup or block a later run from reclaiming the canonical name. The source file's permission bits are preserved onto the backup.
  • --dry-run wouldBackup mirrors the real run: reported only when the run would change the file, named .bak or timestamped as the real run would name it.

Tests

  • A modifying re-run writes a timestamped backup of the current content while keeping .bak as the original; a no-op re-run writes no new backup; same-second backups get unique names; writeNew refuses to overwrite and preserves mode; first-run --dry-run plans the canonical .bak.
  • go test ./libs/localenv/ (197) and ./cmd/environments/ (29) pass; go vet and gofmt clean.

This pull request and its description were written by Isaac.

@github-actions

Copy link
Copy Markdown
Contributor

Waiting for approval

Could not determine reviewers from git history.
Round-robin suggestion: @misha-db

Eligible reviewers: @anton-107, @misha-db, @rclarey

Suggestions based on git history. See OWNERS for ownership rules.

@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: 9c1d478

Run: 31786276220

Env 🟨​KNOWN 💚​RECOVERED 🙈​SKIP ✅​pass 🙈​skip Time
🟨​ aws linux 3 1 4 286 1144 4:37
🟨​ aws windows 3 1 4 288 1142 4:01
🟨​ azure linux 3 1 4 285 1144 6:42
🟨​ azure windows 3 1 4 287 1142 6:09
💚​ gcp linux 1 5 286 1144 3:58
💚​ gcp windows 1 5 288 1142 4:21
8 interesting tests: 4 SKIP, 3 KNOWN, 1 RECOVERED
Test Name aws linux aws windows azure linux azure windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
🙈​ TestAccept/bundle/invariant/no_drift 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_endpoints/drift/recreated_same_name 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_indexes/recreate/embedding_dimension 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/ssh/connection 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🟨​ TestFetchRepositoryInfoAPI_FromRepo 🟨​K 🟨​K 🟨​K 🟨​K 🙈​S 🙈​S
🟨​ TestFetchRepositoryInfoAPI_FromRepo/root 🟨​K 🟨​K 🟨​K 🟨​K
🟨​ TestFetchRepositoryInfoAPI_FromRepo/subdir 🟨​K 🟨​K 🟨​K 🟨​K
Top 1 slowest tests (at least 2 minutes):
duration env testname
2:32 azure windows TestAccept

*Why*
setup-local backed up the pre-first-sync pyproject.toml to pyproject.toml.bak
once and never refreshed it. On a re-run that overwrote a managed region the
user had edited between syncs (requires-python, the databricks-connect pin,
[tool.uv], [tool.databricks.environment]), that edit was discarded with no
backup capturing it: the .bak still held the older original, so the pre-run
state was unrecoverable.

*What*
- applyMerge now backs up the current pyproject.toml before every run that will
  actually change it. The first backup keeps the canonical pyproject.toml.bak
  name (the permanent pristine original); once it exists, later modifying runs
  write a distinct pyproject.toml.<UTC-timestamp>.bak, so no prior state is ever
  clobbered. Same-second runs get a -N suffix.
- The backup is skipped on a no-op re-run (merged output already on disk), so an
  idempotent re-sync writes nothing and clutters no backups.
- Reordered applyMerge to choose the backup path (failing on an unstattable
  .bak) and no-op-check before copying; an unreadable backup is never shadowed.
- mergePlan --dry-run WouldBackup now mirrors this: reported only when the run
  would change the file, named .bak or timestamped as a real run would.
- Added a nowFn clock seam for deterministic backup-name tests.

*Verification*
go test ./libs/localenv/ (196 pass), go test ./cmd/environments/ (29 pass),
go vet + gofmt clean.

Co-authored-by: Isaac
*Why*
Review flagged that the timestamped-backup naming stat'd a candidate and then
copied with a truncating write. That left two gaps: a candidate stat error other
than not-exist made the copy fall back to overwriting an existing backup, and the
stat/copy pair had a TOCTOU window against a concurrent run picking the same name.
Both could destroy an earlier backup, breaking the no-clobber invariant.

*What*
- Replace copyFile (truncating) with writeNew, which creates the backup with
  O_CREATE|O_EXCL: it fails with os.ErrExist rather than overwriting, so a write
  can never clobber an existing backup and two runs can't both claim one name.
- backupCurrent now tries the canonical pyproject.toml.bak, then timestamped
  names, advancing the -N suffix only on os.ErrExist (any other error is returned,
  not spun on) — atomic, terminating, and clobber-proof by construction.
- applyMerge stats pyproject.toml up front for the mode bits and fails cleanly on
  a stat/read error before any write; the backup preserves the source's perms.
- Split the dry-run preview into plannedBackupName (best-effort naming only), so
  --dry-run reporting stays a preview and never fails on an unstattable .bak.

*Verification*
go test ./libs/localenv/ (197) + ./cmd/environments/ (29) pass; go vet + gofmt
clean. New tests: writeNew refuses to overwrite and preserves mode.

Co-authored-by: Isaac
*Why*
writeNew creates the backup with O_EXCL, then writes. If the write (or the
flushing close) failed after the file was created — e.g. ENOSPC — the empty or
truncated file was left behind. Because O_EXCL then treats that name as occupied,
a later run could never reclaim it, leaving the canonical pyproject.toml.bak
permanently truncated and masquerading as the pristine original.

*What*
On a write/close error, remove the just-created file before returning, so the
name is free for a retry and no partial file can pose as a complete backup.
Best-effort: a failing Remove means a badly degraded filesystem, where the write
error is the one worth surfacing. Errors from write and close are joined so a
close-time flush failure is not lost.

*Verification*
go test ./libs/localenv/ (197) + ./cmd/environments/ (29) pass; go vet + gofmt
clean.

Co-authored-by: Isaac
@rugpanov
rugpanov force-pushed the dbconnect/setup-local-backup-no-clobber branch from b096530 to 9c252e8 Compare August 14, 2026 08:50
*Why*
The backup helpers' comments restated mechanics the code already shows
(backupCurrent re-spec'd canonical→timestamped→-N; writeNew narrated umask; the
dry-run block explained each branch). Verbose comments compete with the code and
rot; a doc comment should state the contract and the one non-obvious caveat.

*What*
Trim the comments I added for backupCurrent, writeNew, plannedBackupName,
timestampedBackupBase, the nowFn/clock seam, the backupTimestampLayout const, and
the applyMerge stat/backup lines down to their load-bearing intent (no-clobber
invariant, canonical = pristine original, loop termination, no-op skip). No code
or behavior change; the pre-existing no-op comment is left as its author wrote it.

*Verification*
go test ./libs/localenv/ (197) + ./cmd/environments/ (29) pass; go vet + gofmt
clean.

Co-authored-by: Isaac
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.

2 participants