Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 7 additions & 5 deletions docs/src/content/docs/enterprise/lifecycle-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`<br />Windows: `C:\ProgramData\APM\policy.d\*.json` | Platform/IT team | JSON |
| 1 (highest) | POSIX: `/etc/apm/policy.d/*.json`<br />Windows: `%ProgramData%\APM\policy.d\*.json` | Platform/IT team | JSON |
| 2 | `~/.apm/apm.yml` | Individual user | YAML |
| 3 | `apm.yml` `lifecycle:` | Project | YAML |

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
{
Expand Down
2 changes: 1 addition & 1 deletion packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 15 additions & 8 deletions src/apm_cli/core/lifecycle_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")


Expand Down Expand Up @@ -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)
"""
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/core/test_lifecycle_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -16,6 +18,7 @@
PackageInfo,
ScriptEntry,
_entries_from_lifecycle_map,
_get_policy_scripts_dir,
build_runner_from_context,
Comment on lines 18 to 22

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, typed all three, pushed 2984d65

discover_scripts,
parse_apm_yml_lifecycle,
Expand Down Expand Up @@ -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(
Expand Down