Skip to content

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 error state, 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_count increments on each error → provisioning transition.
  • Max 3 retries enforced in the service (configurable constant).
  • After max retries, module stays in error — user must uninstall and reinstall.
  • last_error preserved 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" }

  • action triggers state transition + lifecycle hooks.
  • channel is 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() logic
  • update_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 configure method → 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 schema
  • test_connector_plugins.py — add coverage for configure(), 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.