Skip to content
Merged
Show file tree
Hide file tree
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 Jul 1, 2026
1f00ff2
feat(sync): add sync API routes and wire into server
HahaSula Jul 1, 2026
bda2091
feat(sync): trigger eager sync on save, auto-unlink on delete
HahaSula Jul 1, 2026
447cfbe
feat(sync): add recursive deployment listing + frontend API client
HahaSula Jul 1, 2026
5948767
fix(deployments): DELETE removes the whole directory in folder mode
HahaSula Jul 1, 2026
3a89b75
feat(sync): badges, context menu, and sync/delete modals in Deploymen…
HahaSula Jul 1, 2026
cb3b606
feat(sync): frozen read-only state in AlertUserView
HahaSula Jul 1, 2026
771219c
fix(sync): fix stale-closure bug in DeploymentTree refresh + add E2E …
HahaSula Jul 1, 2026
2c66562
perf(sync): parallelize registry + tree fetches in DeploymentTree
HahaSula Jul 1, 2026
a1cd440
fix(sync): enforce read-only sync targets server-side, revalidate reg…
HahaSula Jul 2, 2026
8770347
fix(sync): normalize paths before validation so equivalent spellings …
HahaSula Jul 2, 2026
a6a3523
fix(sync): require overwrite ack for manually-typed sync targets in S…
HahaSula Jul 2, 2026
fc05fb3
fix(sync): DeleteSourceModal stops on first failure instead of always…
HahaSula Jul 2, 2026
36439bc
test(e2e): cover sync-to overwrite bypass and delete-source flow, mak…
HahaSula Jul 2, 2026
b2bc7de
fix(sync): DeleteSourceModal catches thrown errors, keys effect on ta…
HahaSula Jul 2, 2026
994b14d
fix(sync): harden registry integrity β€” ENOENT-only reads, serialized …
HahaSula Jul 3, 2026
48cc19a
fix(sync): visible actions trigger, selection cleanup on delete, froz…
HahaSula Jul 3, 2026
aa4f6f5
fix(sync): write sync.yaml atomically via temp file + rename
HahaSula Jul 3, 2026
bb72c32
fix(sync): guard frozenSource against stale getSyncSource responses
HahaSula Jul 3, 2026
45e6c6c
fix(sync): initialize frozenSource for session-restored folders, hard…
HahaSula Jul 3, 2026
e0a174f
fix(sync): insert refreshed tree children parents-first
HahaSula Jul 5, 2026
c053c9e
fix(sync): normalize sync paths with POSIX semantics on every host OS
HahaSula Jul 5, 2026
5ad4081
fix(sync): selecting a deployment no longer wipes other expanded bran…
HahaSula Jul 5, 2026
0534023
feat(sync): confirm before discarding unsaved edits on folder switch
HahaSula Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import renderRouter from './server/routes/render.js'
import gitRouter from './server/routes/git.js'
import git from './server/lib/git.js'
import foldersRouter from './server/routes/folders.js'
import syncRouter from './server/routes/sync.js'
import { getChartsDir, getDeploymentsDir, scaffoldSamplesIfNeeded } from './server/lib/chartDiscovery.js'
import { logger, httpLogger } from './server/lib/logger.js'

Expand Down Expand Up @@ -67,6 +68,7 @@ baseRouter.use('/api/v2/deployments', setGitopsDir, deploymentsRouter())
baseRouter.use('/api/v2/render', setGitopsDir, renderRouter())
baseRouter.use('/api/v2/git', setGitopsDir, gitRouter())
baseRouter.use('/api/v2/folders', setGitopsDir, foldersRouter())
baseRouter.use('/api/v2/sync', setGitopsDir, syncRouter())

baseRouter.get('/api/v2/user', (req, res) => {
const user = process.env.JUPYTERHUB_USER || null
Expand Down
174 changes: 174 additions & 0 deletions server/lib/sync.js
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Comment thread
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
}
}
63 changes: 61 additions & 2 deletions server/routes/deployments.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import fs from 'fs/promises'
import path from 'path'
import yaml from 'js-yaml'
import { getDepName, wrapValues, unwrapValues, countAlerts } from '../lib/subchart.js'
import { readSyncRegistry, writeSyncRegistry, withSyncRegistryLock, getTargetsForSource, isTarget, isSafeSyncPath, applyUnlink } from '../lib/sync.js'

const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/
const FOLDER_DEPLOYMENT_SEGMENT_RE = /^(?!\.{1,2}$)[^/\\]+$/

function chartsDirName() {
return process.env.CHARTS_DIR || 'charts'
}

function isValidDeploymentParam(req) {
const deployment = req.params.deployment
return req.query.folder
Expand Down Expand Up @@ -94,9 +99,21 @@ export default function deploymentsRouter() {
if (!isValidDeploymentParam(req)) {
return res.status(400).json({ error: 'Invalid deployment name' })
}
const folder = req.query.folder
const legacyFile = path.join(dir, `${req.params.deployment}-values.yaml`)
const directFile = path.join(dir, 'values.yaml')
try {
// A synced target is read-only server-side, not just in the UI β€”
// the frontend's freeze can race (see AlertUserView's getSyncSource
// call), so this must be enforced here too, not only by disabling Save.
let registry = null
if (folder) {
registry = await readSyncRegistry(req.gitopsDir)
if (isTarget(registry, folder)) {
return res.status(409).json({ error: `${folder} is synced from another deployment and is read-only` })
}
}

await fs.mkdir(dir, { recursive: true })
let file = legacyFile
try { await fs.access(directFile); file = directFile } catch { /* use legacy */ }
Expand All @@ -106,6 +123,26 @@ export default function deploymentsRouter() {
values = yaml.dump(wrapValues(values, depName), { lineWidth: -1 })
}
await fs.writeFile(file, values, 'utf-8')

// Eager sync: only folder-mode deployments participate (sync.yaml
// paths are folder-relative, matching the `folder` query param).
if (folder) {
const targets = getTargetsForSource(registry, folder)
for (const target of targets) {
// sync.yaml is a plain file inside the gitops repo β€” it can be
// hand-edited or arrive via `git pull` outside the app, so a
// registry entry isn't automatically trustworthy just because
// it's in the registry. Re-run the same check used at write time
// (POST /sync) before ever joining it into a filesystem path.
if (!isSafeSyncPath(target, chartsDirName())) continue
try {
const targetDir = path.join(req.gitopsDir, target)
await fs.mkdir(targetDir, { recursive: true })
await fs.writeFile(path.join(targetDir, 'values.yaml'), values, 'utf-8')
} catch { /* best-effort β€” one failing target doesn't undo the source save */ }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: err.message })
Expand Down Expand Up @@ -137,9 +174,31 @@ export default function deploymentsRouter() {
if (!isValidDeploymentParam(req)) {
return res.status(400).json({ error: 'Invalid deployment name' })
}
const file = path.join(dir, `${req.params.deployment}-values.yaml`)
const legacyFile = path.join(dir, `${req.params.deployment}-values.yaml`)
const directFile = path.join(dir, 'values.yaml')
try {
await fs.rm(file, { force: true })
const folder = req.query.folder
if (folder) {
await withSyncRegistryLock(req.gitopsDir, async () => {
const registry = await readSyncRegistry(req.gitopsDir)
if (isTarget(registry, folder)) {
applyUnlink(registry, folder)
await writeSyncRegistry(req.gitopsDir, registry)
}
})
}

let hasDirectFile = false
try { await fs.access(directFile); hasDirectFile = true } catch { /* legacy sibling-file mode */ }

if (hasDirectFile) {
// Folder-mode deployment: the directory itself is the deployment
// (Chart.yaml + values.yaml live directly inside it), so removing
// just one file would leave an orphaned, half-deleted deployment.
await fs.rm(dir, { recursive: true, force: true })
} else {
await fs.rm(legacyFile, { force: true })
}
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: err.message })
Expand Down
50 changes: 50 additions & 0 deletions server/routes/folders.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'path'
import yaml from 'js-yaml'
import { getChartsDir, findAlertTemplateCharts } from '../lib/chartDiscovery.js'
import { wrapValues, countAlerts } from '../lib/subchart.js'
import { isDeploymentDir } from '../lib/sync.js'

const EXCLUDED_DIRS = new Set(['.git', 'node_modules', '.cache'])

Expand Down Expand Up @@ -64,6 +65,44 @@ async function listChildren(baseDir, parentPath) {
return folders.sort((a, b) => a.name.localeCompare(b.name))
}

// Recursive, on-demand flat listing of every deployment folder in the repo β€”
// used to populate the Sync to/from modals' candidate lists. Deliberately
// not cached: this only runs when a sync modal opens (not a hot path like
// the lazy-loaded tree above), so a fresh scan each time is simplest and
// avoids the risk of a cache silently drifting from the filesystem.
async function collectDeployments(baseDir, parentPath, chartsDirName, results) {
const dir = parentPath ? path.join(baseDir, parentPath) : baseDir
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return
}

for (const e of entries) {
if (!e.isDirectory() || isExcluded(e.name)) continue
if (!parentPath && e.name === chartsDirName) continue

const nodePath = parentPath ? `${parentPath}/${e.name}` : e.name
const absPath = path.join(baseDir, nodePath)

if (await isDeploymentDir(absPath)) {
let chart = null
let alertCount = 0
try {
const chartYaml = yaml.load(await fs.readFile(path.join(absPath, 'Chart.yaml'), 'utf-8')) || {}
chart = chartYaml.dependencies?.[0]?.name || null
const valuesYaml = yaml.load(await fs.readFile(path.join(absPath, 'values.yaml'), 'utf-8')) || {}
alertCount = countAlerts(valuesYaml, chart)
} catch { /* leave defaults */ }
results.push({ name: e.name, path: nodePath, chart, alertCount })
continue
}

await collectDeployments(baseDir, nodePath, chartsDirName, results)
}
}

export default function foldersRouter() {
const router = express.Router()

Expand All @@ -80,6 +119,17 @@ export default function foldersRouter() {
}
})

router.get('/deployments', async (req, res) => {
try {
const chartsDirName = process.env.CHARTS_DIR || 'charts'
const results = []
await collectDeployments(req.gitopsDir, '', chartsDirName, results)
res.json(results)
} catch (err) {
res.status(500).json({ error: err.message })
}
})

router.get('/', async (req, res) => {
try {
const parentPath = req.query.path || ''
Expand Down
Loading