Skip to content

Commit c94b1a7

Browse files
authored
feat: plugin bundling, catalog, installation and versioning (#31)
* chore: provde better examples for PluginPackageInfo constructor Signed-off-by: habeck <habeck@us.ibm.com> * enh: add PluginVersionInfo and PluginVersionRegistry models w/unit tests Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * chore: unit tests and fixtures for plugin isolation via venv. Signed-off-by: habeck <habeck@us.ibm.com> * enh: refactored the invoke_hook method in cpex/framework/isolated/client.py to run async Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * chore: updated unit test test_worker to get coverage to 97%. Signed-off-by: habeck <habeck@us.ibm.com> * enh: The optimization eliminates the overhead of: Forking a new Python process (~1.2ms per fork_exec) Initializing the Python interpreter Loading modules and dependencies Setting up the subprocess communication pipes Signed-off-by: habeck <habeck@us.ibm.com> * fix: fail early plugin_path do not exist, computer .venv path automatically, update cli to support creating an isolated plugin. Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * fix: use the system config file (PLUGINS_CONFIG_FILE) for syspath update (Consistent with how the PluginManager works). Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * chore: Validate plugin_dirs entries against an allowlist Signed-off-by: habeck <habeck@us.ibm.com> * fix: remove hardcoded reference to plugins/config in the cpex/framework/isolated/client.py and update tests. remove methods_to_exclude from validator. Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * enh: Add a maximum line length check before parsing. Add model tests for PluginPackageInfo and PluginVersionRegistry Signed-off-by: habeck <habeck@us.ibm.com> * enh: model updates for PluginManifest, InstalledPluginInfo, and InstalledPluginRegistry Signed-off-by: habeck <habeck@us.ibm.com> * enh: example values for git monorepo installation Signed-off-by: habeck <habeck@us.ibm.com> * enh: add ConfigSaver class to ConfigLoader Signed-off-by: habeck <habeck@us.ibm.com> * enh: plugin installation catalog Signed-off-by: habeck <habeck@us.ibm.com> * enh: add support to enable installation of a plugin using the cli from a git monorepo, or pypi. Signed-off-by: habeck <habeck@us.ibm.com> * chore: doc string fix Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * chore: remove duplicate code Signed-off-by: habeck <habeck@us.ibm.com> * chore: test coverage improvements, remove duplicate code Signed-off-by: habeck <habeck@us.ibm.com> * chore: replace cargo with search for pyproject. Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fixes Signed-off-by: habeck <habeck@us.ibm.com> * enh: use pygithub apis rather than github rest apis, as they provide automatic backoff when github response with too many requests. Signed-off-by: habeck <habeck@us.ibm.com> * enh: add support for uninstall of plugin Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint-fix Signed-off-by: habeck <habeck@us.ibm.com> * fix: use the manifest from the local catalog to pull the kind value of the package to be removed and use that to remove all matching kind entries from plugins/config.yaml unless kind is external or isolated_venv in which case check if the plugin name is a substring of the plugin name. Signed-off-by: habeck <habeck@us.ibm.com> * fix: when installing a plugin via mono-repo or pipi, the cache_root will not exist under the plugins dir, create the directory if it does not exist on plugin startup for venv plugins. Signed-off-by: habeck <habeck@us.ibm.com> * enh: enable package install from both pypi and test-pypi, fix: properly resolve location of requirements.txt while performing pypi installs. Signed-off-by: habeck <habeck@us.ibm.com> * chore: stub for local installation Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * doc: add README for tools Signed-off-by: habeck <habeck@us.ibm.com> * enh: use cached repo object Signed-off-by: habeck <habeck@us.ibm.com> * chore: use rich emoji Signed-off-by: habeck <habeck@us.ibm.com> * ptf: workaround for version mis-match of cpex dependency in plugin Signed-off-by: habeck <habeck@us.ibm.com> * fix: download install targets to a temp folder to avoid installing to incorrect venv. Signed-off-by: habeck <habeck@us.ibm.com> * enh: pass the plugin install path to the update method, as isolated_venv plugins are not installed in the current venv. Signed-off-by: habeck <habeck@us.ibm.com> * enh: the catalog now returns the install path for isolated_venv plugins Signed-off-by: habeck <habeck@us.ibm.com> * chore: unit test updates Signed-off-by: habeck <habeck@us.ibm.com> * misc: type fix Signed-off-by: habeck <habeck@us.ibm.com> * enh: the plugin self installs into the isolated_venv via requirements.txt Signed-off-by: habeck <habeck@us.ibm.com> * chore: increase coverage above 90% Signed-off-by: habeck <habeck@us.ibm.com> * chore: update min_max_framework_version Signed-off-by: habeck <habeck@us.ibm.com> * enh: isolated venv cookiecutter update for install flow Signed-off-by: habeck <habeck@us.ibm.com> * enh: catalog now properly persists all plugin-manifest*.yaml files Signed-off-by: habeck <habeck@us.ibm.com> * enh: upgrade pip before installing requirements Signed-off-by: habeck <habeck@us.ibm.com> * enh: allow the developer provided version registry values to persist, overriding only if they are not present. Improved install path resolution for isolated_venv plugins Signed-off-by: habeck <habeck@us.ibm.com> * enh: only update the catalog when not installing from test-pypi or pypi. Correctly determine the install path for the plugin registry for monorepo installs and isolated_venv plugins. Signed-off-by: habeck <habeck@us.ibm.com> * enh: refactor to reduce duplicate code, fix uninstall for isolated_venv, add install support for type local, Signed-off-by: habeck <habeck@us.ibm.com> * chore: properly format info Signed-off-by: habeck <habeck@us.ibm.com> * chore: update README.md Signed-off-by: habeck <habeck@us.ibm.com> * chore: Add a, "before you begin" section detailing the required .env variable. Signed-off-by: habeck <habeck@us.ibm.com> * fix: P0 fix — tarfile/zip path traversal Signed-off-by: habeck <habeck@us.ibm.com> * enh: add remove_venv method to IsolatedVenvPlugin for uninstall cleanup. Signed-off-by: habeck <habeck@us.ibm.com> * fix: priority 1 items Signed-off-by: habeck <habeck@us.ibm.com> * fix: p2 item 17 search() case-insensitive match broken Signed-off-by: habeck <habeck@us.ibm.com> * chore: add tests for _ver method. Signed-off-by: habeck <habeck@us.ibm.com> * fix: version registry update cleanup Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * chore: add missing doc string, and tests for _ver method. Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * fix: review p2 moderate 21 - list function now uses console.print Signed-off-by: habeck <habeck@us.ibm.com> * chore: fix failing unit test, address non-atomic registry write. Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * chore: claude can't tell the difference between if "rc is False" and "if rc". Signed-off-by: habeck <habeck@us.ibm.com> * fix: cpex/framework/models.py — register_plugin (line 2392): replaced the unconditional append with a filter-then-append. Any existing entry with the same name is removed before the new one is added, so a reinstall upgrades the entry rather than creating a duplicate. One save() call, same atomicity as before. Signed-off-by: habeck <habeck@us.ibm.com> * fix: P2 Issue 20 Implementation Complete: Exit Code Handling Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * fix: list function shadows built-in Signed-off-by: habeck <habeck@us.ibm.com> * chore: logic tweak Signed-off-by: habeck <habeck@us.ibm.com> * fix: p2 issue 23 Signed-off-by: habeck <habeck@us.ibm.com> * fix: P2 issue 19 error handling for corrupted JSON registry Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * fix: P2 issue 24 - Registry file path triplicated Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint-fix Signed-off-by: habeck <habeck@us.ibm.com> * fix: P2 issue 25 Signed-off-by: habeck <habeck@us.ibm.com> * fix: update worker to call cpex.framework.utils.import_module rather than importlib.import_module directly. Signed-off-by: habeck <habeck@us.ibm.com> * enh: add package integrity verification Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * chore: missed commit Signed-off-by: habeck <habeck@us.ibm.com> * fix: if the plugins/config.yaml plugins array is empty, initialize it with the appropriate default from catalog settings. Signed-off-by: habeck <habeck@us.ibm.com> * chore: lint fix Signed-off-by: habeck <habeck@us.ibm.com> * chore: version to '0.1.0 minimum' Signed-off-by: habeck <habeck@us.ibm.com> --------- Signed-off-by: habeck <habeck@us.ibm.com>
1 parent af53214 commit c94b1a7

24 files changed

Lines changed: 10198 additions & 123 deletions

File tree

.env.example

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,25 @@
66
# `allow`, `deny`
77
# PLUGINS_DEFAULT_HOOK_POLICY=allow
88

9+
# Path to plugins folder
10+
# PLUGINS_FOLDER=plugins
911
# Path to main plugins configuration file
1012
# PLUGINS_CONFIG_FILE=plugins/config.yaml
1113

14+
### Plugin installation
15+
# Comma Separated Values used by install with --type monorepo
16+
# PLUGINS_REPO_URLS="https://github.com/ibm/cpex-plugins"
17+
18+
# registry path
19+
# PLUGIN_REGISTRY_FOLDER=data
20+
21+
# Github API
22+
# PLUGINS_GITHUB_API=api.github.com
23+
24+
# PLUGINS_GITHUB_TOKEN=<github token>
25+
### end Plugin installation
26+
27+
1228
# Logging level for plugin framework components
1329
# PLUGINS_LOG_LEVEL=INFO
1430

@@ -148,3 +164,15 @@
148164
# PLUGINS_GRPC_SERVER_SSL_ENABLED=
149165

150166

167+
168+
### Package Integrity Verification
169+
# Enable SHA256 hash verification for PyPI packages (default: True)
170+
# When enabled, downloaded packages are verified against hashes from PyPI's JSON API
171+
# Recommended: Keep enabled for security
172+
# PLUGINS_VERIFY_PACKAGE_INTEGRITY=True
173+
174+
# Strict integrity mode (default: False)
175+
# When True: Fail installation if package hashes are unavailable
176+
# When False: Warn but continue if hashes are unavailable
177+
# Recommended: False for development, True for production
178+
# PLUGINS_STRICT_INTEGRITY_MODE=False

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,4 +250,4 @@ db_path/
250250
tmp/
251251

252252
.continue
253-
253+
plugin-catalog

cpex/framework/isolated/client.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from cpex.framework.hooks.registry import get_hook_registry
2828
from cpex.framework.isolated.venv_comm import VenvProcessCommunicator
2929
from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult
30+
from cpex.framework.utils import find_package_path
3031

3132
logger = logging.getLogger(__name__)
3233

@@ -43,10 +44,10 @@ def __init__(self, config: PluginConfig, plugin_dirs) -> None:
4344
# use the first plugin dir specified in the plugin configuration file.
4445
path = Path(self.plugin_dirs[0]).resolve()
4546
class_root = self.config.config.get("class_name").split(".")[0]
46-
cache_root = path / class_root
47-
self.plugin_path = cache_root
47+
cache_root: Path = path / class_root
48+
self.plugin_path: Path = cache_root
4849
if not cache_root.exists():
49-
raise RuntimeError(f"plugin path does not exist: {str(cache_root)}")
50+
cache_root.mkdir(parents=True, exist_ok=True)
5051
self.cache_dir: Path = cache_root / ".cpex" / "venv_cache"
5152
self.cache_dir.mkdir(parents=True, exist_ok=True)
5253

@@ -217,21 +218,17 @@ async def initialize(self) -> None:
217218
else:
218219
requirements_file = Path(requirements_file_input)
219220

220-
# If it's a relative path, resolve it relative to plugin_path
221-
if not requirements_file.is_absolute():
222-
requirements_file = (self.plugin_path / requirements_file).resolve()
223-
else:
224-
# If absolute, resolve it to normalize
225-
requirements_file = requirements_file.resolve()
226-
227-
# Validate that the resolved path is within plugin_path (security check)
221+
# Try to find the package location where plugin-manifest.yaml resides
222+
# Fall back to self.plugin_path if package is not installed (e.g., in tests)
228223
try:
229-
requirements_file.relative_to(self.plugin_path.resolve())
230-
except ValueError as ve:
231-
raise RuntimeError(
232-
f"Invalid requirements_file path: {requirements_file_input}. "
233-
f"Path must be within plugin directory: {self.plugin_path}"
234-
) from ve
224+
package_path = find_package_path(self.config.name)
225+
logger.debug("Found installed package %s at %s", self.config.name, package_path)
226+
except RuntimeError:
227+
# Package not installed (e.g., in test environment), use plugin_path
228+
package_path = self.plugin_path
229+
logger.debug("Package %s not installed, using plugin_path: %s", self.config.name, package_path)
230+
231+
requirements_file = package_path / requirements_file_input
235232

236233
# Create venv with caching support
237234
new_venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True)
@@ -339,3 +336,10 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu
339336
except Exception as e:
340337
logger.exception("Unexpected error invoking hook '%s' for plugin '%s'", hook_type, self.name)
341338
raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) from e
339+
340+
def remove_venv(self):
341+
"""
342+
Remove the virtual environment associated with the plugin.
343+
"""
344+
shutil.rmtree(self.plugin_path.joinpath(".cpex"))
345+
shutil.rmtree(self.plugin_path.joinpath(".venv"))

cpex/framework/isolated/venv_comm.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ def _get_python_executable(self):
5353

5454
return str(python_exe)
5555

56+
def upgrade_pip(self) -> None:
57+
"""Upgrade pip in the target venv."""
58+
try:
59+
subprocess.check_call([self.python_executable, "-m", "pip", "install", "--upgrade", "pip"])
60+
except Exception as e:
61+
raise RuntimeError("Failed to upgrade pip") from e
62+
5663
def install_requirements(self, requirements_file: str) -> None:
5764
"""
5865
Install Python requirements from a file in the target venv.
@@ -62,6 +69,7 @@ def install_requirements(self, requirements_file: str) -> None:
6269
requirements_path = Path(requirements_file)
6370
if requirements_path.exists():
6471
try:
72+
self.upgrade_pip()
6573
subprocess.check_call([self.python_executable, "-m", "pip", "install", "-r", requirements_file])
6674
except Exception as e:
6775
raise RuntimeError(f"Failed to install requirements from {requirements_file}") from e

cpex/framework/isolated/worker.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS
2525
from cpex.framework.manager import PluginExecutor
2626
from cpex.framework.models import PluginConfig, PluginContext
27-
from cpex.framework.utils import parse_class_name
27+
from cpex.framework.utils import import_module, parse_class_name
2828

2929
logger = logging.getLogger(__name__)
3030

@@ -115,7 +115,7 @@ async def process_task(task_data, tp: TaskProcessor):
115115
hook_type = task_data.get(HOOK_TYPE)
116116
cls_name: str = task_data.get("class_name")
117117
mod_name, n_cls_name = parse_class_name(cls_name)
118-
module: ModuleType = importlib.import_module(mod_name)
118+
module: ModuleType = import_module(mod_name)
119119
# cool, we found the module, and verified it implemented the hook type.
120120
class_ = getattr(module, n_cls_name)
121121
plugin_type = cast(Type[Plugin], class_)
@@ -162,7 +162,7 @@ async def main():
162162
while True:
163163
try:
164164
# Read one line at a time
165-
if tp.plugin_config:
165+
if tp.plugin_config and "max_content_size" in tp.plugin_config:
166166
line = sys.stdin.readline(limit=int(tp.plugin_config.max_content_size))
167167
else:
168168
# on the first read, the plugin_config has not yet been initialized so just read.
@@ -198,14 +198,17 @@ async def main():
198198
serialized_response = json.dumps(serializable_response)
199199
# Send response back to parent (one line per response)
200200
if tp.plugin_config:
201-
if len(serialized_response) > tp.plugin_config.max_content_size:
202-
logger.error("Serialized response exceeds max content size")
203-
error_response = {
204-
"status": "error",
205-
"message": "Serialized response exceeds max content size",
206-
"request_id": request_id,
207-
}
208-
serialized_response = json.dumps(error_response)
201+
# workaround until cpex is updated beyond dev11
202+
# cpex is a dependency of the plugin and as such it's PluginConfig does not contain the max_content_size yet.
203+
if "max_content_size" in tp.plugin_config:
204+
if len(serialized_response) > tp.plugin_config.max_content_size:
205+
logger.error("Serialized response exceeds max content size")
206+
error_response = {
207+
"status": "error",
208+
"message": "Serialized response exceeds max content size",
209+
"request_id": request_id,
210+
}
211+
serialized_response = json.dumps(error_response)
209212
print(serialized_response, flush=True)
210213

211214
except json.JSONDecodeError as e:

cpex/framework/loader/config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,22 @@ def load_config(config: str, use_jinja: bool = True) -> Config:
7979
except FileNotFoundError:
8080
# Graceful fallback for tests and minimal environments without plugin config
8181
return Config(plugins=[], plugin_dirs=[])
82+
83+
84+
class ConfigSaver:
85+
"""
86+
A configuration saver
87+
"""
88+
89+
@staticmethod
90+
def save_config(config: Config, config_path: str) -> None:
91+
"""
92+
Save the supplied configuration data to the filesystem
93+
"""
94+
try:
95+
updated_content = yaml.safe_dump(config.model_dump(mode="json"), default_flow_style=False)
96+
with open(os.path.normpath(config_path), "w", encoding="utf-8") as file:
97+
file.write(updated_content)
98+
file.flush()
99+
except OSError as ose:
100+
raise RuntimeError(f"Error saving PluginConfig to {config_path}") from ose

0 commit comments

Comments
 (0)