-
Notifications
You must be signed in to change notification settings - Fork 0
feat: deployment sync β follow a source deployment with eager propagation #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
e5738e1
feat(sync): add sync registry library
HahaSula 1f00ff2
feat(sync): add sync API routes and wire into server
HahaSula bda2091
feat(sync): trigger eager sync on save, auto-unlink on delete
HahaSula 447cfbe
feat(sync): add recursive deployment listing + frontend API client
HahaSula 5948767
fix(deployments): DELETE removes the whole directory in folder mode
HahaSula 3a89b75
feat(sync): badges, context menu, and sync/delete modals in Deploymenβ¦
HahaSula cb3b606
feat(sync): frozen read-only state in AlertUserView
HahaSula 771219c
fix(sync): fix stale-closure bug in DeploymentTree refresh + add E2E β¦
HahaSula 2c66562
perf(sync): parallelize registry + tree fetches in DeploymentTree
HahaSula a1cd440
fix(sync): enforce read-only sync targets server-side, revalidate regβ¦
HahaSula 8770347
fix(sync): normalize paths before validation so equivalent spellings β¦
HahaSula a6a3523
fix(sync): require overwrite ack for manually-typed sync targets in Sβ¦
HahaSula fc05fb3
fix(sync): DeleteSourceModal stops on first failure instead of alwaysβ¦
HahaSula 36439bc
test(e2e): cover sync-to overwrite bypass and delete-source flow, makβ¦
HahaSula b2bc7de
fix(sync): DeleteSourceModal catches thrown errors, keys effect on taβ¦
HahaSula 994b14d
fix(sync): harden registry integrity β ENOENT-only reads, serialized β¦
HahaSula 48cc19a
fix(sync): visible actions trigger, selection cleanup on delete, frozβ¦
HahaSula aa4f6f5
fix(sync): write sync.yaml atomically via temp file + rename
HahaSula bb72c32
fix(sync): guard frozenSource against stale getSyncSource responses
HahaSula 45e6c6c
fix(sync): initialize frozenSource for session-restored folders, hardβ¦
HahaSula e0a174f
fix(sync): insert refreshed tree children parents-first
HahaSula c053c9e
fix(sync): normalize sync paths with POSIX semantics on every host OS
HahaSula 5ad4081
fix(sync): selecting a deployment no longer wipes other expanded branβ¦
HahaSula 0534023
feat(sync): confirm before discarding unsaved edits on folder switch
HahaSula File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| import fs from 'fs/promises' | ||
| import path from 'path' | ||
| import yaml from 'js-yaml' | ||
|
|
||
| const SYNC_FILE = 'sync.yaml' | ||
|
|
||
| // Serializes registry read-modify-write sequences. Concurrent POST/DELETE | ||
| // /api/v2/sync (or an eager-sync save racing either) would otherwise both | ||
| // read the same registry state and the second write would silently drop the | ||
| // first one's change. A per-directory promise chain is sufficient here: the | ||
| // app runs as a per-user JupyterHub singleuser server, so there is exactly | ||
| // one Node process per repo β no cross-process locking needed. | ||
| const registryLocks = new Map() | ||
|
|
||
| export function withSyncRegistryLock(gitopsDir, fn) { | ||
| const prev = registryLocks.get(gitopsDir) || Promise.resolve() | ||
| const next = prev.then(fn, fn) | ||
| registryLocks.set(gitopsDir, next.catch(() => {})) | ||
| return next | ||
| } | ||
|
|
||
| export async function readSyncRegistry(gitopsDir) { | ||
| let raw | ||
| try { | ||
| raw = await fs.readFile(path.join(gitopsDir, SYNC_FILE), 'utf-8') | ||
| } catch (err) { | ||
| // Only a missing registry means "no syncs yet". Parse, permission, and | ||
| // I/O errors must surface β treating a malformed sync.yaml as empty | ||
| // would let the next write silently discard every existing sync link. | ||
| if (err?.code === 'ENOENT') return { syncs: [] } | ||
| throw err | ||
| } | ||
| const data = yaml.load(raw) || {} | ||
| return { syncs: Array.isArray(data.syncs) ? data.syncs : [] } | ||
| } | ||
|
|
||
| export async function writeSyncRegistry(gitopsDir, registry) { | ||
| // Temp file + rename so a crash mid-write can't leave sync.yaml | ||
| // truncated β readSyncRegistry treats a corrupt registry as a hard | ||
| // error (deliberately), which would take the whole sync API down. | ||
| const file = path.join(gitopsDir, SYNC_FILE) | ||
| const tmp = `${file}.tmp` | ||
| await fs.writeFile(tmp, yaml.dump(registry, { lineWidth: -1 }), 'utf-8') | ||
| await fs.rename(tmp, file) | ||
| } | ||
|
|
||
| export function findSourceEntry(registry, source) { | ||
| return registry.syncs.find(s => s.source === source) || null | ||
| } | ||
|
|
||
| export function findEntryForTarget(registry, target) { | ||
| return registry.syncs.find(s => s.targets.includes(target)) || null | ||
| } | ||
|
|
||
| export function getTargetsForSource(registry, source) { | ||
| return findSourceEntry(registry, source)?.targets || [] | ||
| } | ||
|
|
||
| export function getSourceForTarget(registry, target) { | ||
| return findEntryForTarget(registry, target)?.source || null | ||
| } | ||
|
|
||
| // A path is a "source" only once it has 1+ targets β an entry that's been | ||
| // unlinked down to zero targets is pruned, so it demotes back to independent. | ||
| export function isSource(registry, candidate) { | ||
| const entry = findSourceEntry(registry, candidate) | ||
| return !!entry && entry.targets.length > 0 | ||
| } | ||
|
|
||
| export function isTarget(registry, candidate) { | ||
| return !!findEntryForTarget(registry, candidate) | ||
| } | ||
|
|
||
| // Canonical form used for both validation and registry storage/comparison β | ||
| // callers must normalize a candidate with this *before* comparing it against | ||
| // existing registry entries (applySync/applyUnlink do strict string | ||
| // equality), otherwise 'cpu/prod' and 'cpu/./prod' would be treated as two | ||
| // different deployments and slip past role-exclusivity checks. | ||
| // Always POSIX semantics, regardless of host OS. On win32, path.normalize | ||
| // rewrites '/' to '\', which (a) stores non-portable '\'-separated paths in | ||
| // sync.yaml β a file that gets committed to the gitops repo and read on | ||
| // Linux β and (b) lets 'charts\evil' pass the charts-dir check below | ||
| // (split('/') can't see the '\' separator) while path.join still resolves | ||
| // it into the charts directory. Backslashes are folded into '/' first so | ||
| // both spellings canonicalize identically. | ||
| export function normalizeSyncPath(candidate) { | ||
| if (typeof candidate !== 'string') return candidate | ||
| return path.posix.normalize(candidate.replaceAll('\\', '/')).replace(/\/+$/, '') | ||
| } | ||
|
|
||
| // Reject traversal above the root, absolute paths, and anything rooted at | ||
| // the charts directory β sync must only ever point at deployment folders. | ||
| // See #33/#34 for the two prior path-traversal bugs this codebase has | ||
| // shipped. Candidates are normalized first so equivalent variants like | ||
| // 'cpu/./prod', 'cpu//prod', and 'cpu/prod/' can't slip past the checks | ||
| // below under a different spelling than what ends up on disk. | ||
| export function isSafeSyncPath(candidate, chartsDirName) { | ||
| if (!candidate || typeof candidate !== 'string') return false | ||
| if (path.isAbsolute(candidate)) return false | ||
| const normalized = normalizeSyncPath(candidate) | ||
| if (normalized === '.' || normalized === '' || normalized === '..' || normalized.startsWith('../')) return false | ||
| const firstSegment = normalized.split('/')[0] | ||
| if (firstSegment === chartsDirName) return false | ||
| return true | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // Core mutation: point `target` at `source`. Mutates `registry` in place. | ||
| // Enforces role exclusivity (see Issue #39 β flat tree, no chains) and | ||
| // switches `target` off whatever source it was previously following. | ||
| export function applySync(registry, source, target) { | ||
| if (source === target) { | ||
| return { ok: false, error: 'A deployment cannot sync to itself' } | ||
| } | ||
| if (isSource(registry, target)) { | ||
| return { ok: false, error: `${target} is itself a sync source and cannot become a target` } | ||
| } | ||
| if (isTarget(registry, source)) { | ||
| return { ok: false, error: `${source} is currently a target and cannot become a source` } | ||
| } | ||
|
|
||
| const existing = findEntryForTarget(registry, target) | ||
| if (existing && existing.source !== source) { | ||
| existing.targets = existing.targets.filter(t => t !== target) | ||
| if (existing.targets.length === 0) { | ||
| registry.syncs = registry.syncs.filter(s => s !== existing) | ||
| } | ||
| } | ||
|
|
||
| let entry = findSourceEntry(registry, source) | ||
| if (!entry) { | ||
| entry = { source, targets: [] } | ||
| registry.syncs.push(entry) | ||
| } | ||
| if (!entry.targets.includes(target)) { | ||
| entry.targets.push(target) | ||
| } | ||
| return { ok: true } | ||
| } | ||
|
|
||
| // Remove `target` from whichever source it's under. If that source is left | ||
| // with zero targets, drop the entry entirely β this is what lets a former | ||
| // source be folded into a different tree later (see "Merging two trees"). | ||
| export function applyUnlink(registry, target) { | ||
| const entry = findEntryForTarget(registry, target) | ||
| if (!entry) { | ||
| return { ok: false, error: `${target} is not currently synced` } | ||
| } | ||
| entry.targets = entry.targets.filter(t => t !== target) | ||
| if (entry.targets.length === 0) { | ||
| registry.syncs = registry.syncs.filter(s => s !== entry) | ||
| } | ||
| return { ok: true } | ||
| } | ||
|
|
||
| // Same recognition rule used by the folder tree (Chart.yaml with a | ||
| // dependency + values.yaml) β a sync source/target must resolve to an | ||
| // actual deployment, not an arbitrary directory. | ||
| export async function isDeploymentDir(absDir) { | ||
| let chartData | ||
| try { | ||
| const raw = await fs.readFile(path.join(absDir, 'Chart.yaml'), 'utf-8') | ||
| chartData = yaml.load(raw) || {} | ||
| } catch { | ||
| return false | ||
| } | ||
| const hasDeps = Array.isArray(chartData.dependencies) && chartData.dependencies.length > 0 | ||
| if (!hasDeps) return false | ||
| try { | ||
| await fs.access(path.join(absDir, 'values.yaml')) | ||
| return true | ||
| } catch { | ||
| return false | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.