mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: add Linux system-wide setup
Install shared shims, managed configuration, and login-shell PATH integration so golden images and multi-user hosts can protect package installs for every user. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: System install design
|
||||
overview: "Design for `pmg setup install --system` (Linux): general system-wide shims + config + `/etc/profile.d`, motivated by Docker golden images but not Docker-specific. Venv-after-activate remains a documented limitation."
|
||||
todos:
|
||||
- id: spec
|
||||
content: Write design spec to docs/specs/ after user approves design
|
||||
status: completed
|
||||
- id: eventlog-root
|
||||
content: Eventlog soft-fail + root-without--system warning
|
||||
status: completed
|
||||
- id: system-artifacts
|
||||
content: System shim manager, profile.d writer, WriteSystemTemplateConfig
|
||||
status: completed
|
||||
- id: cli-flags
|
||||
content: Wire --system on install/remove with uid checks
|
||||
status: completed
|
||||
- id: info-doctor
|
||||
content: Update setup info and doctor for system scope
|
||||
status: completed
|
||||
- id: docs-tests
|
||||
content: Docs (golden image, venv limit) + unit tests
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# PMG System Install (`--system`) — Design
|
||||
|
||||
Re-evaluation of [issue #317](https://github.com/safedep/pmg/issues/317) against current codebase. Prerequisite [PR #323](https://github.com/safedep/pmg/pull/323) (`PMG_SHIM_PATH`) is already merged.
|
||||
|
||||
## Goal
|
||||
|
||||
Root can install PMG once for **all users** on a Linux host (golden Docker image, shared VM). Later `USER appuser` / non-root logins get interception without per-user `pmg setup install`.
|
||||
|
||||
Primary motivator: package installs inside `docker build` (host GHA PMG cannot see them). Feature itself is a **general Linux system install**, not a Docker mode.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- macOS / Windows `--system`
|
||||
- Persistent proxy baked into images
|
||||
- In-place wrap of `/usr/local/bin` binaries
|
||||
- Shell **function** wrappers (venv interactive fix)
|
||||
- Making host PMG intercept `docker build` from outside the image
|
||||
- Closing the venv-after-`activate` PATH race via shims
|
||||
|
||||
## Locked decisions
|
||||
|
||||
|
||||
| Topic | Choice |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Scope | Linux only |
|
||||
| Shim location | `/usr/local/lib/pmg/bin` (not `/usr/local/bin`) |
|
||||
| Config | Write `/etc/safedep/pmg/config.yml` (read path already exists) |
|
||||
| PATH on VMs / login shells | `/etc/profile.d/pmg.sh` prepends shim dir |
|
||||
| PATH in Docker builds | Image author sets `ENV PATH="/usr/local/lib/pmg/bin:$PATH"` (documented; required for `RUN`) |
|
||||
| Venv after `activate` | Documented limitation → use `pmg pip …` |
|
||||
| Proxy-in-image | Rejected for this use case |
|
||||
| Layers | System and per-user installs independent |
|
||||
|
||||
|
||||
## User flows
|
||||
|
||||
### Golden Docker base
|
||||
|
||||
```dockerfile
|
||||
RUN curl -fsSL … | sh && pmg setup install --system
|
||||
ENV PATH="/usr/local/lib/pmg/bin:$PATH"
|
||||
```
|
||||
|
||||
Child images:
|
||||
|
||||
```dockerfile
|
||||
FROM company/golden:latest
|
||||
USER appuser
|
||||
RUN npm ci # intercepted
|
||||
RUN pip install pkg # intercepted (system pip)
|
||||
# RUN .venv/bin/activate && pip install x # NOT intercepted — use pmg pip
|
||||
```
|
||||
|
||||
### Multi-user Linux VM
|
||||
|
||||
```bash
|
||||
sudo pmg setup install --system
|
||||
# new login shells get PATH via /etc/profile.d/pmg.sh
|
||||
```
|
||||
|
||||
### CLI UX
|
||||
|
||||
```bash
|
||||
sudo pmg setup install --system # root-only; fail fast otherwise
|
||||
sudo pmg setup remove --system
|
||||
pmg setup info # reports system / user / both
|
||||
pmg setup doctor # checks system shim dir + on PATH
|
||||
```
|
||||
|
||||
- Root without `--system` → warn (other users not covered; point to `--system`), then proceed with per-user install into root’s HOME.
|
||||
- Non-root `--system` → `usefulerror` + sudo guidance.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph install [pmg setup install --system]
|
||||
Cfg["/etc/safedep/pmg/config.yml"]
|
||||
Shims["/usr/local/lib/pmg/bin/*"]
|
||||
Profile["/etc/profile.d/pmg.sh"]
|
||||
end
|
||||
subgraph path [How PATH gets shims]
|
||||
Env["Docker ENV PATH"]
|
||||
Prof["profile.d on login shells"]
|
||||
end
|
||||
Shims --> Env
|
||||
Shims --> Prof
|
||||
Env --> Run["Dockerfile RUN / any process with ENV"]
|
||||
Prof --> Login["VM interactive/login shells"]
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Components to add/change
|
||||
|
||||
- `[cmd/setup/setup.go](cmd/setup/setup.go)` — `--system` flag, uid-0 preflight, branch install/remove; root warning without flag
|
||||
- `[config/config.go](config/config.go)` — `WriteSystemTemplateConfig()` → `globalConfigDir()`; remove only with `--system`
|
||||
- `[internal/shim/shim.go](internal/shim/shim.go)` — `NewSystemShimManager()` with `BinDir=/usr/local/lib/pmg/bin`, no per-user rc edits
|
||||
- New system profile writer — `/etc/profile.d/pmg.sh` (marker-gated, PATH prepend only)
|
||||
- `[cmd/setup/info.go](cmd/setup/info.go)` / `[cmd/setup/doctor.go](cmd/setup/doctor.go)` — system scope reporting + PATH check
|
||||
- `[main.go](main.go)` / eventlog — soft-fail when log init fails (unusable HOME); do not `Fatalf`
|
||||
|
||||
Reuse existing `PMG_SHIM_PATH` filtering in `[internal/shim/path.go](internal/shim/path.go)`; no recursion work needed for the new shim dir.
|
||||
|
||||
## Corner cases (handled or documented)
|
||||
|
||||
|
||||
| Case | Handling |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Docker `RUN` does not source profile.d | Document `ENV PATH=…` in golden image |
|
||||
| Login shell resets PATH via `/etc/profile` | profile.d restores shim dir |
|
||||
| Child Dockerfile overwrites `PATH` without shim dir | Document; doctor can detect when run |
|
||||
| Venv `activate` then `pip` | Documented limitation → `pmg pip` |
|
||||
| System + per-user both present | Independent; remove is scoped |
|
||||
| `PMG_SHIM_PATH` unset + direct `pmg npm` | Legacy `/.pmg/bin` only; system dir not stripped — prefer shim invocation or ensure env from shim |
|
||||
| Unwritable HOME | Eventlog soft-fail |
|
||||
| Windows | `--system` → not supported |
|
||||
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests with path overrides (same pattern as `globalConfigDirOverride`)
|
||||
- Install/remove idempotence for system shims + profile.d
|
||||
- Doctor/info system detection
|
||||
- Document a minimal Dockerfile golden-image smoke example in docs (not necessarily CI matrix in v1)
|
||||
|
||||
## Docs
|
||||
|
||||
- Setup / config docs: `--system`, golden-image snippet, VM vs Docker PATH, venv limitation
|
||||
- Clarify host GHA PMG does not protect in-image `docker build` installs — bake PMG into the image
|
||||
|
||||
## Implementation order (after spec approval)
|
||||
|
||||
1. Soft-fail eventlog + root warn (small, unblocks containers)
|
||||
2. System shim manager + profile.d writer + config write
|
||||
3. Wire `--system` on install/remove
|
||||
4. info/doctor
|
||||
5. Docs + tests
|
||||
|
||||
@@ -102,6 +102,8 @@ pmg setup install
|
||||
```
|
||||
|
||||
> **Tip:** Re-run `pmg setup install` after upgrading PMG to pick up new configuration options.
|
||||
>
|
||||
> Linux all-users / golden images: `sudo pmg setup install --system` — see [docs/system-install.md](./docs/system-install.md).
|
||||
|
||||
Validate your installation and verify protection is working:
|
||||
|
||||
|
||||
+40
-8
@@ -91,11 +91,17 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
|
||||
Name: checkEventLogDir,
|
||||
Category: "Configuration",
|
||||
Run: func() doctor.CheckResult {
|
||||
if cfg.Config.SkipEventLogging {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusWarn,
|
||||
Message: "Event logging is disabled",
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(cfg.EventLogDir())
|
||||
if err != nil {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusFail,
|
||||
Message: "Event log directory not found",
|
||||
Status: doctor.StatusWarn,
|
||||
Message: "Event log directory not found (PMG still runs without event logs)",
|
||||
}
|
||||
}
|
||||
if !info.IsDir() {
|
||||
@@ -114,6 +120,12 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
|
||||
Name: checkShellAliases,
|
||||
Category: "Shell Integration",
|
||||
Run: func() doctor.CheckResult {
|
||||
if shim.SystemShimsInstalled() {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: "System shims installed (aliases optional)",
|
||||
}
|
||||
}
|
||||
aliasCfg := alias.DefaultConfig()
|
||||
rcFileManager, err := alias.NewDefaultRcFileManager(aliasCfg.RcFileName)
|
||||
if err != nil {
|
||||
@@ -146,6 +158,12 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
|
||||
Name: checkShimDirectory,
|
||||
Category: "Shell Integration",
|
||||
Run: func() doctor.CheckResult {
|
||||
if shim.SystemShimsInstalled() {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: fmt.Sprintf("System shim directory found (%s)", shim.SystemBinDir()),
|
||||
}
|
||||
}
|
||||
sm, err := shim.NewDefaultShimManager()
|
||||
if err != nil {
|
||||
return doctor.CheckResult{
|
||||
@@ -171,6 +189,20 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
|
||||
Name: checkShimInPath,
|
||||
Category: "Shell Integration",
|
||||
Run: func() doctor.CheckResult {
|
||||
pathEntries := filepath.SplitList(os.Getenv("PATH"))
|
||||
if shim.SystemShimsInstalled() {
|
||||
systemDir := shim.SystemBinDir()
|
||||
if slices.Contains(pathEntries, systemDir) {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: "System shim directory is in PATH",
|
||||
}
|
||||
}
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusFail,
|
||||
Message: fmt.Sprintf("System shim directory not in PATH (add ENV PATH=\"%s:$PATH\" for Docker RUN)", systemDir),
|
||||
}
|
||||
}
|
||||
sm, err := shim.NewDefaultShimManager()
|
||||
if err != nil {
|
||||
return doctor.CheckResult{
|
||||
@@ -179,7 +211,7 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
|
||||
}
|
||||
}
|
||||
shimDir := sm.GetBinDir()
|
||||
if slices.Contains(filepath.SplitList(os.Getenv("PATH")), shimDir) {
|
||||
if slices.Contains(pathEntries, shimDir) {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: "Shim directory is in PATH",
|
||||
@@ -334,15 +366,15 @@ var checkDisplayNames = map[string]string{
|
||||
var checkFixes = map[string]string{
|
||||
checkConfigFile: "pmg setup install",
|
||||
checkEventLogDir: "pmg setup install",
|
||||
checkShellAliases: "pmg setup install",
|
||||
checkShimDirectory: "pmg setup install",
|
||||
checkShimInPath: "Restart shell or source config",
|
||||
checkShellAliases: "pmg setup install [--system]",
|
||||
checkShimDirectory: "pmg setup install [--system]",
|
||||
checkShimInPath: "Restart shell, source profile, or set ENV PATH for Docker",
|
||||
checkProxyMode: "Set proxy.enabled: true in config",
|
||||
checkSandbox: "Set sandbox.enabled: true in config",
|
||||
checkDependencyCooldown: "Set dependency_cooldown.enabled: true in config",
|
||||
checkEventLogging: "Set skip_event_logging: false in config",
|
||||
checkProtectionNpm: "pmg setup install",
|
||||
checkProtectionPip: "pmg setup install",
|
||||
checkProtectionNpm: "pmg setup install [--system]",
|
||||
checkProtectionPip: "pmg setup install [--system]",
|
||||
checkCA: "pmg setup cert install",
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/safedep/pmg/internal/alias"
|
||||
"github.com/safedep/pmg/internal/analytics"
|
||||
"github.com/safedep/pmg/internal/audit"
|
||||
"github.com/safedep/pmg/internal/shim"
|
||||
"github.com/safedep/pmg/internal/ui"
|
||||
"github.com/safedep/pmg/internal/version"
|
||||
"github.com/safedep/pmg/proxy/certmanager"
|
||||
@@ -78,6 +79,12 @@ func executeSetupInfo() error {
|
||||
|
||||
shellEntries["Detected Shell"] = shell
|
||||
shellEntries["Alias Installed"] = strconv.FormatBool(isInstalled)
|
||||
shellEntries["User Shims"] = strconv.FormatBool(shim.UserShimsInstalled())
|
||||
shellEntries["System Shims"] = strconv.FormatBool(shim.SystemShimsInstalled())
|
||||
shellEntries["System Profile"] = strconv.FormatBool(shim.SystemProfileInstalled())
|
||||
if shim.SystemShimsInstalled() {
|
||||
shellEntries["System Shim Dir"] = shim.SystemBinDir()
|
||||
}
|
||||
ui.PrintInfoSection("Shell Integration", shellEntries)
|
||||
|
||||
// Security section
|
||||
|
||||
+131
-38
@@ -1,10 +1,14 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/safedep/dry/usefulerror"
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/errcodes"
|
||||
"github.com/safedep/pmg/internal/alias"
|
||||
"github.com/safedep/pmg/internal/shim"
|
||||
"github.com/safedep/pmg/internal/ui"
|
||||
@@ -12,7 +16,13 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var setupRemoveConfigFile = false
|
||||
var (
|
||||
setupRemoveConfigFile bool
|
||||
setupInstallSystem bool
|
||||
setupRemoveSystem bool
|
||||
)
|
||||
|
||||
var setupGeteuid = os.Geteuid
|
||||
|
||||
func NewSetupCommand() *cobra.Command {
|
||||
setupCmd := &cobra.Command{
|
||||
@@ -35,7 +45,7 @@ func NewSetupCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func NewInstallCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Setup PMG config, aliases, and shims for package managers (npm, pnpm, pip, and more)",
|
||||
SilenceUsage: true,
|
||||
@@ -44,9 +54,20 @@ func NewInstallCommand() *cobra.Command {
|
||||
return install()
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&setupInstallSystem, "system", false, "Install system-wide for all users (Linux, requires root)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func install() error {
|
||||
if setupInstallSystem {
|
||||
return installSystem()
|
||||
}
|
||||
|
||||
if setupGeteuid() == 0 {
|
||||
fmt.Printf("%s %s\n", ui.Colors.Yellow("⚠"),
|
||||
"Running as root without --system configures only root's home. Use `pmg setup install --system` so all users are covered.")
|
||||
}
|
||||
|
||||
if err := config.WriteTemplateConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write template config: %w", err)
|
||||
}
|
||||
@@ -88,6 +109,28 @@ func install() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func installSystem() error {
|
||||
if err := errIfSystemInstallAllowed(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := config.WriteSystemTemplateConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write system config: %w", err)
|
||||
}
|
||||
|
||||
shimMgr, err := shim.NewSystemShimManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create system shim manager: %w", err)
|
||||
}
|
||||
|
||||
if err := shimMgr.Install(); err != nil {
|
||||
return fmt.Errorf("failed to install system shims: %w", err)
|
||||
}
|
||||
|
||||
ui.PrintSetupSystemInstallCmdInfo(shimMgr.GetBinDir(), config.SystemConfigDir(), shim.SystemProfilePath())
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewRemoveCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
@@ -95,45 +138,95 @@ func NewRemoveCommand() *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Print(ui.GeneratePMGBanner(version.Version, version.Commit))
|
||||
|
||||
if setupRemoveConfigFile {
|
||||
// Only ever remove the per-user file; the globally managed
|
||||
// config is not ours to delete from a per-user uninstall.
|
||||
if err := config.RemoveUserConfigFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG config removed. No aliases or shims to clean up on Windows.")
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := alias.DefaultConfig()
|
||||
rcFileManager, err := alias.NewDefaultRcFileManager(cfg.RcFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aliasManager := alias.New(cfg, rcFileManager)
|
||||
if err := aliasManager.Remove(); err != nil {
|
||||
return fmt.Errorf("failed to remove aliases: %w", err)
|
||||
}
|
||||
|
||||
shimMgr, err := shim.NewDefaultShimManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create shim manager: %w", err)
|
||||
}
|
||||
|
||||
if err := shimMgr.Remove(); err != nil {
|
||||
return fmt.Errorf("failed to remove shims: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG aliases and shims removed. Restart your terminal for changes to take effect")
|
||||
return nil
|
||||
return remove()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&setupRemoveConfigFile, "config-file", false, "Remove the config file")
|
||||
cmd.Flags().BoolVar(&setupRemoveSystem, "system", false, "Remove system-wide install (Linux, requires root)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func remove() error {
|
||||
if setupRemoveSystem {
|
||||
return removeSystem()
|
||||
}
|
||||
|
||||
if setupRemoveConfigFile {
|
||||
// Only ever remove the per-user file; the globally managed
|
||||
// config is not ours to delete from a per-user uninstall.
|
||||
if err := config.RemoveUserConfigFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG config removed. No aliases or shims to clean up on Windows.")
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := alias.DefaultConfig()
|
||||
rcFileManager, err := alias.NewDefaultRcFileManager(cfg.RcFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aliasManager := alias.New(cfg, rcFileManager)
|
||||
if err := aliasManager.Remove(); err != nil {
|
||||
return fmt.Errorf("failed to remove aliases: %w", err)
|
||||
}
|
||||
|
||||
shimMgr, err := shim.NewDefaultShimManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create shim manager: %w", err)
|
||||
}
|
||||
|
||||
if err := shimMgr.Remove(); err != nil {
|
||||
return fmt.Errorf("failed to remove shims: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG aliases and shims removed. Restart your terminal for changes to take effect")
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeSystem() error {
|
||||
if err := errIfSystemInstallAllowed(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if setupRemoveConfigFile {
|
||||
if err := config.RemoveSystemConfigFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
shimMgr, err := shim.NewSystemShimManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create system shim manager: %w", err)
|
||||
}
|
||||
|
||||
if err := shimMgr.Remove(); err != nil {
|
||||
return fmt.Errorf("failed to remove system shims: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG system install removed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func errIfSystemInstallAllowed() error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return usefulerror.NewUsefulError().
|
||||
WithCode(errcodes.UnsupportedPlatform).
|
||||
WithHumanError("system install is only supported on Linux").
|
||||
WithHelp("Use `pmg setup install` without --system for per-user setup, or run on Linux").
|
||||
Wrap(errors.New("unsupported platform for --system"))
|
||||
}
|
||||
if setupGeteuid() != 0 {
|
||||
return usefulerror.NewUsefulError().
|
||||
WithCode(errcodes.PermissionDenied).
|
||||
WithHumanError("system install requires root").
|
||||
WithHelp("Re-run as root, e.g. `sudo pmg setup install --system`").
|
||||
Wrap(errors.New("not root"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/safedep/dry/usefulerror"
|
||||
"github.com/safedep/pmg/errcodes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestErrIfSystemInstallAllowed(t *testing.T) {
|
||||
orig := setupGeteuid
|
||||
t.Cleanup(func() { setupGeteuid = orig })
|
||||
|
||||
setupGeteuid = func() int { return 0 }
|
||||
err := errIfSystemInstallAllowed()
|
||||
if runtime.GOOS == "linux" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
usefulErr, ok := usefulerror.AsUsefulError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errcodes.UnsupportedPlatform, usefulErr.Code())
|
||||
}
|
||||
|
||||
setupGeteuid = func() int { return 1000 }
|
||||
err = errIfSystemInstallAllowed()
|
||||
require.Error(t, err)
|
||||
usefulErr, ok := usefulerror.AsUsefulError(err)
|
||||
require.True(t, ok)
|
||||
if runtime.GOOS == "linux" {
|
||||
assert.Equal(t, errcodes.PermissionDenied, usefulErr.Code())
|
||||
} else {
|
||||
assert.Equal(t, errcodes.UnsupportedPlatform, usefulErr.Code())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSystemRequiresLinuxAndRoot(t *testing.T) {
|
||||
orig := setupGeteuid
|
||||
t.Cleanup(func() {
|
||||
setupGeteuid = orig
|
||||
setupInstallSystem = false
|
||||
})
|
||||
|
||||
setupInstallSystem = true
|
||||
setupGeteuid = func() int { return 0 }
|
||||
|
||||
err := install()
|
||||
if runtime.GOOS == "linux" {
|
||||
if err != nil {
|
||||
usefulErr, ok := usefulerror.AsUsefulError(err)
|
||||
if ok {
|
||||
assert.NotEqual(t, errcodes.UnsupportedPlatform, usefulErr.Code())
|
||||
assert.NotEqual(t, errcodes.PermissionDenied, usefulErr.Code())
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
usefulErr, ok := usefulerror.AsUsefulError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errcodes.UnsupportedPlatform, usefulErr.Code())
|
||||
}
|
||||
+49
-17
@@ -821,20 +821,59 @@ func WriteTemplateConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
configDir, err := configDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
configFilePath, err := userConfigFilePath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config file path: %w", err)
|
||||
}
|
||||
|
||||
return writeTemplateConfigFile(configFilePath)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
return removeFileIfExists(path)
|
||||
}
|
||||
|
||||
// WriteSystemTemplateConfig writes the template configuration to the OS-level
|
||||
// managed config path (e.g. /etc/safedep/pmg/config.yml on Linux). Used by
|
||||
// `pmg setup install --system`.
|
||||
func WriteSystemTemplateConfig() error {
|
||||
path := globalConfigFilePath()
|
||||
if path == "" {
|
||||
return fmt.Errorf("system config is not supported on %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
return writeTemplateConfigFile(path)
|
||||
}
|
||||
|
||||
// RemoveSystemConfigFile deletes the globally managed config file. A missing
|
||||
// file is not an error. Returns an error when the platform has no system path.
|
||||
func RemoveSystemConfigFile() error {
|
||||
path := globalConfigFilePath()
|
||||
if path == "" {
|
||||
return fmt.Errorf("system config is not supported on %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
return removeFileIfExists(path)
|
||||
}
|
||||
|
||||
// SystemConfigDir returns the OS-level managed config directory, or "" when
|
||||
// unsupported.
|
||||
func SystemConfigDir() string {
|
||||
return globalConfigDir()
|
||||
}
|
||||
|
||||
func writeTemplateConfigFile(configFilePath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(configFilePath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
existingConfig, err := os.ReadFile(configFilePath)
|
||||
if os.IsNotExist(err) {
|
||||
return os.WriteFile(configFilePath, []byte(templateConfig), 0o644)
|
||||
@@ -855,14 +894,7 @@ 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)
|
||||
}
|
||||
|
||||
func removeFileIfExists(path string) error {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
@@ -188,3 +188,19 @@ func TestRemoveUserConfigFileNeverTouchesGlobal(t *testing.T) {
|
||||
assert.NoFileExists(t, userFile, "per-user file should be removed")
|
||||
assert.FileExists(t, globalFile, "globally managed file must be left intact")
|
||||
}
|
||||
|
||||
func TestWriteAndRemoveSystemTemplateConfig(t *testing.T) {
|
||||
globalDir := t.TempDir()
|
||||
useManagedConfigDir(t, globalDir)
|
||||
|
||||
require.NoError(t, WriteSystemTemplateConfig())
|
||||
assert.FileExists(t, filepath.Join(globalDir, "config.yml"))
|
||||
assert.Equal(t, globalDir, SystemConfigDir())
|
||||
assert.Equal(t, filepath.Join(globalDir, "config.yml"), globalConfigFilePath())
|
||||
|
||||
require.NoError(t, WriteSystemTemplateConfig())
|
||||
|
||||
require.NoError(t, RemoveSystemConfigFile())
|
||||
assert.NoFileExists(t, filepath.Join(globalDir, "config.yml"))
|
||||
require.NoError(t, RemoveSystemConfigFile())
|
||||
}
|
||||
|
||||
@@ -113,6 +113,10 @@ Whenever a global config file is present:
|
||||
|
||||
By default a user can still override the global config's values at runtime through `PMG_*` environment variables and CLI flags. Enable lockdown to forbid that.
|
||||
|
||||
## System Install (Linux)
|
||||
|
||||
See [system-install.md](./system-install.md) for `pmg setup install --system`: artifacts, Docker `ENV PATH`, what works vs what differs (aliases, config edit, logs, cloud, CA, sandbox), and permissions.
|
||||
|
||||
### Lockdown
|
||||
|
||||
Add `global_lockdown: true` to the global config to enforce it:
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
- [User Interface](./ui.md)
|
||||
- [Steps to introduce a new Package Manager](./package-manager.md)
|
||||
- [System install (Linux)](./system-install.md)
|
||||
|
||||
@@ -179,8 +179,8 @@ proxy regardless of how the package manager reported the failure.
|
||||
same state file is refused while one is running.
|
||||
- **System-level trust enforcement is out of scope.** The server relies on env
|
||||
var propagation. Enforcing interception for `sudo`-scrubbed environments (e.g.
|
||||
via `iptables`) and system-wide install (`pmg setup install --system`) are
|
||||
tracked separately.
|
||||
via `iptables`) is tracked separately. For system-wide shell shims on Linux,
|
||||
see [system-install.md](./system-install.md).
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# System Install (`pmg setup install --system`) Spec
|
||||
|
||||
Date: 2026-07-10
|
||||
Issue: [#317](https://github.com/safedep/pmg/issues/317)
|
||||
Prerequisite: [#323](https://github.com/safedep/pmg/pull/323) (`PMG_SHIM_PATH`)
|
||||
|
||||
## Problem
|
||||
|
||||
`pmg setup install` writes shims and shell integration relative to the invoking user's HOME. When root installs PMG in a golden Docker image (or shared VM) and a later `USER` / non-root account runs package managers, those users do not get interception.
|
||||
|
||||
Host-side PMG (e.g. GitHub Actions) also cannot see package installs that happen inside `docker build`.
|
||||
|
||||
## Goal
|
||||
|
||||
Root can install PMG once for all users on a Linux host. Feature is a **general Linux system install**, motivated by Docker golden images but not Docker-specific.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- macOS / Windows `--system`
|
||||
- Persistent proxy baked into images
|
||||
- In-place wrap of binaries under `/usr/local/bin`
|
||||
- Shell function wrappers for venv PATH races
|
||||
- Host PMG intercepting `docker build` from outside the image
|
||||
|
||||
## Behaviour
|
||||
|
||||
### Artifacts
|
||||
|
||||
| Artifact | Path |
|
||||
|----------|------|
|
||||
| Config | `/etc/safedep/pmg/config.yml` |
|
||||
| Shims | `/usr/local/lib/pmg/bin/<pm>` |
|
||||
| Login PATH | `/etc/profile.d/pmg.sh` (prepends shim dir) |
|
||||
|
||||
Per-user cache and event logs stay per-user.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
sudo pmg setup install --system
|
||||
sudo pmg setup remove --system
|
||||
sudo pmg setup remove --system --config-file # also remove system config
|
||||
```
|
||||
|
||||
- Non-root `--system` → fail with `PermissionDenied` and sudo guidance
|
||||
- Non-Linux `--system` → fail with `UnsupportedPlatform`
|
||||
- Root without `--system` → warn, then per-user install into root's HOME
|
||||
- System and per-user layers are independent
|
||||
|
||||
### PATH
|
||||
|
||||
- **VMs / login shells:** `/etc/profile.d/pmg.sh` prepends the shim dir
|
||||
- **Docker `RUN`:** image must set `ENV PATH="/usr/local/lib/pmg/bin:$PATH"` (profile.d is not sourced)
|
||||
|
||||
### Limitations
|
||||
|
||||
- After `venv/bin/activate`, `pip` resolves to the venv binary and bypasses shims. Use `pmg pip …`.
|
||||
- Child Dockerfiles that rewrite `PATH` without the shim dir lose interception.
|
||||
|
||||
### Event logging
|
||||
|
||||
If event log initialization fails (e.g. unusable HOME), PMG warns and continues instead of exiting.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests with overridden system paths
|
||||
- Install/remove idempotence for system shims and profile.d
|
||||
- Doctor/info report system scope
|
||||
@@ -0,0 +1,124 @@
|
||||
# System Install (Linux)
|
||||
|
||||
Use system install when one machine or image should protect every user account: shared VMs, golden Docker images, and similar setups.
|
||||
|
||||
```bash
|
||||
sudo pmg setup install --system
|
||||
```
|
||||
|
||||
Requires Linux and root. To uninstall:
|
||||
|
||||
```bash
|
||||
sudo pmg setup remove --system
|
||||
sudo pmg setup remove --system --config-file # also remove the system config file
|
||||
```
|
||||
|
||||
Per-user `pmg setup install` remains available and does not conflict with a system install.
|
||||
|
||||
## Files created
|
||||
|
||||
|
||||
| Item | Path |
|
||||
| --------------------- | ----------------------------- |
|
||||
| Configuration | `/etc/safedep/pmg/config.yml` |
|
||||
| Package-manager shims | `/usr/local/lib/pmg/bin` |
|
||||
| Shell PATH snippet | `/etc/profile.d/pmg.sh` |
|
||||
|
||||
|
||||
## Making shims visible on PATH
|
||||
|
||||
System install writes shims to `/usr/local/lib/pmg/bin`. Processes only use them when that directory is on `PATH` ahead of the real `npm`, `pip`, and other package managers.
|
||||
|
||||
### Linux VMs and login shells
|
||||
|
||||
`pmg setup install --system` installs `/etc/profile.d/pmg.sh`, which prepends the shim directory for login shells.
|
||||
|
||||
```bash
|
||||
sudo pmg setup install --system
|
||||
```
|
||||
|
||||
New login sessions pick this up automatically. For an already open shell, start a new login session or run:
|
||||
|
||||
```bash
|
||||
source /etc/profile.d/pmg.sh
|
||||
```
|
||||
|
||||
Confirm with:
|
||||
|
||||
```bash
|
||||
which npm # should resolve under /usr/local/lib/pmg/bin
|
||||
pmg setup doctor
|
||||
```
|
||||
|
||||
|
||||
### Docker and container images
|
||||
|
||||
Docker `RUN` does not load `/etc/profile.d`. After system install you **must** set `ENV PATH` so build steps and the runtime container see the shims:
|
||||
|
||||
```dockerfile
|
||||
FROM ubuntu:24.04
|
||||
|
||||
RUN curl -fsSL https://raw.githubusercontent.com/safedep/pmg/main/install.sh | sh \
|
||||
&& pmg setup install --system
|
||||
|
||||
# Required: profile.d is not sourced during docker build
|
||||
ENV PATH="/usr/local/lib/pmg/bin:$PATH"
|
||||
|
||||
# Optional: switch user; PATH from ENV still applies
|
||||
USER appuser
|
||||
RUN npm ci
|
||||
```
|
||||
|
||||
Derived images inherit that `ENV`. Later `RUN npm install` / `RUN pip install` go through PMG for any `USER`.
|
||||
|
||||
If a child Dockerfile sets `ENV PATH=...` again, keep `/usr/local/lib/pmg/bin` ahead of the real `npm`/`pip` directories. Leaving it out (or behind those toolchains) drops interception.
|
||||
|
||||
## Configuration
|
||||
|
||||
The system config file is authoritative for every user. A per-user `config.yml` is ignored while `/etc/safedep/pmg/config.yml` exists.
|
||||
|
||||
`pmg config set` and `pmg config edit` fail under a system config. Update the file as root, or redeploy it through your image or configuration management.
|
||||
|
||||
Optional lockdown (`global_lockdown: true`) is documented in [config.md](./config.md).
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Virtualenv.** After `source .venv/bin/activate`, bare `pip` uses the venv binary and skips PMG shims. Call `pmg pip …` explicitly.
|
||||
- **No shell aliases.** System install only installs PATH shims. There is no `~/.pmg.rc` alias layer.
|
||||
- **Config changes.** `pmg config set` and `pmg config edit` are unavailable while the system config is active. Edit `/etc/safedep/pmg/config.yml` as root, or redeploy the file.
|
||||
- **Custom sandbox** `policy_templates`**.** Relative paths in the system config resolve under each user's config directory, not `/etc/safedep/pmg`. Prefer absolute paths.
|
||||
- `pmg sandbox allow`**.** Blocked when the system config sets `global_lockdown: true`.
|
||||
|
||||
|
||||
## User data directories
|
||||
|
||||
Shared policy lives under `/etc/safedep/pmg`. Runtime data stays per user:
|
||||
|
||||
|
||||
| Data | Default location |
|
||||
| --------------------- | ------------------------------------------------- |
|
||||
| Event logs | `~/.config/safedep/pmg/logs/` |
|
||||
| Cloud sync state | `~/.config/safedep/pmg/cloud-sync.db` |
|
||||
| Cache | `~/.cache/safedep/pmg/` |
|
||||
| Sandbox overlays | `~/.config/safedep/pmg/sandbox/overlays/` |
|
||||
| Persistent CA keypair | `~/.config/safedep/pmg/ca-cert.pem`, `ca-key.pem` |
|
||||
|
||||
You can relocate these with `PMG_CONFIG_DIR` and `PMG_CACHE_DIR`.
|
||||
|
||||
The invoking user must be able to write their config directory. If they cannot, PMG skips event logging for that run (and prints a warning) and continues the package-manager command. Cloud sync also needs that directory to store pending events.
|
||||
|
||||
In Docker images, avoid creating `/home/<user>/.config/safedep` as root during the build. Either fix ownership for the runtime user, or set `PMG_CONFIG_DIR` to a writable location.
|
||||
|
||||
For cloud sync, enable cloud in the system config and provide credentials (`SAFEDEP_API_KEY` and `SAFEDEP_TENANT_ID`, or a keychain login on developer machines).
|
||||
|
||||
## Certificates
|
||||
|
||||
System install does not set up a MITM certificate authority. For npm and pip on Linux, PMG's default ephemeral CA and environment-variable injection are enough.
|
||||
|
||||
To install a persistent CA into the OS trust store, use a separate command:
|
||||
|
||||
```bash
|
||||
pmg setup cert install --system
|
||||
```
|
||||
|
||||
Run that as your normal user. Details are in [cert.md](./cert.md).
|
||||
@@ -263,6 +263,7 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
gorm.io/plugin/opentelemetry v0.1.14/go.mod h1:ZAp4v5vU1CCcK9Oo8/va5rl6NStrzpSU+a70evd+W/g=
|
||||
gorm.io/plugin/prometheus v0.1.0/go.mod h1:5nrc/JrWCUNoDXCY4eOae/FK/J5WjQ0axXuFusCzdTc=
|
||||
|
||||
@@ -18,6 +18,11 @@ type ShimConfig struct {
|
||||
PMGBin string
|
||||
PackageManagers []string
|
||||
Shells []alias.Shell
|
||||
// SkipShellRc skips per-user shell rc PATH edits. Used by system install,
|
||||
// which relies on /etc/profile.d or ENV PATH instead.
|
||||
SkipShellRc bool
|
||||
// ManageProfile writes and removes /etc/profile.d/pmg.sh with Install/Remove.
|
||||
ManageProfile bool
|
||||
}
|
||||
|
||||
type ShimManager struct {
|
||||
@@ -68,6 +73,16 @@ func (m *ShimManager) Install() error {
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.ManageProfile {
|
||||
if err := writeSystemProfile(); err != nil {
|
||||
return fmt.Errorf("failed to write system profile: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.SkipShellRc {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := m.addPathToShells(); err != nil {
|
||||
return fmt.Errorf("failed to update shell configs: %w", err)
|
||||
}
|
||||
@@ -80,6 +95,16 @@ func (m *ShimManager) Remove() error {
|
||||
log.Warnf("Warning: failed to remove shim directory: %v", err)
|
||||
}
|
||||
|
||||
if m.config.ManageProfile {
|
||||
if err := removeSystemProfile(); err != nil {
|
||||
return fmt.Errorf("failed to remove system profile: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.SkipShellRc {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := m.removePathFromShells(); err != nil {
|
||||
return fmt.Errorf("failed to clean shell configs: %w", err)
|
||||
}
|
||||
@@ -207,3 +232,13 @@ func (m *ShimManager) removePathFromShells() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserShimsInstalled reports whether the per-user shim directory (~/.pmg/bin)
|
||||
// contains at least one shim script.
|
||||
func UserShimsInstalled() bool {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return shimsPresent(filepath.Join(homeDir, ".pmg", "bin"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package shim
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/safedep/pmg/internal/alias"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSystemBinDir = "/usr/local/lib/pmg/bin"
|
||||
defaultSystemProfilePath = "/etc/profile.d/pmg.sh"
|
||||
systemProfileMarker = "PMG system shims"
|
||||
)
|
||||
|
||||
// systemBinDirOverride and systemProfilePathOverride replace the OS-level
|
||||
// system install paths. They exist only for tests within this package. There
|
||||
// is intentionally no env var or flag for them.
|
||||
var (
|
||||
systemBinDirOverride string
|
||||
systemProfilePathOverride string
|
||||
)
|
||||
|
||||
// SystemBinDir returns the directory for system-wide PMG shims.
|
||||
func SystemBinDir() string {
|
||||
if systemBinDirOverride != "" {
|
||||
return systemBinDirOverride
|
||||
}
|
||||
return defaultSystemBinDir
|
||||
}
|
||||
|
||||
// SystemProfilePath returns the path of the system profile.d snippet.
|
||||
func SystemProfilePath() string {
|
||||
if systemProfilePathOverride != "" {
|
||||
return systemProfilePathOverride
|
||||
}
|
||||
return defaultSystemProfilePath
|
||||
}
|
||||
|
||||
// NewSystemShimManager creates a shim manager for system-wide install: shims
|
||||
// under SystemBinDir, no per-user rc edits, and /etc/profile.d management.
|
||||
func NewSystemShimManager() (*ShimManager, error) {
|
||||
aliasCfg := alias.DefaultConfig()
|
||||
pmgBin, err := currentExecutable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ShimManager{
|
||||
config: ShimConfig{
|
||||
BinDir: SystemBinDir(),
|
||||
PMGBin: pmgBin,
|
||||
PackageManagers: aliasCfg.PackageManagers,
|
||||
SkipShellRc: true,
|
||||
ManageProfile: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SystemShimsInstalled reports whether the system shim directory contains at
|
||||
// least one shim script.
|
||||
func SystemShimsInstalled() bool {
|
||||
return shimsPresent(SystemBinDir())
|
||||
}
|
||||
|
||||
func shimsPresent(dir string) bool {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SystemProfileInstalled reports whether the system profile snippet exists and
|
||||
// contains the PMG marker.
|
||||
func SystemProfileInstalled() bool {
|
||||
data, err := os.ReadFile(SystemProfilePath())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(data), systemProfileMarker)
|
||||
}
|
||||
|
||||
func writeSystemProfile() error {
|
||||
binDir := SystemBinDir()
|
||||
path := SystemProfilePath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create profile.d directory: %w", err)
|
||||
}
|
||||
|
||||
if data, err := os.ReadFile(path); err == nil && strings.Contains(string(data), systemProfileMarker) {
|
||||
return nil
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(`# %s - managed by pmg setup install --system
|
||||
# remove by running: pmg setup remove --system
|
||||
export PATH="%s:$PATH"
|
||||
`, systemProfileMarker, binDir)
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write system profile %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeSystemProfile() error {
|
||||
path := SystemProfilePath()
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove system profile %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package shim
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func useSystemPaths(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
systemBinDirOverride = filepath.Join(dir, "bin")
|
||||
systemProfilePathOverride = filepath.Join(dir, "profile.d", "pmg.sh")
|
||||
t.Cleanup(func() {
|
||||
systemBinDirOverride = ""
|
||||
systemProfilePathOverride = ""
|
||||
})
|
||||
}
|
||||
|
||||
func TestSystemShimManagerInstallAndRemove(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
useSystemPaths(t, root)
|
||||
|
||||
mgr, err := NewSystemShimManager()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mgr.config.SkipShellRc)
|
||||
assert.True(t, mgr.config.ManageProfile)
|
||||
assert.Equal(t, SystemBinDir(), mgr.GetBinDir())
|
||||
|
||||
require.NoError(t, mgr.Install())
|
||||
assert.True(t, SystemShimsInstalled())
|
||||
assert.True(t, SystemProfileInstalled())
|
||||
|
||||
npmShim := filepath.Join(SystemBinDir(), "npm")
|
||||
content, err := os.ReadFile(npmShim)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "export PMG_SHIM_PATH")
|
||||
|
||||
profile, err := os.ReadFile(SystemProfilePath())
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(profile), mgr.GetBinDir())
|
||||
assert.Contains(t, string(profile), systemProfileMarker)
|
||||
|
||||
require.NoError(t, mgr.Install())
|
||||
profile2, err := os.ReadFile(SystemProfilePath())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(profile), string(profile2))
|
||||
|
||||
require.NoError(t, mgr.Remove())
|
||||
assert.False(t, SystemShimsInstalled())
|
||||
assert.False(t, SystemProfileInstalled())
|
||||
require.NoError(t, mgr.Remove())
|
||||
}
|
||||
|
||||
func TestSystemShimManagerDoesNotTouchUserRc(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
useSystemPaths(t, root)
|
||||
|
||||
home := t.TempDir()
|
||||
bashrc := filepath.Join(home, ".bashrc")
|
||||
require.NoError(t, os.WriteFile(bashrc, []byte("# user bashrc\n"), 0o644))
|
||||
|
||||
mgr, err := NewSystemShimManager()
|
||||
require.NoError(t, err)
|
||||
mgr.config.HomeDir = home
|
||||
require.NoError(t, mgr.Install())
|
||||
|
||||
content, err := os.ReadFile(bashrc)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "# user bashrc\n", string(content))
|
||||
}
|
||||
@@ -32,3 +32,13 @@ func PrintSetupInstallCmdInfo(aliasPath, shimBinDir, configPath string) {
|
||||
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Config: %s", configPath)))
|
||||
fmt.Printf(" %s\n", Colors.Dim("Restart your terminal for changes to take effect"))
|
||||
}
|
||||
|
||||
func PrintSetupSystemInstallCmdInfo(shimBinDir, configDir, profilePath string) {
|
||||
fmt.Printf("%s %s\n", Colors.Green("✓"), "PMG system install completed")
|
||||
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Shims: %s", shimBinDir)))
|
||||
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Config: %s", configDir)))
|
||||
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Profile: %s", profilePath)))
|
||||
fmt.Printf("\n%s For Docker builds (RUN does not source profile.d), add:\n", Colors.Dim("ℹ"))
|
||||
fmt.Printf(" %s\n", Colors.Bold(fmt.Sprintf(`ENV PATH="%s:$PATH"`, shimBinDir)))
|
||||
fmt.Printf("%s Login shells pick up PATH from profile.d. After venv activate, use `pmg pip`.\n", Colors.Dim("ℹ"))
|
||||
}
|
||||
|
||||
@@ -103,7 +103,9 @@ func main() {
|
||||
}
|
||||
|
||||
if eventlogErr != nil {
|
||||
ui.Fatalf("failed to initialize event logging: %v", eventlogErr)
|
||||
// Soft-fail: unusable HOME (e.g. system accounts, some containers)
|
||||
// must not prevent package-manager commands from running.
|
||||
log.Warnf("failed to initialize event logging: %v", eventlogErr)
|
||||
}
|
||||
|
||||
if err := audit.Initialize(config.Get()); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user