Marketplace Lifecycle Execution — Design Spec¶
Date: 2026-03-28 Scope: Production-ready install/enable/disable/retry/uninstall lifecycle for marketplace modules Approach: Inline state machine (no background task queue)
1. Problem¶
The marketplace can list, purchase, and create installation records, but the actual lifecycle is incomplete:
- Plugin hooks (
configure,install,uninstall,activate,deactivate,health_check) are defined but never called. - Install failures return 500 with no error state or retry path.
- Uninstall doesn't clean up connector instances, policy pack bindings, or OPA policies.
- Enable/disable doesn't trigger any plugin behavior.
- No health check endpoint exists.
2. State Machine¶
Module installation lifecycle modeled as a finite state machine on installed_modules.install_state:
purchase
(none) ──────────→ entitled
│ POST /install
▼
provisioning
│ │
success│ │failure
▼ ▼
installed ← error
↕ │ retry (max 3)
disabled │
│ ▼
uninstall provisioning
▼
removing
▼
removed
Transition Table¶
| From | To | Trigger | Side Effects |
|---|---|---|---|
entitled |
provisioning |
POST /install |
— |
provisioning |
installed |
Install succeeds | Create connector_instance or policy_pack_binding, call plugin install() + configure() |
provisioning |
error |
Install fails | Rollback partial resources, populate last_error, increment retry_count |
error |
provisioning |
PATCH with action: retry |
Clear last_error |
installed |
disabled |
PATCH with action: disable |
Call plugin deactivate() / set connector enabled=false |
disabled |
installed |
PATCH with action: enable |
Call plugin activate() / set connector enabled=true |
installed or disabled |
removing |
DELETE /installations/{id} |
— |
removing |
removed |
Uninstall succeeds | Call plugin uninstall(), delete resources, soft-delete row |
removing |
error |
Uninstall fails | Populate last_error |
Enforcement¶
A _assert_transition(current, target) function validates that only legal transitions are attempted. Invalid transitions raise 400 Bad Request.
Schema Change¶
Add to installed_modules:
ALTER TABLE installed_modules ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0;
last_error TEXT already exists but is never populated — the lifecycle service will now write to it.
3. Plugin Lifecycle Hooks¶
Connector Plugins¶
| Hook | When Called | Purpose |
|---|---|---|
configure(config) |
provisioning → installed |
Validate config, set up internal state. Called first. |
install(config) |
provisioning → installed |
One-time setup (webhooks, OAuth). Called after configure. |
health_check(config) |
On demand via health endpoint | Returns HealthStatus (healthy/degraded/unhealthy). |
sync(installation_id) |
Already wired | No changes. |
uninstall(installation_id) |
removing → removed |
Cleanup (delete webhooks, revoke tokens). Called before resource deletion. |
Policy Pack Plugins¶
| Hook | When Called | Purpose |
|---|---|---|
activate(config) |
provisioning → installed and disabled → installed |
Upload Rego to OPA, validate compilation. |
get_policies() |
provisioning → installed |
Return policy list for binding metadata. |
evaluate(context) |
Already wired | No changes. |
deactivate() |
installed → disabled and removing → removed |
Remove Rego from OPA. |
Hook Error Handling¶
- During install: Rollback to
errorstate, cleanup partial resources. - During enable/disable: Stay in current state, return error to caller.
- During uninstall: Log the error but proceed with cleanup (best-effort).
Connector Install Sequence¶
1. Validate entitlement + license
2. Create installed_modules row (state=provisioning)
3. Create connector_instance (enabled=false)
4. Call plugin.configure(config)
5. Call plugin.install(config)
6. Set connector_instance.enabled=true
7. Set installed_modules.state=installed
8. Record marketplace_event
If step 4 or 5 fails → delete connector_instance → set state=error.
Policy Pack Install Sequence¶
1. Validate entitlement + license
2. Create installed_modules row (state=provisioning)
3. Create policy_pack_binding (enabled=false)
4. Call plugin.get_policies() → store in binding metadata
5. Call plugin.activate(config) → upload Rego to OPA
6. Set policy_pack_binding.enabled=true
7. Set installed_modules.state=installed
8. Record marketplace_event
If step 4 or 5 fails → delete policy_pack_binding → set state=error.
Connector Hook Adapter¶
Existing connectors use a Protocol (not DataConnectorBase). The lifecycle service wraps hook calls with duck-typing checks — if the plugin has a configure method, call it; if not, skip. This avoids forcing a rewrite of GitHubConnectorPlugin while calling hooks on plugins that implement them.
4. Rollback Strategy¶
Install Rollback (provisioning → error)¶
Undo in reverse order when any step fails:
Failure at plugin.install():
→ delete connector_instance / policy_pack_binding row
→ set installed_modules.state = error, populate last_error
Failure at plugin.configure():
→ delete connector_instance / policy_pack_binding row
→ set installed_modules.state = error, populate last_error
Failure at resource creation:
→ set installed_modules.state = error, populate last_error
Each rollback step is wrapped in its own try/except. If cleanup itself fails, log the error but still set error state. last_error captures the original failure.
Uninstall Rollback (best-effort)¶
If plugin.uninstall() or plugin.deactivate() fails:
1. Log the plugin error
2. Delete connector_instance / policy_pack_binding anyway
3. Set installed_modules.state = removed
Rationale: a stuck removing state with no cleanup path is worse than a completed removal with a logged warning.
Retry Behavior¶
retry_countincrements on eacherror → provisioningtransition.- Max 3 retries enforced in the service (configurable constant).
- After max retries, module stays in
error— user must uninstall and reinstall. last_errorpreserved across retries for debugging.
5. API Surface¶
Modified Endpoints¶
PATCH /marketplace/installed/{id} — now action-based:
Request body:
{ "action": "enable" | "disable" | "retry", "channel": "stable" | "candidate" }
actiontriggers state transition + lifecycle hooks.channelis a simple field update (no hooks).- Returns updated
InstalledModuleOut.
DELETE /marketplace/installations/{id} — unchanged signature, new behavior:
- Sets state to removing.
- Calls plugin uninstall() / deactivate().
- Cleans up connector_instance / policy_pack_binding / OPA policy.
- Soft-deletes (state = removed) instead of hard-delete.
POST /marketplace/install — unchanged signature, new behavior:
- Calls configure() + install() hooks.
- Populates last_error and retry_count on failure instead of raising 500.
New Endpoint¶
GET /marketplace/installed/{id}/health
- Connectors: calls plugin.health_check(config), returns { "status": "healthy" | "degraded" | "unhealthy", "detail": "..." }.
- Policy packs: checks OPA policy exists and compiles → healthy/unhealthy.
- 404 if module not found or not in installed state.
Response Schema Addition¶
InstalledModuleOut gains:
retry_count: int
last_error: str | null
No new tables. Only the retry_count column added to installed_modules.
6. Service Layer¶
New: backend/app/modules/marketplace/lifecycle.py¶
class ModuleLifecycleService:
"""Owns all install_state transitions and plugin hook orchestration."""
def __init__(self, repo, connector_repo, policy_runtime_svc, plugin_registry):
...
async def install(self, module, org_id, config, license_token) -> InstalledModule
async def enable(self, installation_id, org_id) -> InstalledModule
async def disable(self, installation_id, org_id) -> InstalledModule
async def retry(self, installation_id, org_id) -> InstalledModule
async def uninstall(self, installation_id, org_id) -> None
async def health_check(self, installation_id, org_id) -> HealthStatus
Each method: loads current state → validates transition → executes hooks with rollback → persists new state + marketplace_event.
Unchanged: backend/app/modules/marketplace/service.py¶
Retains: purchase flow, catalog queries, request workflow, entitlement management.
Moved to lifecycle.py¶
install_module()logicupdate_installed_module()logic (state-changing parts)- Uninstall logic
Dependency Injection¶
New get_lifecycle_service() factory in marketplace/dependencies.py composing from existing repos + plugin registry.
Plugin Resolution¶
Resolves plugin instance from registry using module.module_type + module.module_key. Uses existing registry for hardcoded plugins (GitHub, GDPR, etc.). Same interface for future dynamic plugins.
7. Testing Strategy¶
Unit Tests (test_marketplace_lifecycle.py)¶
- Every legal state transition succeeds
- Every illegal transition raises 400
- Rollback: mock
plugin.install()to raise → verify connector_instance deleted, state=error, last_error populated - Retry: verify retry_count increments, max retries enforced (4th retry blocked)
- Uninstall best-effort: mock
plugin.uninstall()to raise → verify state still becomes removed - Hook adapter: plugin without
configuremethod → skip without error
Mock all repos and plugin registry — pure logic testing.
Integration Tests (test_marketplace_lifecycle_integration.py)¶
- Full install flow: purchase → install → verify connector_instance exists + state=installed
- Full disable/enable cycle: verify plugin hooks called in correct order
- Full uninstall flow: verify all resources cleaned up
- Retry flow: install with failing plugin → retry → succeed on second attempt
- Health check: installed module returns healthy, disabled module returns 404
Real repos against test database, mocked plugin instances.
Existing Test Updates¶
test_marketplace.py— update for action-based PATCH schematest_connector_plugins.py— add coverage forconfigure(),install(),uninstall()hooks
Out of Scope¶
- OPA network calls (mocked)
- GitHub API calls (already mocked)
- UI changes
Estimated Coverage¶
~15-20 test cases across the three files.
8. Files Changed¶
| File | Change |
|---|---|
backend/app/modules/marketplace/lifecycle.py |
New. ModuleLifecycleService with all transition logic. |
backend/app/modules/marketplace/service.py |
Remove install/update/uninstall logic, delegate to lifecycle service. |
backend/app/modules/marketplace/router.py |
Wire action-based PATCH, add health endpoint, inject lifecycle service. |
backend/app/modules/marketplace/schemas.py |
Add action field to update schema, add HealthStatusOut. |
backend/app/modules/marketplace/dependencies.py |
Add get_lifecycle_service() factory. |
backend/app/modules/marketplace/repository.py |
Add update_state() helper, increment_retry(). |
backend/db/postgres/V12__retry_count.sql |
Add retry_count column to installed_modules. |
backend/tests/modules/test_marketplace_lifecycle.py |
New. Unit tests for state machine. |
backend/tests/modules/test_marketplace_lifecycle_integration.py |
New. Integration tests. |
backend/tests/modules/test_marketplace.py |
Update for new PATCH schema. |
backend/tests/modules/test_connector_plugins.py |
Add hook coverage. |