mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
+121
-8
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user