fix: MacOS MDM based Deployment (#277)

* fix: MacOS MDM deployment script

* fix: Handle shell alias for bash on macos

* fix: Code review fixes

* fix: Code review fixes

* feat: Add support for global config file

* feat: Add support for global config file

* fix: Code review fixes

* fix: Avoid blocking CLI for analytics flush
This commit is contained in:
Abhisek Datta
2026-05-21 14:32:30 +05:30
committed by GitHub
parent 875cda2e43
commit b15ce33fe4
24 changed files with 1396 additions and 374 deletions
+121 -8
View File
@@ -1,6 +1,7 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -13,6 +14,7 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/usefulerror"
"github.com/spf13/viper"
)
@@ -198,7 +200,8 @@ type RuntimeConfig struct {
// Internal config values computed at runtime and must be accessed via. API
configDir string
configFilePath string
configFilePath string // active config: globally managed file if present, else per-user
userConfigFilePath string // per-user config file, used for writes and removal
eventLogDir string
sandboxProfileDir string
sandboxViolationCacheDir string
@@ -222,11 +225,26 @@ func (r *RuntimeConfig) CloudSyncLastRunPath() string {
return filepath.Join(r.configDir, "cloud-sync.lastrun")
}
// ConfigFilePath returns the path to the config file.
// ConfigFilePath returns the path to the active config file (the globally
// managed file when present, otherwise the per-user file).
func (r *RuntimeConfig) ConfigFilePath() string {
return r.configFilePath
}
// UserConfigFilePath returns the per-user config file path, regardless of
// whether a globally managed config is active.
func (r *RuntimeConfig) UserConfigFilePath() string {
return r.userConfigFilePath
}
// IsManaged reports whether the active config is the globally managed file.
// When true, the per-user file is ignored and config writes are refused. It is
// derived: the active path differs from the per-user path only when the global
// file was chosen.
func (r *RuntimeConfig) IsManaged() bool {
return r.configFilePath != r.userConfigFilePath
}
// EventLogDir returns the path to the event log directory.
func (r *RuntimeConfig) EventLogDir() string {
return r.eventLogDir
@@ -351,9 +369,14 @@ func initConfig() {
panic(fmt.Errorf("failed to get config directory: %w", err))
}
configFilePath, err := configFilePath()
activeConfigPath, err := resolveConfigFile()
if err != nil {
panic(fmt.Errorf("failed to get config file path: %w", err))
panic(fmt.Errorf("failed to resolve config file path: %w", err))
}
userConfigPath, err := userConfigFilePath()
if err != nil {
panic(fmt.Errorf("failed to get user config file path: %w", err))
}
eventLogDir, err := eventLogDir()
@@ -372,7 +395,8 @@ func initConfig() {
}
globalConfig.configDir = configDir
globalConfig.configFilePath = configFilePath
globalConfig.configFilePath = activeConfigPath
globalConfig.userConfigFilePath = userConfigPath
globalConfig.eventLogDir = eventLogDir
globalConfig.sandboxProfileDir = sandboxProfileDir
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
@@ -409,8 +433,8 @@ func configDir() (string, error) {
return filepath.Join(userConfigDir, pmgDefaultHomeRelativePath), nil
}
// configFilePath computes the path to the config file.
func configFilePath() (string, error) {
// userConfigFilePath computes the path to the per-user config file.
func userConfigFilePath() (string, error) {
configDir, err := configDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
@@ -419,6 +443,61 @@ func configFilePath() (string, error) {
return filepath.Join(configDir, pmgConfigFileName), nil
}
// globalConfigDirOverride replaces the OS-level managed config directory. It
// exists only for tests within this package. There is intentionally no env var
// or flag for it, so a user cannot point the "managed" config at their own file
// and bypass the globally managed config.
var globalConfigDirOverride string
// globalConfigDir returns the OS-level directory for a globally managed config
// file, or "" when the platform has no such location.
func globalConfigDir() string {
if globalConfigDirOverride != "" {
return globalConfigDirOverride
}
switch runtime.GOOS {
case "darwin":
return "/Library/Application Support/safedep/pmg"
case "linux":
return "/etc/safedep/pmg"
case "windows":
programData := os.Getenv("PROGRAMDATA")
if programData == "" {
programData = `C:\ProgramData`
}
return filepath.Join(programData, "safedep", "pmg")
}
return ""
}
// globalConfigFilePath returns the path to the globally managed config file, or
// "" when the platform has no global config location.
func globalConfigFilePath() string {
dir := globalConfigDir()
if dir == "" {
return ""
}
return filepath.Join(dir, pmgConfigFileName)
}
// resolveConfigFile picks the active config file. The globally managed file,
// when present, is authoritative and the per-user file is ignored entirely.
func resolveConfigFile() (string, error) {
if global := globalConfigFilePath(); global != "" && isRegularFile(global) {
return global, nil
}
return userConfigFilePath()
}
func isRegularFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular()
}
// eventLogDir computes the path to the event log directory.
func eventLogDir() (string, error) {
// For rationale on why different directory for Windows, see:
@@ -515,7 +594,14 @@ func ConfigureSandbox(mayDownloadPackages bool) {
// If the config file does not exist, the full template is written.
// If it already exists, missing keys from the template are merged
// into the existing config while preserving all user values and comments.
//
// When a globally managed config is active, this is a no-op: the per-user
// file is ignored at load time, so creating it would only mislead.
func WriteTemplateConfig() error {
if globalConfig.IsManaged() {
return nil
}
configDir, err := configDir()
if err != nil {
return fmt.Errorf("failed to get config directory: %w", err)
@@ -525,7 +611,7 @@ func WriteTemplateConfig() error {
return fmt.Errorf("failed to create config directory: %w", err)
}
configFilePath, err := configFilePath()
configFilePath, err := userConfigFilePath()
if err != nil {
return fmt.Errorf("failed to get config file path: %w", err)
}
@@ -549,3 +635,30 @@ func WriteTemplateConfig() error {
return nil
}
// RemoveUserConfigFile deletes the per-user config file. It never touches the
// globally managed file. A missing file is not an error.
func RemoveUserConfigFile() error {
path, err := userConfigFilePath()
if err != nil {
return fmt.Errorf("failed to get config file path: %w", err)
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove config file %q: %w", path, err)
}
return nil
}
// NewManagedConfigError returns the error shown when a user tries to change a
// globally managed configuration. It carries a useful error code and help text
// so the CLI presents it as an expected, actionable failure rather than a bug.
func NewManagedConfigError() error {
msg := fmt.Sprintf("configuration is globally managed (%s) and cannot be changed", globalConfig.configFilePath)
return usefulerror.Useful().
WithCode(usefulerror.ErrCodePermissionDenied).
WithHumanError(msg).
WithHelp("This machine's PMG configuration is centrally managed. Contact your administrator to change it.").
Wrap(errors.New(msg))
}
+103
View File
@@ -0,0 +1,103 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// useManagedConfigDir points the globally managed config at dir for the test,
// and restores the default resolution afterwards.
func useManagedConfigDir(t *testing.T, dir string) {
t.Helper()
globalConfigDirOverride = dir
t.Cleanup(func() {
globalConfigDirOverride = ""
initConfig()
})
}
func TestManagedConfigTakesPrecedenceAndIgnoresUserFile(t *testing.T) {
globalDir := t.TempDir()
userDir := t.TempDir()
// Global file sets paranoid=true (default is false). User file sets
// transitive=false (default is true) and must be ignored entirely.
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(userDir, "config.yml"), []byte("transitive: false\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", userDir)
initConfig()
cfg := Get()
assert.True(t, cfg.IsManaged())
assert.Equal(t, filepath.Join(globalDir, "config.yml"), cfg.ConfigFilePath())
assert.Equal(t, filepath.Join(userDir, "config.yml"), cfg.UserConfigFilePath())
assert.True(t, cfg.Config.Paranoid, "value should come from the global file")
assert.True(t, cfg.Config.Transitive, "user file must be ignored, so this stays at the template default")
}
func TestManagedConfigFallsBackToUserWhenGlobalAbsent(t *testing.T) {
globalDir := t.TempDir() // no config.yml written here
userDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(userDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", userDir)
initConfig()
cfg := Get()
assert.False(t, cfg.IsManaged())
assert.Equal(t, filepath.Join(userDir, "config.yml"), cfg.ConfigFilePath())
assert.True(t, cfg.Config.Paranoid, "value should come from the user file")
}
func TestWriteTemplateConfigNoOpWhenManaged(t *testing.T) {
globalDir := t.TempDir()
userDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", userDir)
initConfig()
require.NoError(t, WriteTemplateConfig())
assert.NoFileExists(t, filepath.Join(userDir, "config.yml"), "managed mode must not create a per-user config")
}
func TestSetConfigValueRefusedWhenManaged(t *testing.T) {
globalDir := t.TempDir()
userDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", userDir)
initConfig()
err := SetConfigValue("paranoid", "false")
require.Error(t, err)
assert.Contains(t, err.Error(), "globally managed")
assert.NoFileExists(t, filepath.Join(userDir, "config.yml"))
}
func TestRemoveUserConfigFileNeverTouchesGlobal(t *testing.T) {
globalDir := t.TempDir()
userDir := t.TempDir()
globalFile := filepath.Join(globalDir, "config.yml")
userFile := filepath.Join(userDir, "config.yml")
require.NoError(t, os.WriteFile(globalFile, []byte("paranoid: true\n"), 0o644))
require.NoError(t, os.WriteFile(userFile, []byte("transitive: false\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", userDir)
initConfig()
require.NoError(t, RemoveUserConfigFile())
assert.NoFileExists(t, userFile, "per-user file should be removed")
assert.FileExists(t, globalFile, "globally managed file must be left intact")
}
+3 -4
View File
@@ -13,10 +13,9 @@ import (
// Precedence (highest to lowest): cobra flags > env vars > config file > defaults.
// Cobra flags write directly to the config struct after this function runs.
func loadViperConfig() error {
configPath, err := configFilePath()
if err != nil {
return fmt.Errorf("failed to get config file path: %w", err)
}
// Use the active config path resolved by initConfig (globally managed file
// when present, otherwise the per-user file).
configPath := globalConfig.configFilePath
v := viper.New()
v.SetConfigType("yaml")
+5 -1
View File
@@ -15,7 +15,11 @@ import (
// It does not update the in-memory config or viper state. Callers that need
// the updated value must re-initialize the config after calling this function.
func SetConfigValue(key, value string) error {
configPath, err := configFilePath()
if globalConfig.IsManaged() {
return NewManagedConfigError()
}
configPath, err := userConfigFilePath()
if err != nil {
return fmt.Errorf("failed to get config file path: %w", err)
}