mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add Support for Optimistic Cloud Sync (#273)
* feat: Add support for background sync * refactor: Maintain single source of truth for command defn * fix: Code review fixes * fix: Code review fixes * docs: Add corner case inline doc
This commit is contained in:
@@ -44,12 +44,12 @@ PMG intercepts every package install and checks it for malware **before** code e
|
||||
|
||||
## How PMG Works
|
||||
|
||||
PMG takes a defense in depth approach. Each install passes through three independent layers before code runs, plus an audit trail after.
|
||||
PMG takes a defense in depth approach. Each install passes through the enabled protection layers before code runs, plus an audit trail after.
|
||||
|
||||
- **Transparent Interception** - PMG wraps `npm`, `pip`, and other package managers. Developers and AI agents use the same commands. No workflow changes.
|
||||
- **Layer 1: Threat Intelligence** - PMG checks every package against [SafeDep's real-time threat intelligence](https://safedep.io) before install. Known-malicious packages never reach disk.
|
||||
- **Layer 2: Policy (Dependency Cooldown)** - PMG blocks package versions published inside a configurable cooldown window, so freshly compromised versions cannot land before the ecosystem has had time to flag them.
|
||||
- **Layer 3: Sandbox** - PMG runs installs inside OS-native sandboxes (macOS Seatbelt, Linux Bubblewrap), so install scripts cannot touch the system even if a threat slips past the first two layers.
|
||||
- **Layer 3: Optional Sandbox** - When sandboxing is enabled and configured, PMG runs installs inside OS-native sandboxes (macOS Seatbelt, Linux Landlock by default, or Bubblewrap fallback) so install scripts have restricted system access even if a threat slips past the first two layers.
|
||||
- **Audit Logging** - PMG logs every install (what, when, from where) for a verifiable audit trail.
|
||||
|
||||
## Quick Start
|
||||
@@ -192,8 +192,9 @@ Protect CI workflows with one step. PMG analyzes every `npm install`,
|
||||
- run: npm ci
|
||||
```
|
||||
|
||||
By default you get malware blocking and dependency cooldown. Tune behavior
|
||||
via inputs (`paranoid`, `sandbox`, `cooldown-days`, ...) or point
|
||||
By default you get malware blocking and dependency cooldown. Sandbox isolation
|
||||
is opt-in via the `sandbox` input. Tune behavior via inputs (`paranoid`,
|
||||
`sandbox`, `cooldown-days`, ...) or point
|
||||
`config-file` at a YAML in the repo. See
|
||||
[docs/github-action.md](docs/github-action.md) for the full reference.
|
||||
|
||||
|
||||
@@ -258,6 +258,13 @@ runs:
|
||||
export_var SAFEDEP_TENANT_ID "$IN_TENANT_ID"
|
||||
export_var PMG_CLOUD_ENABLED "true"
|
||||
|
||||
# PMG's opportunistic background auto-sync is designed for long-lived
|
||||
# workstations. On an ephemeral runner the detached child can be
|
||||
# torn down before it drains the WAL, and the WAL goes with it. Turn
|
||||
# it off and rely on an explicit `pmg cloud sync` at job-end if the
|
||||
# user wants events delivered.
|
||||
export_var PMG_CLOUD_AUTO_SYNC_ENABLED "false"
|
||||
|
||||
if [ -n "$IN_ENDPOINT_ID" ]; then
|
||||
export_var PMG_CLOUD_ENDPOINT_ID "$IN_ENDPOINT_ID"
|
||||
else
|
||||
|
||||
@@ -11,6 +11,7 @@ func NewCloudCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.AddCommand(newSyncCommand())
|
||||
cmd.AddCommand(newSyncBackgroundCommand())
|
||||
cmd.AddCommand(newLoginCommand())
|
||||
cmd.AddCommand(newLogoutCommand())
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// manualSyncLockTimeout caps how long `pmg cloud sync` waits to acquire the
|
||||
// shared sync lock when an auto-sync child is already running. Long enough to
|
||||
// let a normal background drain complete, short enough that a stuck process
|
||||
// surfaces as a usefulerror rather than an indefinite hang.
|
||||
const manualSyncLockTimeout = 30 * time.Second
|
||||
|
||||
var syncTimeout time.Duration
|
||||
|
||||
func newSyncCommand() *cobra.Command {
|
||||
@@ -42,6 +48,30 @@ func runSync(cmd *cobra.Command, args []string) error {
|
||||
WithHelp("Set 'cloud.enabled: true' in PMG config to enable cloud sync"))
|
||||
}
|
||||
|
||||
lock := audit.NewSyncLock(cfg.CloudSyncLockPath())
|
||||
lockCtx, lockCancel := context.WithTimeout(cmd.Context(), manualSyncLockTimeout)
|
||||
defer lockCancel()
|
||||
|
||||
locked, err := lock.TryLockContext(lockCtx, 250*time.Millisecond)
|
||||
if err != nil {
|
||||
ui.ErrorExit(usefulerror.Useful().
|
||||
Wrap(err).
|
||||
WithCode(usefulerror.ErrCodeLifecycle).
|
||||
WithHumanError("Failed to acquire cloud sync lock").
|
||||
WithHelp("Another sync may be in progress; try again shortly"))
|
||||
}
|
||||
if !locked {
|
||||
ui.ErrorExit(usefulerror.Useful().
|
||||
WithCode(usefulerror.ErrCodeLifecycle).
|
||||
WithHumanError("Another cloud sync is already in progress").
|
||||
WithHelp("Wait for the in-progress sync to finish, then try again"))
|
||||
}
|
||||
defer func() {
|
||||
if err := lock.Unlock(); err != nil {
|
||||
log.Warnf("failed to release cloud sync lock: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), syncTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -60,6 +90,7 @@ func runSync(cmd *cobra.Command, args []string) error {
|
||||
}()
|
||||
|
||||
synced, err := bundle.Sync(ctx)
|
||||
recordLastSyncAttempt(cfg)
|
||||
if err != nil {
|
||||
ui.ErrorExit(usefulerror.Useful().
|
||||
Wrap(err).
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/internal/analytics"
|
||||
"github.com/safedep/pmg/internal/audit"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newSyncBackgroundCommand returns the hidden subcommand invoked by the
|
||||
// detached child that audit.MaybeSpawnBackgroundSync forks. Always exits 0:
|
||||
// the parent has already returned to the user's shell by the time this runs,
|
||||
// so a non-zero exit would only show up to whatever (init / launchd) reaped
|
||||
// the orphaned process.
|
||||
func newSyncBackgroundCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: audit.SyncBackgroundSubcommand,
|
||||
Short: "Internal: drain the cloud sync WAL from a detached child process",
|
||||
Hidden: true,
|
||||
RunE: runSyncBackground,
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runSyncBackground(cmd *cobra.Command, args []string) error {
|
||||
audit.MarkBackgroundSyncChild()
|
||||
|
||||
cfg := config.Get()
|
||||
|
||||
if analytics.IsDisabled() {
|
||||
log.Debugf("Auto-sync: telemetry disabled; exiting")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !cfg.Config.Cloud.Enabled || !cfg.Config.Cloud.AutoSync.Enabled {
|
||||
log.Debugf("Auto-sync: cloud or auto_sync disabled; exiting")
|
||||
return nil
|
||||
}
|
||||
|
||||
lock := audit.NewSyncLock(cfg.CloudSyncLockPath())
|
||||
|
||||
locked, err := lock.TryLock()
|
||||
if err != nil {
|
||||
log.Warnf("Auto-sync: failed to acquire lock: %v", err)
|
||||
return nil
|
||||
}
|
||||
if !locked {
|
||||
log.Debugf("Auto-sync: another sync is in progress; exiting")
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if err := lock.Unlock(); err != nil {
|
||||
log.Warnf("Auto-sync: failed to release lock: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Re-check cooldown under the lock to close the TOCTOU window between the
|
||||
// parent's pre-fork stat and our acquisition.
|
||||
if !audit.SyncCooldownElapsed(cfg.CloudSyncLastRunPath(), cfg.Config.Cloud.AutoSync.MinInterval) {
|
||||
log.Debugf("Auto-sync: cooldown still in effect under lock; exiting")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), cfg.Config.Cloud.AutoSync.Timeout)
|
||||
defer cancel()
|
||||
|
||||
bundle, err := audit.NewSyncClientBundle(cfg)
|
||||
if err != nil {
|
||||
log.Warnf("Auto-sync: failed to initialize sync client: %v", err)
|
||||
recordLastSyncAttempt(cfg)
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if err := bundle.Close(); err != nil {
|
||||
log.Warnf("Auto-sync: failed to close sync client: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
synced, err := bundle.Sync(ctx)
|
||||
recordLastSyncAttempt(cfg)
|
||||
if err != nil {
|
||||
log.Warnf("Auto-sync: sync failed after %d events: %v", synced, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Infof("Auto-sync: drained %d events to SafeDep Cloud", synced)
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordLastSyncAttempt updates the cooldown timestamp on every attempt so a
|
||||
// failing cloud endpoint does not cause every PMG invocation to retry.
|
||||
func recordLastSyncAttempt(cfg *config.RuntimeConfig) {
|
||||
if err := audit.WriteLastSyncAttempt(cfg.CloudSyncLastRunPath()); err != nil {
|
||||
log.Warnf("failed to update cloud sync lastrun: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/safedep/dry/cloud"
|
||||
"github.com/safedep/dry/log"
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/internal/alias"
|
||||
"github.com/safedep/pmg/internal/analytics"
|
||||
"github.com/safedep/pmg/internal/audit"
|
||||
"github.com/safedep/pmg/internal/ui"
|
||||
"github.com/safedep/pmg/internal/version"
|
||||
"github.com/safedep/pmg/sandbox/platform"
|
||||
@@ -141,6 +143,8 @@ func executeSetupInfo() error {
|
||||
cloudEntries["Endpoint ID"] = cfg.Config.Cloud.EndpointID
|
||||
}
|
||||
cloudEntries["Credentials"] = describeCloudCredentials()
|
||||
cloudEntries["Auto Sync"] = describeAutoSync(cfg.Config.Cloud.AutoSync)
|
||||
cloudEntries["Last Sync"] = describeLastSync(cfg.CloudSyncLastRunPath())
|
||||
ui.PrintInfoSection("Cloud Sync", cloudEntries)
|
||||
}
|
||||
|
||||
@@ -192,6 +196,34 @@ func tryResolveKeychainCredentials() (string, bool) {
|
||||
return "keychain", true
|
||||
}
|
||||
|
||||
func describeAutoSync(c config.CloudAutoSyncConfig) string {
|
||||
if !c.Enabled {
|
||||
return "disabled"
|
||||
}
|
||||
return fmt.Sprintf("enabled (every %s, timeout %s)", c.MinInterval, c.Timeout)
|
||||
}
|
||||
|
||||
// describeLastSync renders a missing or unparseable timestamp as "never" so a
|
||||
// fresh install does not look broken.
|
||||
func describeLastSync(path string) string {
|
||||
last := audit.ReadLastSyncAttempt(path)
|
||||
if last.IsZero() {
|
||||
return "never"
|
||||
}
|
||||
|
||||
delta := time.Since(last)
|
||||
switch {
|
||||
case delta < time.Minute:
|
||||
return "just now"
|
||||
case delta < time.Hour:
|
||||
return fmt.Sprintf("%d minutes ago", int(delta.Minutes()))
|
||||
case delta < 24*time.Hour:
|
||||
return fmt.Sprintf("%d hours ago", int(delta.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%d days ago", int(delta.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
func tryResolveEnvCredentials() (string, bool) {
|
||||
resolver, err := cloud.NewEnvCredentialResolver()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCloudAutoSyncDefaults(t *testing.T) {
|
||||
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
|
||||
initConfig()
|
||||
|
||||
c := Get().Config.Cloud.AutoSync
|
||||
assert.True(t, c.Enabled, "auto_sync.enabled defaults to true")
|
||||
assert.Equal(t, 15*time.Minute, c.MinInterval)
|
||||
assert.Equal(t, 5*time.Minute, c.Timeout)
|
||||
}
|
||||
|
||||
func TestCloudAutoSyncEnvOverridesDefaults(t *testing.T) {
|
||||
t.Run("PMG_CLOUD_AUTO_SYNC_ENABLED=false flips the default", func(t *testing.T) {
|
||||
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
|
||||
t.Setenv("PMG_CLOUD_AUTO_SYNC_ENABLED", "false")
|
||||
initConfig()
|
||||
|
||||
assert.False(t, Get().Config.Cloud.AutoSync.Enabled)
|
||||
})
|
||||
|
||||
t.Run("PMG_CLOUD_AUTO_SYNC_MIN_INTERVAL is parsed as duration", func(t *testing.T) {
|
||||
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
|
||||
t.Setenv("PMG_CLOUD_AUTO_SYNC_MIN_INTERVAL", "2m")
|
||||
initConfig()
|
||||
|
||||
assert.Equal(t, 2*time.Minute, Get().Config.Cloud.AutoSync.MinInterval)
|
||||
})
|
||||
|
||||
t.Run("PMG_CLOUD_AUTO_SYNC_TIMEOUT is parsed as duration", func(t *testing.T) {
|
||||
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
|
||||
t.Setenv("PMG_CLOUD_AUTO_SYNC_TIMEOUT", "30s")
|
||||
initConfig()
|
||||
|
||||
assert.Equal(t, 30*time.Second, Get().Config.Cloud.AutoSync.Timeout)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCloudAutoSyncConfigFileMerge(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||
t.Setenv("PMG_CLOUD_AUTO_SYNC_ENABLED", "")
|
||||
t.Setenv("PMG_CLOUD_AUTO_SYNC_MIN_INTERVAL", "")
|
||||
t.Setenv("PMG_CLOUD_AUTO_SYNC_TIMEOUT", "")
|
||||
|
||||
configYAML := `cloud:
|
||||
enabled: true
|
||||
auto_sync:
|
||||
enabled: false
|
||||
min_interval: 1m
|
||||
`
|
||||
configPath := filepath.Join(tmpDir, "config.yml")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(configYAML), 0o644))
|
||||
|
||||
initConfig()
|
||||
c := Get().Config.Cloud
|
||||
assert.True(t, c.Enabled)
|
||||
assert.False(t, c.AutoSync.Enabled, "explicit auto_sync.enabled=false should win")
|
||||
assert.Equal(t, time.Minute, c.AutoSync.MinInterval)
|
||||
// Sibling field not set in file falls back to default.
|
||||
assert.Equal(t, 5*time.Minute, c.AutoSync.Timeout)
|
||||
}
|
||||
|
||||
func TestCloudSyncPathHelpers(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||
initConfig()
|
||||
|
||||
cfg := Get()
|
||||
assert.Equal(t, filepath.Join(tmpDir, "cloud-sync.db"), cfg.CloudSyncDBPath())
|
||||
assert.Equal(t, filepath.Join(tmpDir, "cloud-sync.lock"), cfg.CloudSyncLockPath())
|
||||
assert.Equal(t, filepath.Join(tmpDir, "cloud-sync.lastrun"), cfg.CloudSyncLastRunPath())
|
||||
}
|
||||
+39
-2
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
|
||||
@@ -97,8 +98,19 @@ type Config struct {
|
||||
|
||||
// CloudConfig configures audit event sync to SafeDep Cloud.
|
||||
type CloudConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
EndpointID string `mapstructure:"endpoint_id"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
EndpointID string `mapstructure:"endpoint_id"`
|
||||
AutoSync CloudAutoSyncConfig `mapstructure:"auto_sync"`
|
||||
}
|
||||
|
||||
// CloudAutoSyncConfig controls opportunistic background sync of the cloud
|
||||
// audit WAL. When Enabled, PMG spawns a detached `pmg cloud sync-background`
|
||||
// child at the end of each invocation, gated by a per-host cooldown so the
|
||||
// sync does not fire on every command.
|
||||
type CloudAutoSyncConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
MinInterval time.Duration `mapstructure:"min_interval"`
|
||||
Timeout time.Duration `mapstructure:"timeout"`
|
||||
}
|
||||
|
||||
type ProxyConfig struct {
|
||||
@@ -198,6 +210,18 @@ func (r *RuntimeConfig) CloudSyncDBPath() string {
|
||||
return filepath.Join(r.configDir, "cloud-sync.db")
|
||||
}
|
||||
|
||||
// CloudSyncLockPath returns the path to the cross-process lock file that
|
||||
// serializes manual `pmg cloud sync` and the auto-sync background child.
|
||||
func (r *RuntimeConfig) CloudSyncLockPath() string {
|
||||
return filepath.Join(r.configDir, "cloud-sync.lock")
|
||||
}
|
||||
|
||||
// CloudSyncLastRunPath returns the path to the timestamp file recording the
|
||||
// last sync attempt (success or failure) in Unix epoch seconds.
|
||||
func (r *RuntimeConfig) CloudSyncLastRunPath() string {
|
||||
return filepath.Join(r.configDir, "cloud-sync.lastrun")
|
||||
}
|
||||
|
||||
// ConfigFilePath returns the path to the config file.
|
||||
func (r *RuntimeConfig) ConfigFilePath() string {
|
||||
return r.configFilePath
|
||||
@@ -283,6 +307,11 @@ func DefaultConfig() RuntimeConfig {
|
||||
},
|
||||
Cloud: CloudConfig{
|
||||
Enabled: false,
|
||||
AutoSync: CloudAutoSyncConfig{
|
||||
Enabled: true,
|
||||
MinInterval: 15 * time.Minute,
|
||||
Timeout: 5 * time.Minute,
|
||||
},
|
||||
},
|
||||
Proxy: ProxyConfig{
|
||||
Enabled: true,
|
||||
@@ -303,6 +332,14 @@ func init() {
|
||||
initConfig()
|
||||
}
|
||||
|
||||
// Reload re-runs the initialization that runs at package init. Tests that
|
||||
// mutate PMG_CONFIG_DIR via t.Setenv must call this so the resolved config
|
||||
// directory reflects the new env, instead of the value computed when the
|
||||
// package was first loaded.
|
||||
func Reload() {
|
||||
initConfig()
|
||||
}
|
||||
|
||||
// initConfig should be idempotent and can be called multiple times.
|
||||
// This is required for testing purposes.
|
||||
func initConfig() {
|
||||
|
||||
@@ -167,3 +167,23 @@ cloud:
|
||||
# Endpoint ID is not required. By default, it falls back to the machine's hostname.
|
||||
# Set it only if you want to explicitly override the identifier for this endpoint.
|
||||
endpoint_id: ""
|
||||
|
||||
# Auto sync drains the local cloud-sync WAL to SafeDep Cloud opportunistically
|
||||
# at the end of each PMG invocation, gated by a per-host cooldown so it does
|
||||
# not fire on every command. The drain runs as a short-lived detached child
|
||||
# so the user-facing CLI returns immediately.
|
||||
#
|
||||
# Disable this in ephemeral environments (CI runners, throwaway VMs) where
|
||||
# the detached child may be torn down before it finishes draining; in those
|
||||
# environments, prefer an explicit `pmg cloud sync` at job-end.
|
||||
auto_sync:
|
||||
enabled: true
|
||||
|
||||
# Minimum gap between sync attempts. The lastrun timestamp is updated on
|
||||
# every attempt (success or failure), so a failing cloud endpoint will not
|
||||
# cause every PMG invocation to retry. Reduce this if you want a more
|
||||
# aggressive retry cadence.
|
||||
min_interval: 15m
|
||||
|
||||
# Hard timeout applied to a single background sync attempt.
|
||||
timeout: 5m
|
||||
|
||||
@@ -53,6 +53,7 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/go-github/v74 v74.0.0 // indirect
|
||||
|
||||
@@ -89,6 +89,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/internal/analytics"
|
||||
)
|
||||
|
||||
// SyncBackgroundSubcommand is the cobra `Use` of the hidden child command
|
||||
// that MaybeSpawnBackgroundSync forks. Shared with cmd/cloud so renaming the
|
||||
// command can't desync the spawn args from the cobra registration.
|
||||
const SyncBackgroundSubcommand = "sync-background"
|
||||
|
||||
// isBackgroundSyncChild gates MaybeSpawnBackgroundSync against a respawn
|
||||
// chain when an early-exit child (e.g. losing the lock race) leaves
|
||||
// cloud-sync.lastrun stale.
|
||||
var isBackgroundSyncChild bool
|
||||
|
||||
func MarkBackgroundSyncChild() {
|
||||
isBackgroundSyncChild = true
|
||||
}
|
||||
|
||||
// detachedSpawner forks a detached child running `name` with `args`. Pulled
|
||||
// behind a package var so tests can intercept without actually forking the
|
||||
// test binary into the background.
|
||||
type detachedSpawner func(name string, args ...string) error
|
||||
|
||||
var spawnDetached detachedSpawner = spawnDetachedExec
|
||||
|
||||
// MaybeSpawnBackgroundSync forks a detached `pmg cloud sync-background` child
|
||||
// when auto-sync is enabled and the cooldown has elapsed. The call returns as
|
||||
// soon as `cmd.Start()` finishes (~milliseconds), so it must only be called
|
||||
// after audit.Close() has released the parent's SQLite handle on the WAL.
|
||||
//
|
||||
// All failures are logged and swallowed: auto-sync is opportunistic and must
|
||||
// never affect the parent PMG invocation's exit behavior or user-visible
|
||||
// output.
|
||||
func MaybeSpawnBackgroundSync(cfg *config.RuntimeConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if isBackgroundSyncChild {
|
||||
return
|
||||
}
|
||||
if !cfg.Config.Cloud.Enabled || !cfg.Config.Cloud.AutoSync.Enabled {
|
||||
return
|
||||
}
|
||||
if analytics.IsDisabled() {
|
||||
return
|
||||
}
|
||||
|
||||
if !SyncCooldownElapsed(cfg.CloudSyncLastRunPath(), cfg.Config.Cloud.AutoSync.MinInterval) {
|
||||
log.Debugf("Auto-sync cooldown not elapsed; skipping spawn")
|
||||
return
|
||||
}
|
||||
|
||||
pmgPath, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Warnf("Auto-sync: failed to resolve pmg binary path: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// This may seem like a pollution of concerns because the internal audit package
|
||||
// is aware of the CLI cmd layer. We mitigate the risk by having SSOT for sub-command
|
||||
// definition. We gain simplicity of the API that can be plugged in to appropriate
|
||||
// hook point in the main command handler.
|
||||
if err := spawnDetached(pmgPath, "cloud", SyncBackgroundSubcommand); err != nil {
|
||||
log.Warnf("Auto-sync: failed to spawn background sync child: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// spawnDetachedExec is the production implementation of detachedSpawner. It
|
||||
// redirects stdio to /dev/null, applies platform-specific detach attributes,
|
||||
// and starts the child without waiting on it.
|
||||
func spawnDetachedExec(name string, args ...string) error {
|
||||
devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// We hand the fd to exec, which dup's it onto the child's stdio; we can
|
||||
// close our own copy as soon as Start returns.
|
||||
defer func() {
|
||||
if closeErr := devNull.Close(); closeErr != nil {
|
||||
log.Warnf("Auto-sync: failed to close %s handle: %v", os.DevNull, closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdin = devNull
|
||||
cmd.Stdout = devNull
|
||||
cmd.Stderr = devNull
|
||||
applyDetachAttrs(cmd)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// On Unix, Setsid already orphans the child to init; Release just drops
|
||||
// our os.Process reference so we don't accumulate a zombie if the parent
|
||||
// process group is reused.
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
log.Warnf("Auto-sync: failed to release background sync child: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// spawnRecorder is a detachedSpawner stub that captures invocations instead
|
||||
// of actually forking. We swap it in via withMockSpawner.
|
||||
type spawnRecorder struct {
|
||||
mu sync.Mutex
|
||||
calls []spawnCall
|
||||
err error
|
||||
}
|
||||
|
||||
type spawnCall struct {
|
||||
name string
|
||||
args []string
|
||||
}
|
||||
|
||||
func (r *spawnRecorder) spawn(name string, args ...string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cp := make([]string, len(args))
|
||||
copy(cp, args)
|
||||
r.calls = append(r.calls, spawnCall{name: name, args: cp})
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *spawnRecorder) callCount() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.calls)
|
||||
}
|
||||
|
||||
func withMockSpawner(t *testing.T) *spawnRecorder {
|
||||
t.Helper()
|
||||
rec := &spawnRecorder{}
|
||||
prev := spawnDetached
|
||||
spawnDetached = rec.spawn
|
||||
t.Cleanup(func() { spawnDetached = prev })
|
||||
return rec
|
||||
}
|
||||
|
||||
func newAutoSyncConfig(t *testing.T) *config.RuntimeConfig {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||
// Make sure no PMG_DISABLE_TELEMETRY leak from the host environment can
|
||||
// fool the analytics short-circuit in MaybeSpawnBackgroundSync.
|
||||
t.Setenv("PMG_DISABLE_TELEMETRY", "false")
|
||||
|
||||
// Re-init the config so configDir picks up the tmpDir we just set.
|
||||
// Without this, cfg.CloudSyncLastRunPath() points at the user's real
|
||||
// config dir, which may not even exist in CI.
|
||||
config.Reload()
|
||||
t.Cleanup(config.Reload)
|
||||
|
||||
cfg := config.Get()
|
||||
cfg.Config.Cloud.Enabled = true
|
||||
cfg.Config.Cloud.AutoSync.Enabled = true
|
||||
cfg.Config.Cloud.AutoSync.MinInterval = 15 * time.Minute
|
||||
cfg.Config.Cloud.AutoSync.Timeout = time.Minute
|
||||
cfg.Config.DisableTelemetry = false
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestMaybeSpawnBackgroundSyncSpawnsByDefault(t *testing.T) {
|
||||
rec := withMockSpawner(t)
|
||||
cfg := newAutoSyncConfig(t)
|
||||
|
||||
MaybeSpawnBackgroundSync(cfg)
|
||||
|
||||
require.Equal(t, 1, rec.callCount())
|
||||
assert.Equal(t, []string{"cloud", "sync-background"}, rec.calls[0].args)
|
||||
assert.NotEmpty(t, rec.calls[0].name, "spawned name should be a resolved binary path")
|
||||
}
|
||||
|
||||
func TestMaybeSpawnBackgroundSyncShortCircuits(t *testing.T) {
|
||||
t.Run("nil config", func(t *testing.T) {
|
||||
rec := withMockSpawner(t)
|
||||
MaybeSpawnBackgroundSync(nil)
|
||||
assert.Equal(t, 0, rec.callCount())
|
||||
})
|
||||
|
||||
t.Run("cloud disabled", func(t *testing.T) {
|
||||
rec := withMockSpawner(t)
|
||||
cfg := newAutoSyncConfig(t)
|
||||
cfg.Config.Cloud.Enabled = false
|
||||
|
||||
MaybeSpawnBackgroundSync(cfg)
|
||||
assert.Equal(t, 0, rec.callCount())
|
||||
})
|
||||
|
||||
t.Run("auto_sync disabled", func(t *testing.T) {
|
||||
rec := withMockSpawner(t)
|
||||
cfg := newAutoSyncConfig(t)
|
||||
cfg.Config.Cloud.AutoSync.Enabled = false
|
||||
|
||||
MaybeSpawnBackgroundSync(cfg)
|
||||
assert.Equal(t, 0, rec.callCount())
|
||||
})
|
||||
|
||||
t.Run("telemetry disabled via config", func(t *testing.T) {
|
||||
rec := withMockSpawner(t)
|
||||
cfg := newAutoSyncConfig(t)
|
||||
cfg.Config.DisableTelemetry = true
|
||||
|
||||
MaybeSpawnBackgroundSync(cfg)
|
||||
assert.Equal(t, 0, rec.callCount())
|
||||
})
|
||||
|
||||
t.Run("we are the sync-background child", func(t *testing.T) {
|
||||
rec := withMockSpawner(t)
|
||||
cfg := newAutoSyncConfig(t)
|
||||
|
||||
t.Cleanup(func() { isBackgroundSyncChild = false })
|
||||
MarkBackgroundSyncChild()
|
||||
|
||||
MaybeSpawnBackgroundSync(cfg)
|
||||
assert.Equal(t, 0, rec.callCount())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMaybeSpawnBackgroundSyncRespectsCooldown(t *testing.T) {
|
||||
rec := withMockSpawner(t)
|
||||
cfg := newAutoSyncConfig(t)
|
||||
|
||||
// Recent attempt → cooldown still in effect → no spawn.
|
||||
require.NoError(t, WriteLastSyncAttempt(cfg.CloudSyncLastRunPath()))
|
||||
|
||||
MaybeSpawnBackgroundSync(cfg)
|
||||
assert.Equal(t, 0, rec.callCount())
|
||||
}
|
||||
|
||||
func TestMaybeSpawnBackgroundSyncSpawnsAfterCooldownElapses(t *testing.T) {
|
||||
rec := withMockSpawner(t)
|
||||
cfg := newAutoSyncConfig(t)
|
||||
|
||||
old := time.Now().Add(-time.Hour).Unix()
|
||||
require.NoError(t, os.WriteFile(cfg.CloudSyncLastRunPath(),
|
||||
[]byte(strconv.FormatInt(old, 10)), 0o600))
|
||||
|
||||
MaybeSpawnBackgroundSync(cfg)
|
||||
assert.Equal(t, 1, rec.callCount())
|
||||
}
|
||||
|
||||
func TestMaybeSpawnBackgroundSyncSwallowsSpawnError(t *testing.T) {
|
||||
rec := withMockSpawner(t)
|
||||
rec.err = os.ErrPermission
|
||||
cfg := newAutoSyncConfig(t)
|
||||
|
||||
// We can't observe log.Warnf directly, but we can assert the function
|
||||
// neither panics nor blocks when the underlying spawn fails.
|
||||
assert.NotPanics(t, func() { MaybeSpawnBackgroundSync(cfg) })
|
||||
assert.Equal(t, 1, rec.callCount(), "spawner should still be invoked once")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !windows
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// applyDetachAttrs configures cmd so that the child process is fully
|
||||
// detached from the parent's controlling terminal and process group. Setsid
|
||||
// makes the child its own session leader, so the parent can exit immediately
|
||||
// without the child receiving SIGHUP/SIGINT from the parent's shell.
|
||||
func applyDetachAttrs(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyDetachAttrsSetsSetsidOnUnix(t *testing.T) {
|
||||
cmd := exec.Command("true")
|
||||
applyDetachAttrs(cmd)
|
||||
|
||||
require.NotNil(t, cmd.SysProcAttr, "applyDetachAttrs must populate SysProcAttr")
|
||||
assert.True(t, cmd.SysProcAttr.Setsid, "Unix detach must set Setsid so child becomes a new session leader")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// applyDetachAttrs configures cmd so that the child process is fully
|
||||
// detached from the parent's console and process group. DETACHED_PROCESS
|
||||
// removes the inherited console; CREATE_NEW_PROCESS_GROUP isolates the
|
||||
// child from Ctrl+C events sent to the parent's group.
|
||||
func applyDetachAttrs(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestApplyDetachAttrsSetsCreationFlagsOnWindows(t *testing.T) {
|
||||
cmd := exec.Command("cmd.exe")
|
||||
applyDetachAttrs(cmd)
|
||||
|
||||
require.NotNil(t, cmd.SysProcAttr, "applyDetachAttrs must populate SysProcAttr")
|
||||
want := uint32(windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP)
|
||||
assert.Equal(t, want, cmd.SysProcAttr.CreationFlags)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
)
|
||||
|
||||
// NewSyncLock returns an unlocked file lock at the given path. The same path
|
||||
// must be used by manual `pmg cloud sync` and the auto-sync background child
|
||||
// so they serialize against each other.
|
||||
func NewSyncLock(path string) *flock.Flock {
|
||||
return flock.New(path)
|
||||
}
|
||||
|
||||
// ReadLastSyncAttempt returns the timestamp of the most recent sync attempt
|
||||
// (success or failure). A missing or unparseable file resolves to the zero
|
||||
// time so callers can treat "never attempted" as "infinitely old".
|
||||
func ReadLastSyncAttempt(path string) time.Time {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
secs, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
return time.Unix(secs, 0)
|
||||
}
|
||||
|
||||
// WriteLastSyncAttempt records the current time as the latest sync attempt.
|
||||
// Both the success and failure code paths must call this so a failing cloud
|
||||
// endpoint does not trigger an attempt on every PMG invocation.
|
||||
func WriteLastSyncAttempt(path string) error {
|
||||
contents := []byte(strconv.FormatInt(time.Now().Unix(), 10))
|
||||
return os.WriteFile(path, contents, 0o600)
|
||||
}
|
||||
|
||||
// SyncCooldownElapsed reports whether enough time has passed since the last
|
||||
// recorded sync attempt to allow a new one.
|
||||
func SyncCooldownElapsed(path string, minInterval time.Duration) bool {
|
||||
last := ReadLastSyncAttempt(path)
|
||||
if last.IsZero() {
|
||||
return true
|
||||
}
|
||||
return time.Since(last) >= minInterval
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReadLastSyncAttempt(t *testing.T) {
|
||||
t.Run("missing file returns zero time", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "does-not-exist")
|
||||
assert.True(t, ReadLastSyncAttempt(path).IsZero())
|
||||
})
|
||||
|
||||
t.Run("empty file returns zero time", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "lastrun")
|
||||
require.NoError(t, os.WriteFile(path, []byte(""), 0o600))
|
||||
assert.True(t, ReadLastSyncAttempt(path).IsZero())
|
||||
})
|
||||
|
||||
t.Run("unparseable contents return zero time", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "lastrun")
|
||||
require.NoError(t, os.WriteFile(path, []byte("not-a-number"), 0o600))
|
||||
assert.True(t, ReadLastSyncAttempt(path).IsZero())
|
||||
})
|
||||
|
||||
t.Run("valid epoch with trailing whitespace is parsed", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "lastrun")
|
||||
want := time.Unix(1700000000, 0)
|
||||
require.NoError(t, os.WriteFile(path, []byte("1700000000\n"), 0o600))
|
||||
assert.True(t, ReadLastSyncAttempt(path).Equal(want))
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteLastSyncAttempt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "lastrun")
|
||||
|
||||
before := time.Now()
|
||||
require.NoError(t, WriteLastSyncAttempt(path))
|
||||
after := time.Now()
|
||||
|
||||
got := ReadLastSyncAttempt(path)
|
||||
require.False(t, got.IsZero())
|
||||
|
||||
// Allow 1s slack on both ends to absorb second-level truncation by
|
||||
// WriteLastSyncAttempt's Unix() serialization.
|
||||
assert.False(t, got.Before(before.Add(-time.Second)), "got=%s before=%s", got, before)
|
||||
assert.False(t, got.After(after.Add(time.Second)), "got=%s after=%s", got, after)
|
||||
}
|
||||
|
||||
func TestSyncCooldownElapsed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "lastrun")
|
||||
|
||||
t.Run("missing lastrun is treated as old enough", func(t *testing.T) {
|
||||
assert.True(t, SyncCooldownElapsed(path, time.Hour))
|
||||
})
|
||||
|
||||
t.Run("recent lastrun blocks", func(t *testing.T) {
|
||||
require.NoError(t, WriteLastSyncAttempt(path))
|
||||
assert.False(t, SyncCooldownElapsed(path, time.Hour))
|
||||
})
|
||||
|
||||
t.Run("old lastrun allows", func(t *testing.T) {
|
||||
old := time.Now().Add(-2 * time.Hour).Unix()
|
||||
require.NoError(t, os.WriteFile(path, []byte(strconv.FormatInt(old, 10)), 0o600))
|
||||
assert.True(t, SyncCooldownElapsed(path, time.Hour))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSyncLockSerializes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sync.lock")
|
||||
|
||||
first := NewSyncLock(path)
|
||||
ok, err := first.TryLock()
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
defer func() {
|
||||
require.NoError(t, first.Unlock())
|
||||
}()
|
||||
|
||||
t.Run("TryLock from a sibling lock fails while first is held", func(t *testing.T) {
|
||||
second := NewSyncLock(path)
|
||||
ok, err := second.TryLock()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("TryLockContext from a sibling times out while first is held", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
second := NewSyncLock(path)
|
||||
ok, err := second.TryLockContext(ctx, 25*time.Millisecond)
|
||||
// On context cancel TryLockContext can return either (false, nil) or
|
||||
// (false, context.DeadlineExceeded) depending on timing; both satisfy
|
||||
// the "did not acquire" assertion.
|
||||
if err != nil {
|
||||
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSyncLockAllowsSecondAcquisitionAfterRelease(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sync.lock")
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
first := NewSyncLock(path)
|
||||
ok, err := first.TryLock()
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// assert (not require) is safe from a goroutine. require.NoError
|
||||
// calls t.FailNow which is not goroutine-safe.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
assert.NoError(t, first.Unlock())
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
second := NewSyncLock(path)
|
||||
ok, err = second.TryLock()
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.NoError(t, second.Unlock())
|
||||
}
|
||||
@@ -166,6 +166,12 @@ func main() {
|
||||
log.Warnf("failed to close eventlog: %v", err)
|
||||
}
|
||||
}()
|
||||
// Defers run LIFO. The spawn must observe the parent's released SQLite
|
||||
// handle, so we declare it BEFORE audit.Close's defer (it then runs AFTER
|
||||
// audit.Close at exit time).
|
||||
defer func() {
|
||||
audit.MaybeSpawnBackgroundSync(config.Get())
|
||||
}()
|
||||
defer func() {
|
||||
if err := audit.Close(); err != nil {
|
||||
log.Warnf("failed to close audit system: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user