1c47030854
Restore a captured volume snapshot onto an image workload's live host-bind
data volumes, then redeploy — the most destructive workload action, built to
the adversarially-reviewed design (C1–C6) with all data-loss guards.
- Engine.Restore (engine-owned): all-or-nothing pre-flight re-resolution from
the workload's CURRENT config (never the tamperable manifest), per-filesystem
disk pre-check, per-workload lock, container quiesce, extract-to-tmp, durable
pre-restore snapshot, write-ahead journal, atomic rename swap, redeploy, and
crash-recovery sweep (RecoverInterruptedRestores) wired before serving.
- internal/keyedmutex: shared per-key lock; deployer now serializes every
deploy entrypoint per workload via DispatchPlugin (+ LockWorkload/RedeployLocked
for the restore re-dispatch, no deadlock).
- Untrusted-archive extractor: zip-slip containment, type allow-list (reg/dir
only), decompression-bomb cap, manifest-index bounds.
- POST /api/workloads/{id}/snapshots/{sid}/restore: admin, X-Confirm-Restore
header (CSRF), per-workload single-flight (409).
- WebUI: Restore button + danger ConfirmDialog + busy state + i18n (en/ru).
Scope: image-source only; scopes absolute/stage/project (driven off the same
supportedScopes constant capture uses).
Plan-reviewed before coding; per-phase go/security/ts reviews; final review
READY TO MERGE. Security review caught + fixed a CRITICAL manifest-Source path
traversal (re-derive target from current config + base containment).
Plan: plans/volume-snapshot-restore/
49 lines
1.4 KiB
Go
49 lines
1.4 KiB
Go
// Package keyedmutex provides a lazily-populated per-key mutex, so a critical
|
|
// section can be serialized per key (e.g. per workload id) without a global
|
|
// lock. It is the shared form of the pattern that originated inline in the
|
|
// GitOps sync handler; the deployer (per-workload deploy serialization) and the
|
|
// volume-snapshot restore single-flight both use it.
|
|
package keyedmutex
|
|
|
|
import "sync"
|
|
|
|
// Mutex hands out one *sync.Mutex per key on demand. The zero value is ready to
|
|
// use. The internal map only grows (one entry per distinct key ever locked),
|
|
// which is bounded in practice by the number of workloads.
|
|
type Mutex struct {
|
|
mu sync.Mutex
|
|
m map[string]*sync.Mutex
|
|
}
|
|
|
|
func (k *Mutex) get(key string) *sync.Mutex {
|
|
k.mu.Lock()
|
|
defer k.mu.Unlock()
|
|
if k.m == nil {
|
|
k.m = make(map[string]*sync.Mutex)
|
|
}
|
|
mu, ok := k.m[key]
|
|
if !ok {
|
|
mu = &sync.Mutex{}
|
|
k.m[key] = mu
|
|
}
|
|
return mu
|
|
}
|
|
|
|
// Lock blocks until the mutex for key is acquired, then returns its unlock func.
|
|
func (k *Mutex) Lock(key string) func() {
|
|
mu := k.get(key)
|
|
mu.Lock()
|
|
return mu.Unlock
|
|
}
|
|
|
|
// TryLock attempts to acquire the mutex for key without blocking. On success it
|
|
// returns the unlock func and true; if the key is already locked it returns nil
|
|
// and false so the caller can reject (e.g. HTTP 409) instead of queuing.
|
|
func (k *Mutex) TryLock(key string) (func(), bool) {
|
|
mu := k.get(key)
|
|
if !mu.TryLock() {
|
|
return nil, false
|
|
}
|
|
return mu.Unlock, true
|
|
}
|