projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
37
agent/internal/instancelock/lock.go
Normal file
37
agent/internal/instancelock/lock.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Package instancelock prevents multiple agent processes from running
|
||||
// concurrently on the same host by holding an exclusive lock on a
|
||||
// well-known path (Unix: flock on /var/lib/.../agent.lock; Windows:
|
||||
// named kernel mutex Global\RedFlagAgent_v1 + per-user file lock).
|
||||
// The lock is released when the process exits (the OS cleans up).
|
||||
// If the lock cannot be acquired, the instance should exit immediately
|
||||
// rather than share config.json and renewal state.
|
||||
package instancelock
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LockPath returns the well-known path for the Unix instance lockfile.
|
||||
// Windows uses a named kernel mutex instead; this function is only called
|
||||
// from lock_unix.go and is referenced here so the package compiles on
|
||||
// Windows.
|
||||
func LockPath() string {
|
||||
return filepath.Join("/var/lib/redflag/agent/state", "agent.lock")
|
||||
}
|
||||
|
||||
// Acquire attempts to acquire an exclusive instance lock. It returns a
|
||||
// release function and nil on success, or an error if another agent
|
||||
// process is already running on this host.
|
||||
//
|
||||
// The release function must be called on graceful shutdown.
|
||||
// On process crash the OS releases the lock automatically:
|
||||
// - Unix: the kernel closes the flock fd on process exit.
|
||||
// - Windows: the kernel transitions the mutex to "abandoned" state;
|
||||
// a crashed owner's mutex is acquired cleanly by the next waiter.
|
||||
func Acquire() (release func(), err error) {
|
||||
return acquireLock()
|
||||
}
|
||||
|
||||
// noopRelease is a safe no-op for platforms that don't need cleanup.
|
||||
func noopRelease() {}
|
||||
|
||||
45
agent/internal/instancelock/lock_unix.go
Normal file
45
agent/internal/instancelock/lock_unix.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//go:build linux || darwin || freebsd
|
||||
|
||||
package instancelock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// acquireLock opens (or creates) the lockfile and takes an exclusive
|
||||
// flock. The lock is released when the process exits (the kernel
|
||||
// closes the fd, which releases the flock).
|
||||
func acquireLock() (func(), error) {
|
||||
path := LockPath()
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("instancelock: mkdir %s: %w", dir, err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instancelock: open %s: %w", path, err)
|
||||
}
|
||||
|
||||
fd := int(f.Fd())
|
||||
if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("instancelock: %s is locked by another process: %w", path, err)
|
||||
}
|
||||
|
||||
// Write our PID so operators can see who holds the lock.
|
||||
_, _ = f.WriteAt([]byte(fmt.Sprintf("%d\n", os.Getpid())), 0)
|
||||
_ = f.Truncate(64)
|
||||
|
||||
release := func() {
|
||||
_ = f.Close()
|
||||
// Don't remove the file — leaving it is harmless and prevents a
|
||||
// TOCTOU race where a fresh open might get an unlocked fd before
|
||||
// we flock it.
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
127
agent/internal/instancelock/lock_windows.go
Normal file
127
agent/internal/instancelock/lock_windows.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
//go:build windows
|
||||
|
||||
package instancelock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
// mu guards handle — the OS mutex handle must be closed exactly once,
|
||||
// but it must outlive the caller's release function (it's process-wide).
|
||||
mu sync.Mutex
|
||||
handle windows.Handle
|
||||
)
|
||||
|
||||
// lockName is the well-known kernel-object name. The Global\ prefix makes
|
||||
// it visible across all sessions (including the service session), which is
|
||||
// necessary because the agent may run both as a service and as a console
|
||||
// process under different sessions on the same host.
|
||||
const lockName = `Global\RedFlagAgent_v1`
|
||||
|
||||
// acquireLock opens a named kernel mutex and attempts to acquire it
|
||||
// without blocking. If the mutex is held by another process, we fail
|
||||
// immediately. If it doesn't exist, CreateMutex creates it for us.
|
||||
//
|
||||
// Unlike the Unix flock (which lives on an fd scoped to the file system
|
||||
// path), a Windows mutex is purely a kernel object — there is no file
|
||||
// to write a PID or leak. The name is the key: any process on the system
|
||||
// (service, console, WSL bridge) that opens the same name gets the same
|
||||
// mutex object.
|
||||
func acquireLock() (func(), error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// If we already hold the lock in this process (shouldn't happen in
|
||||
// normal use, but guard it), don't re-acquire.
|
||||
if handle != 0 {
|
||||
return noopRelease, nil
|
||||
}
|
||||
|
||||
name, err := windows.UTF16PtrFromString(lockName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instancelock: utf16: %w", err)
|
||||
}
|
||||
|
||||
// CreateMutex opens or creates the named mutex. It does NOT set the
|
||||
// initial ownership — we do that with WaitForSingleObject below.
|
||||
h, err := windows.CreateMutex(nil, false, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instancelock: CreateMutex: %w", err)
|
||||
}
|
||||
|
||||
// Attempt to acquire with zero timeout (non-blocking).
|
||||
// WAIT_OBJECT_0 (0) = we got it.
|
||||
// WAIT_ABANDONED (128) = previous owner died holding it — it's ours.
|
||||
// WAIT_TIMEOUT (258) = someone else has it.
|
||||
switch waitResult, _ := windows.WaitForSingleObject(h, 0); waitResult {
|
||||
case 0, windows.WAIT_ABANDONED:
|
||||
handle = h
|
||||
case 258: // WAIT_TIMEOUT
|
||||
_ = windows.CloseHandle(h)
|
||||
return nil, fmt.Errorf("instancelock: another agent process is already running on this host (locked mutex: %s)", lockName)
|
||||
default:
|
||||
_ = windows.CloseHandle(h)
|
||||
return nil, fmt.Errorf("instancelock: WaitForSingleObject failed on %s", lockName)
|
||||
}
|
||||
|
||||
// Release is called on graceful shutdown. If the process crashes or
|
||||
// is killed, the kernel releases the mutex automatically — unlike
|
||||
// Unix where flock is tied to the fd and the fd closes on exit, a
|
||||
// Windows mutex held by a dead thread transitions to "abandoned"
|
||||
// and the next waiter acquires it cleanly.
|
||||
release := func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if handle != 0 {
|
||||
_ = windows.ReleaseMutex(handle)
|
||||
_ = windows.CloseHandle(handle)
|
||||
handle = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Also create a per-user lockfile under APPDATA to catch the case
|
||||
// where the same user runs two console-mode agents without going
|
||||
// through the SCM. The kernel mutex covers cross-session; the file
|
||||
// lock covers same-user-duplicates where both instances open the
|
||||
// same file.
|
||||
localAppData := os.Getenv("LOCALAPPDATA")
|
||||
if localAppData != "" {
|
||||
lockFilePath := filepath.Join(localAppData, "RedFlag", "agent.lock")
|
||||
|
||||
// Best-effort: if we can't write it, the kernel mutex still
|
||||
// protects us across all sessions.
|
||||
_ = os.MkdirAll(filepath.Dir(lockFilePath), 0755)
|
||||
if f, fErr := os.OpenFile(lockFilePath, os.O_RDWR|os.O_CREATE, 0644); fErr == nil {
|
||||
if lErr := lockFile(f); lErr == nil {
|
||||
// Extend release to also close the file.
|
||||
prevRelease := release
|
||||
release = func() {
|
||||
_ = f.Close()
|
||||
prevRelease()
|
||||
}
|
||||
} else {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// lockFile takes an advisory lock on an *os.File using LockFileEx (the
|
||||
// Windows equivalent of flock). This is belt-and-suspenders with the
|
||||
// kernel mutex — both must be acquired for the lock to count.
|
||||
func lockFile(f *os.File) error {
|
||||
ol := &windows.Overlapped{}
|
||||
return windows.LockFileEx(
|
||||
windows.Handle(f.Fd()),
|
||||
windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
|
||||
0, 1, 0, ol,
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue