Files
alexei.dolgolyov 1c47030854 feat(volsnap): volume snapshot restore (backlog #6)
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/
2026-06-22 17:23:52 +03:00

84 lines
1.6 KiB
Go

package keyedmutex
import (
"sync"
"testing"
"time"
)
func TestLockSerializesSameKey(t *testing.T) {
var m Mutex
unlock := m.Lock("a")
acquired := make(chan struct{})
go func() {
u := m.Lock("a")
close(acquired)
u()
}()
select {
case <-acquired:
t.Fatal("second Lock on the same key acquired while the first was held")
case <-time.After(50 * time.Millisecond):
// expected: blocked
}
unlock()
select {
case <-acquired:
// expected: now acquired
case <-time.After(time.Second):
t.Fatal("second Lock did not acquire after release")
}
}
func TestLockIndependentKeys(t *testing.T) {
var m Mutex
unlockA := m.Lock("a")
defer unlockA()
// A different key must not block.
done := make(chan struct{})
go func() { u := m.Lock("b"); u(); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Lock on an independent key blocked")
}
}
func TestTryLock(t *testing.T) {
var m Mutex
unlock, ok := m.TryLock("a")
if !ok {
t.Fatal("TryLock should succeed on a free key")
}
if _, ok := m.TryLock("a"); ok {
t.Fatal("TryLock should fail while the key is held")
}
unlock()
u2, ok := m.TryLock("a")
if !ok {
t.Fatal("TryLock should succeed after release")
}
u2()
}
func TestConcurrentLockNoRace(t *testing.T) {
var m Mutex
var wg sync.WaitGroup
counter := 0
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
u := m.Lock("shared")
counter++ // protected by the keyed lock
u()
}()
}
wg.Wait()
if counter != 50 {
t.Errorf("counter = %d, want 50 (lost updates ⇒ lock not serializing)", counter)
}
}