410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
138 lines
3.6 KiB
Go
138 lines
3.6 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func TestAcquireLockfile_FreshDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
release, err := AcquireLockfile(dir)
|
|
if err != nil {
|
|
t.Fatalf("AcquireLockfile: %v", err)
|
|
}
|
|
defer release()
|
|
|
|
// Lockfile should exist with our PID.
|
|
data, err := os.ReadFile(filepath.Join(dir, "tinyforge.lock"))
|
|
if err != nil {
|
|
t.Fatalf("read lockfile: %v", err)
|
|
}
|
|
want := fmt.Sprintf("%d\n", os.Getpid())
|
|
if string(data) != want {
|
|
t.Errorf("lockfile content = %q, want %q", data, want)
|
|
}
|
|
}
|
|
|
|
func TestAcquireLockfile_HeldByLivePID_Refused(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Plant a lockfile holding the current PID (which is obviously alive).
|
|
if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"),
|
|
[]byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
|
|
t.Fatalf("plant lockfile: %v", err)
|
|
}
|
|
release, err := AcquireLockfile(dir)
|
|
if err == nil {
|
|
release()
|
|
t.Fatal("expected ErrLockHeld, got nil")
|
|
}
|
|
if !errors.Is(err, ErrLockHeld) {
|
|
t.Errorf("error = %v, want wrap of ErrLockHeld", err)
|
|
}
|
|
}
|
|
|
|
func TestAcquireLockfile_StalePID_Reclaimed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// PID 1 is init/launchd/systemd on POSIX and the System Idle Process
|
|
// on Windows — never our process, and very unlikely to be dead. We
|
|
// use a deliberately-impossible PID instead: a 31-bit value far
|
|
// above any plausible system maximum.
|
|
stalePID := 2147483640
|
|
if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"),
|
|
[]byte(fmt.Sprintf("%d\n", stalePID)), 0o600); err != nil {
|
|
t.Fatalf("plant stale lockfile: %v", err)
|
|
}
|
|
release, err := AcquireLockfile(dir)
|
|
if err != nil {
|
|
t.Fatalf("expected reclaim of stale lock, got: %v", err)
|
|
}
|
|
defer release()
|
|
|
|
// Verify it now holds OUR pid, not the stale one.
|
|
data, err := os.ReadFile(filepath.Join(dir, "tinyforge.lock"))
|
|
if err != nil {
|
|
t.Fatalf("read lockfile after reclaim: %v", err)
|
|
}
|
|
want := fmt.Sprintf("%d\n", os.Getpid())
|
|
if string(data) != want {
|
|
t.Errorf("lockfile content after reclaim = %q, want %q", data, want)
|
|
}
|
|
}
|
|
|
|
func TestAcquireLockfile_ConcurrentReclaim_SingleWinner(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Plant a stale lockfile (impossibly high, certainly-dead PID), then have
|
|
// many goroutines race to reclaim it. Exactly one must win; the rest must
|
|
// be refused with ErrLockHeld. A "last-writer-wins" reclaim would let
|
|
// several goroutines all believe they own the lock.
|
|
stalePID := 2147483640
|
|
if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"),
|
|
[]byte(fmt.Sprintf("%d\n", stalePID)), 0o600); err != nil {
|
|
t.Fatalf("plant stale lockfile: %v", err)
|
|
}
|
|
|
|
const n = 16
|
|
var (
|
|
wg sync.WaitGroup
|
|
mu sync.Mutex
|
|
winners int
|
|
releases []func()
|
|
)
|
|
start := make(chan struct{})
|
|
for i := 0; i < n; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
release, err := AcquireLockfile(dir)
|
|
if err != nil {
|
|
if !errors.Is(err, ErrLockHeld) {
|
|
t.Errorf("loser error = %v, want wrap of ErrLockHeld", err)
|
|
}
|
|
return
|
|
}
|
|
mu.Lock()
|
|
winners++
|
|
releases = append(releases, release)
|
|
mu.Unlock()
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
for _, r := range releases {
|
|
r()
|
|
}
|
|
if winners != 1 {
|
|
t.Fatalf("concurrent reclaim winners = %d, want exactly 1", winners)
|
|
}
|
|
}
|
|
|
|
func TestAcquireLockfile_ReleaseRemovesFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
release, err := AcquireLockfile(dir)
|
|
if err != nil {
|
|
t.Fatalf("AcquireLockfile: %v", err)
|
|
}
|
|
release()
|
|
|
|
path := filepath.Join(dir, "tinyforge.lock")
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Errorf("lockfile still present after release: %v", err)
|
|
}
|
|
}
|