diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f70b75a3..9bb67de98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Windows admin lifecycle policies now resolve from `%ProgramData%` instead of + assuming `C:\ProgramData`, while retaining the historical fallback. + (by @lukiod; closes #2684) (#2686) - Marketplace installs now materialize catalog-only LSP and MCP metadata without requiring a package manifest in the downloaded source (by @lkshrk, #2709). diff --git a/docs/src/content/docs/enterprise/lifecycle-scripts.md b/docs/src/content/docs/enterprise/lifecycle-scripts.md index 5427f9188..59c98bbe6 100644 --- a/docs/src/content/docs/enterprise/lifecycle-scripts.md +++ b/docs/src/content/docs/enterprise/lifecycle-scripts.md @@ -31,8 +31,10 @@ Scripts are defined in three tiers. The **project tier** uses the repository `ap manifest under a top-level `lifecycle:` key. The **user tier** uses `~/.apm/apm.yml` (or `$APM_HOME/apm.yml`) under the same `lifecycle:` key. The **admin** tier uses `/etc/apm/policy.d/*.json` on POSIX systems, or -`C:\ProgramData\APM\policy.d\*.json` on Windows. It is suited for -machine- and fleet-managed deployment. +`%ProgramData%\APM\policy.d\*.json` on Windows. `%ProgramData%` normally +expands to `C:\ProgramData`; APM uses that default if the value is missing or +is not an absolute Windows path. The tier is suited for machine- and +fleet-managed deployment. ## Supported events @@ -168,7 +170,7 @@ disabled; the global kill switches below suppress all lifecycle scripts. | Priority | Path | Who controls | Format | |--------------|-----------------------------------------------------------------------------|------------------|--------| -| 1 (highest) | POSIX: `/etc/apm/policy.d/*.json`
Windows: `C:\ProgramData\APM\policy.d\*.json` | Platform/IT team | JSON | +| 1 (highest) | POSIX: `/etc/apm/policy.d/*.json`
Windows: `%ProgramData%\APM\policy.d\*.json` | Platform/IT team | JSON | | 2 | `~/.apm/apm.yml` | Individual user | YAML | | 3 | `apm.yml` `lifecycle:` | Project | YAML | @@ -199,7 +201,7 @@ POST body. Lifecycle scripts from different sources are subject to different trust rules: - **Policy scripts** (`/etc/apm/policy.d/*.json` on POSIX systems or - `C:\ProgramData\APM\policy.d\*.json` on Windows) -- controlled by + `%ProgramData%\APM\policy.d\*.json` on Windows) -- controlled by your platform/IT team. Run without any consent gate; they cannot be individually disabled by the developer. `APM_NO_SCRIPTS=1` suppresses all lifecycle-script tiers for that run. @@ -234,7 +236,7 @@ policy directory to track which packages are actively used: Create `analytics.json` in the platform admin directory: - POSIX: `/etc/apm/policy.d/analytics.json` -- Windows: `C:\ProgramData\APM\policy.d\analytics.json` +- Windows: `%ProgramData%\APM\policy.d\analytics.json` ```json { diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index ffc90c4de..26c9779f3 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -182,7 +182,7 @@ If the install cache has not been warmed (e.g. a fresh checkout before the first | `apm lifecycle trust` | Trust `apm.yml` `lifecycle:` at its current contents so project scripts run on install | -- | | `apm lifecycle untrust` | Revoke trust for `apm.yml` `lifecycle:`; project scripts will stop running | -- | -Lifecycle scripts fire on six events: `pre-install`, `post-install`, `pre-update`, `post-update`, `pre-uninstall`, `post-uninstall`. `post-install` fires only after success or partial success; failed and dry-run installs skip it. Script files are discovered from three sources (additive): policy (POSIX: `/etc/apm/policy.d/*.json`; Windows: `C:\ProgramData\APM\policy.d\*.json`; JSON), user (`~/.apm/apm.yml`, YAML), project (`apm.yml` `lifecycle:` at repo root, YAML). Two script types: `command` (shell via subprocess, event JSON on stdin) and `http` (HTTPS POST). Script output is appended to `~/.apm/logs/scripts.log`. See the [Lifecycle scripts](/apm/enterprise/lifecycle-scripts/) guide for full documentation. +Lifecycle scripts fire on six events: `pre-install`, `post-install`, `pre-update`, `post-update`, `pre-uninstall`, `post-uninstall`. `post-install` fires only after success or partial success; failed and dry-run installs skip it. Script files are discovered from three sources (additive): policy (POSIX: `/etc/apm/policy.d/*.json`; Windows: `%ProgramData%\APM\policy.d\*.json`; JSON), user (`~/.apm/apm.yml`, YAML), project (`apm.yml` `lifecycle:` at repo root, YAML). Two script types: `command` (shell via subprocess, event JSON on stdin) and `http` (HTTPS POST). Script output is appended to `~/.apm/logs/scripts.log`. See the [Lifecycle scripts](/apm/enterprise/lifecycle-scripts/) guide for full documentation. ## Distribution diff --git a/src/apm_cli/core/lifecycle_scripts.py b/src/apm_cli/core/lifecycle_scripts.py index d73ca35f0..569f8492a 100644 --- a/src/apm_cli/core/lifecycle_scripts.py +++ b/src/apm_cli/core/lifecycle_scripts.py @@ -4,7 +4,7 @@ update, and uninstall operations. Scripts are configured in well-known locations discovered from three tiers: -1. Policy -- /etc/apm/policy.d/*.json (admin-owned, JSON drop-ins, unchanged) +1. Policy -- platform-specific admin directory (JSON drop-ins, unchanged) 2. User -- ~/.apm/apm.yml (or $APM_HOME/apm.yml) lifecycle: key 3. Project -- apm.yml lifecycle: key (repo root) @@ -38,7 +38,7 @@ import threading from dataclasses import asdict, dataclass, field from datetime import datetime, timezone -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -314,9 +314,9 @@ def parse_apm_yml_lifecycle_with_fingerprint( def parse_script_file(path: Path, source: str = "project") -> list[ScriptEntry]: """Parse a single JSON script file into a list of ScriptEntry. - Used for JSON-backed sources such as the admin policy tier - (/etc/apm/policy.d/*.json). Returns an empty list if the file is - malformed or uses an unsupported version. + Used for JSON-backed sources such as the platform-specific admin policy + tier. Returns an empty list if the file is malformed or uses an unsupported + version. """ try: with open(path, encoding="utf-8") as f: @@ -344,10 +344,17 @@ def parse_project_script_file(path: Path) -> list[ScriptEntry]: def _get_policy_scripts_dir() -> Path: - """Return the platform-specific policy scripts directory.""" + """Return the platform-specific policy scripts directory. + + Windows normally supplies an absolute ``ProgramData`` environment value. + Fall back to its historical default when that value is missing or unsafe. + """ system = platform.system() if system == "Windows": - return Path(r"C:\ProgramData\APM\policy.d") + program_data = os.environ.get("PROGRAMDATA") + if not program_data or not PureWindowsPath(program_data).is_absolute(): + program_data = r"C:\ProgramData" + return Path(program_data) / "APM" / "policy.d" return Path("/etc/apm/policy.d") @@ -385,7 +392,7 @@ def discover_scripts( """Discover and merge scripts from all three sources. Load order (all additive, policy first): - 1. Policy -- /etc/apm/policy.d/*.json (directory, JSON) + 1. Policy -- platform-specific admin directory (JSON) 2. User -- ~/.apm/apm.yml (or $APM_HOME/apm.yml) lifecycle: key 3. Project -- apm.yml lifecycle: key (repo root) """ diff --git a/tests/unit/core/test_lifecycle_scripts.py b/tests/unit/core/test_lifecycle_scripts.py index 77efff7e4..214180ef5 100644 --- a/tests/unit/core/test_lifecycle_scripts.py +++ b/tests/unit/core/test_lifecycle_scripts.py @@ -3,9 +3,11 @@ from __future__ import annotations import json +import os from pathlib import Path from unittest.mock import MagicMock, patch +import pytest import yaml from apm_cli.core.lifecycle_scripts import ( @@ -16,6 +18,7 @@ PackageInfo, ScriptEntry, _entries_from_lifecycle_map, + _get_policy_scripts_dir, build_runner_from_context, discover_scripts, parse_apm_yml_lifecycle, @@ -153,6 +156,65 @@ def test_parse_project_script_file_is_alias(self, tmp_path: Path) -> None: assert parse_project_script_file(path) == parse_apm_yml_lifecycle(path, "project") +@pytest.mark.windows_compat +class TestGetPolicyScriptsDir: + def test_windows_honours_programdata_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setenv("PROGRAMDATA", r"D:\ProgramData") + assert _get_policy_scripts_dir() == Path(r"D:\ProgramData") / "APM" / "policy.d" + + def test_windows_falls_back_to_c_drive_when_unset( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.delenv("PROGRAMDATA", raising=False) + assert _get_policy_scripts_dir() == Path(r"C:\ProgramData") / "APM" / "policy.d" + + @pytest.mark.parametrize("program_data", ["", "relative"]) + def test_windows_falls_back_when_programdata_is_unsafe( + self, + monkeypatch: pytest.MonkeyPatch, + program_data: str, + ) -> None: + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setenv("PROGRAMDATA", program_data) + assert _get_policy_scripts_dir() == Path(r"C:\ProgramData") / "APM" / "policy.d" + + def test_windows_discovers_policy_scripts_from_programdata( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + if os.name == "nt": + program_data = tmp_path / "ProgramData" + program_data_value = str(program_data) + else: + program_data_value = r"D:\ProgramData" + program_data = tmp_path / program_data_value + monkeypatch.chdir(tmp_path) + script_file = program_data / "APM" / "policy.d" / "admin.json" + script_file.parent.mkdir(parents=True) + script_file.write_text( + json.dumps( + { + "version": 1, + "scripts": {"post-install": [{"type": "command", "command": "echo admin"}]}, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setenv("PROGRAMDATA", program_data_value) + + entries = discover_scripts(project_root=str(tmp_path / "project")) + + assert [(entry.source, entry.command) for entry in entries] == [("policy", "echo admin")] + + def test_non_windows_uses_etc(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("platform.system", lambda: "Linux") + assert _get_policy_scripts_dir() == Path("/etc/apm/policy.d") + + class TestDiscoverScripts: def test_discovers_from_project_file(self, tmp_path: Path) -> None: _write_yaml(