diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 016da287..8fb362c5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -18,6 +18,19 @@ "description": "All AEM as a Cloud Service skills: component development and Dispatcher, workflows" }, { + "name": "aem-best-practices", + "source": "./skills/aem/cloud-service/skills/best-practices", + "description": "AEM Cloud Service Java/OSGi: pattern modules, Felix SCR→OSGi DS, and ResourceResolver/logging skills (single plugin)" + }, + { + "name": "aem-migration", + "source": "./skills/aem/cloud-service/skills/migration", + "description": "Migrate legacy AEM code to Cloud Service: BPA/CAM workflows; delegates to aem-best-practices plugin" + }, + { + "name": "aem-6-5-lts-dispatcher", + "source": "./skills/aem/6.5-lts/skills/dispatcher", + "description": "Mode-specific Dispatcher skills for AEM 6.5 LTS and AMS workflows" "name": "aem-6-5-lts", "source": "./skills/aem/6.5-lts", "description": "All AEM 6.5 LTS skills: Dispatcher, workflows for AEM 6.5 LTS and AMS environment" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4d3e9f0b..b76ccc6a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,4 +19,4 @@ jobs: - run: npm ci - - run: npm run validate + - run: npm test diff --git a/README.md b/README.md index f31933ac..6c7a2636 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ Repository of Adobe skills for AI coding agents. # Install all AEM as a Cloud Service skills (create-component + workflow + dispatcher) in one command /plugin install aem-cloud-service@adobe-skills +# Install AEM 6.5 LTS Dispatcher plugin +/plugin install aem-6-5-lts-dispatcher@adobe-skills + +# Install AEM Cloud Service best practices (platform + pattern modules) +/plugin install aem-best-practices@adobe-skills + +# For migrations — install migration and best-practices (patterns / refactors live in best-practices; migration delegates to them): +/plugin install aem-migration@adobe-skills +/plugin install aem-best-practices@adobe-skills + # Install all AEM 6.5 LTS skills (workflow + dispatcher) in one command /plugin install aem-6-5-lts@adobe-skills ``` @@ -32,6 +42,18 @@ npx skills add https://github.com/adobe/skills/tree/beta/skills/aem/cloud-servic # Install all AEM 6.5 LTS skills (workflow + dispatcher) in one command npx skills add https://github.com/adobe/skills/tree/beta/skills/aem/6.5-lts --all +# Install all AEM Cloud Service best-practices skills (pattern modules + SCR→DS + resource/logging) +npx skills add https://github.com/adobe/skills/tree/main/skills/aem/cloud-service/skills/best-practices --all + +# For migrations — install migration and best-practices (patterns / refactors live in best-practices; migration delegates to them): +npx skills add https://github.com/adobe/skills/tree/main/skills/aem/cloud-service/skills/migration --all +npx skills add https://github.com/adobe/skills/tree/main/skills/aem/cloud-service/skills/best-practices --all + +# Install dispatcher skills for a single agent (pick ONE mode only) +# AEM as a Cloud Service mode: +npx skills add https://github.com/adobe/skills/tree/main/skills/aem/cloud-service/skills/dispatcher --all -a cursor -y +# AEM 6.5 LTS mode: +npx skills add https://github.com/adobe/skills/tree/main/skills/aem/6.5-lts/skills/dispatcher --all -a cursor -y # Install for a single agent (pick ONE flavor only) npx skills add https://github.com/adobe/skills/tree/beta/skills/aem/cloud-service -a cursor -y npx skills add https://github.com/adobe/skills/tree/beta/skills/aem/6.5-lts -a cursor -y @@ -60,6 +82,13 @@ gh upskill adobe/skills --path skills/aem/cloud-service --all # Install all AEM 6.5 LTS skills (workflow + dispatcher) gh upskill adobe/skills --path skills/aem/6.5-lts --all +# Install only AEM Cloud Service best-practices skills +gh upskill adobe/skills --path skills/aem/cloud-service/skills/best-practices --all + +# For migrations — install migration and best-practices (patterns / refactors live in best-practices; migration delegates to them): +gh upskill adobe/skills --path skills/aem/cloud-service/skills/migration --all +gh upskill adobe/skills --path skills/aem/cloud-service/skills/best-practices --all + # Install a specific skill gh upskill adobe/skills --path skills/aem/edge-delivery-services --skill content-driven-development @@ -139,6 +168,14 @@ Current dispatcher flavors: Each flavor contains parallel capability groups (workflow orchestration, config authoring, technical advisory, incident response, performance tuning, and security hardening). Shared advisory logic is centralized under each flavor's `dispatcher/shared/references/` to reduce duplication and drift. +### AEM as a Cloud Service — Best Practices & Migration + +Under `skills/aem/cloud-service/skills/`, **`aem-best-practices`** (source `best-practices/`) is the **general-purpose** Cloud Service skill: pattern modules, Java baseline references (SCR→OSGi DS, resolver/logging, and related refs), and day-to-day Cloud Service alignment. Use it **on its own** for greenfield or maintainability work on AEM as a Cloud Service. **`aem-migration`** (source `migration/`, BPA/CAM orchestration) is **scoped to legacy AEM → AEM as a Cloud Service** (not Edge Delivery or 6.5 LTS); it **delegates** concrete refactors to **`aem-best-practices`** (`references/`). **For BPA- or CAM-driven bulk migration, install both** (see Installation); use **`aem-migration`** alone only when the agent already has the same best-practices material (for example the full repo open). + +**Key features:** +- **Best practices:** one skill for patterns, SCR→OSGi DS, and resolver/logging — applicable to Cloud Service projects generally, not only migration +- **Migration:** orchestration-only; pattern and transformation content lives in **`aem-best-practices`** + ## Repository Structure ``` @@ -155,6 +192,24 @@ skills/ | |-- .claude-plugin/ | | \-- plugin.json | \-- skills/ + | |-- best-practices/ + | | |-- .claude-plugin/ + | | | \-- plugin.json + | | |-- README.md + | | |-- SKILL.md + | | \-- references/ + | | | |-- scheduler.md + | | | |-- replication.md + | | | |-- scr-to-osgi-ds.md + | | | |-- resource-resolver-logging.md + | | | \-- ... + | \-- migration/ + | |-- .claude-plugin/ + | | \-- plugin.json + | |-- README.md + | |-- SKILL.md + | |-- references/ + | \-- scripts/ | |-- ensure-agents-md/ | | |-- SKILL.md <-- bootstrap: creates AGENTS.md + CLAUDE.md if missing | | \-- references/ diff --git a/package.json b/package.json index f7ad8507..f9e373f8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "private": true, "description": "Adobe skills for AI coding agents", "scripts": { - "validate": "find skills -name SKILL.md -exec dirname {} \\; | xargs -I {} skills-ref validate {}" + "validate": "find skills -name SKILL.md -exec dirname {} \\; | xargs -I {} skills-ref validate {}", + "test": "npm run validate && node --test \"skills/aem/cloud-service/skills/migration/scripts/bpa-findings-helper.test.js\" && node -e \"JSON.parse(require('fs').readFileSync('.claude-plugin/marketplace.json','utf8')); console.log('marketplace.json: OK')\"" }, "devDependencies": { "skills-ref": "^0.1.5" diff --git a/skills/aem/cloud-service/skills/best-practices/.claude-plugin/plugin.json b/skills/aem/cloud-service/skills/best-practices/.claude-plugin/plugin.json new file mode 100644 index 00000000..f6500a03 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "aem-best-practices", + "description": "AEM as a Cloud Service Java/OSGi: one skill with pattern modules and Java baseline refs (SCR→OSGi DS, ResourceResolver/logging) under references/. Plugin root: skills/aem/cloud-service/skills/best-practices/.", + "version": "1.0.0", + "author": { + "name": "Adobe" + }, + "repository": "https://github.com/adobe/skills", + "license": "Apache-2.0", + "keywords": ["aem", "aemaacs", "cloud-service", "best-practices", "osgi", "java", "migration", "adobe"] +} diff --git a/skills/aem/cloud-service/skills/best-practices/README.md b/skills/aem/cloud-service/skills/best-practices/README.md new file mode 100644 index 00000000..3dc0fe0e --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/README.md @@ -0,0 +1,29 @@ +# AEM as a Cloud Service — Best practices (plugin) + +Source: `skills/aem/cloud-service/skills/best-practices/`. Plugin **`aem-best-practices`**: `SKILL.md` and `references/` (patterns: scheduler, replication, events, assets; Java baseline: `scr-to-osgi-ds.md`, `resource-resolver-logging.md`, prerequisites hub). + +For **BPA- or CAM-driven bulk migration**, install **`aem-migration`** as well (`skills/aem/cloud-service/skills/migration/`); it supplies targets, this plugin supplies transformation modules. + +## Installation + +### Claude Code Plugins + +```bash +/plugin install aem-best-practices@adobe-skills +``` + +### Vercel Skills + +```bash +npx skills add https://github.com/adobe/skills/tree/main/skills/aem/cloud-service/skills/best-practices --all +``` + +### upskill + +```bash +gh upskill adobe/skills --path skills/aem/cloud-service/skills/best-practices --all +``` + +## Related + +For **BPA/CAM-driven bulk migration**, install **`aem-migration`** (`skills/aem/cloud-service/skills/migration`). diff --git a/skills/aem/cloud-service/skills/best-practices/SKILL.md b/skills/aem/cloud-service/skills/best-practices/SKILL.md new file mode 100644 index 00000000..c6d84ce7 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/SKILL.md @@ -0,0 +1,84 @@ +--- +name: best-practices +description: AEM as a Cloud Service Java/OSGi best practices, guardrails, and legacy-to-cloud pattern transformations. Use for Cloud Service–correct bundles, deprecated APIs, schedulers, ResourceChangeListener, replication, Replicator, JCR observation (javax.jcr.observation.EventListener), OSGi Event Admin (org.osgi.service.event.EventHandler), DAM AssetManager, BPA-style fixes, or any time you need the detailed pattern reference modules under this skill. +--- + +# AEM as a Cloud Service — Best Practices + +Platform guidance for **AEM as a Cloud Service** backend code: what to use, what to avoid, and **how to refactor** known legacy patterns into Cloud-compatible implementations. + +This skill holds the **pattern transformation modules** (`references/*.md`). It is intended to be **installable on its own** so Cloud Service guidance is not locked behind a migration-only package. + +**Quick pick:** Open the **Pattern Reference Modules** table below → jump to the matching `references/.md` → read it fully before editing Java. For Felix SCR, resolvers, or logging, use **Java / OSGi baseline** links in this file first when those appear in the same change set. + +## When to Use This Skill + +Use this skill when you need to: + +- Apply **AEM as a Cloud Service** constraints to Java/OSGi code (new or existing) +- Refactor **legacy patterns** into supported APIs (same modules migration uses) +- Follow **consistent rules** across schedulers, replication, **JCR observation listeners** (`eventListener`), **OSGi event handlers** (`eventHandler`), and DAM assets +- Read **step-by-step transformation** and validation checklists for a specific pattern + +For **BPA/CAM orchestration** (collections, CSV, MCP project selection), use **`aem-migration`** (`skills/aem/cloud-service/skills/migration/`). + +## Pattern Reference Modules + +Each supported pattern has a dedicated module under `references/` relative to this `SKILL.md`. + +| Pattern / topic | BPA Pattern ID | Module file | Status | +|-----------------|----------------|-------------|--------| +| Scheduler | `scheduler` | `references/scheduler.md` | Ready | +| Resource Change Listener | `resourceChangeListener` | `references/resource-change-listener.md` | Ready | +| Replication | `replication` | `references/replication.md` | Ready | +| Event listener (JCR observation) | `eventListener` | `references/event-migration.md` | Ready | +| Event handler (OSGi Event Admin) | `eventHandler` | `references/event-migration.md` | Ready | +| Asset Manager | `assetApi` | `references/asset-manager.md` | Ready | +| Felix SCR → OSGi DS | — | `references/scr-to-osgi-ds.md` | Ready | +| ResourceResolver + SLF4J | — | `references/resource-resolver-logging.md` | Ready | +| *(Prerequisites hub)* | — | `references/aem-cloud-service-pattern-prerequisites.md` | — | + +**Event listener vs event handler (not the same):** **`eventListener`** is **JCR observation** — the JCR API for repository change callbacks (`javax.jcr.observation.EventListener`, `onEvent`). **`eventHandler`** is **OSGi Event Admin** — whiteboard-style OSGi events (`org.osgi.service.event.EventHandler`, `handleEvent`). Both migrate via **`references/event-migration.md`** (Path A vs Path B). **`resourceChangeListener`** is separate: Sling **`ResourceChangeListener`**, module **`references/resource-change-listener.md`**. + +**Before changing code for a pattern:** read the module for that pattern in full. Modules include classification criteria, ordered transformation steps, and validation checklists. + +## Java / OSGi baseline (same skill; no separate installables) + +SCR→DS and `ResourceResolver`/logging are **reference modules** under `references/` — not separate skills. Read them when relevant **instead of** re-embedding the same steps inside each pattern file. + +- **Hub:** [`references/aem-cloud-service-pattern-prerequisites.md`](references/aem-cloud-service-pattern-prerequisites.md) +- **Modules:** [`references/scr-to-osgi-ds.md`](references/scr-to-osgi-ds.md), [`references/resource-resolver-logging.md`](references/resource-resolver-logging.md) + +## Critical Rules (All Patterns) + +**These rules apply to every pattern module. Violation means incorrect migration or unsafe Cloud Service code.** + +- **READ THE PATTERN MODULE FIRST** — never transform code without reading the module +- **READ** [`scr-to-osgi-ds.md`](references/scr-to-osgi-ds.md) and [`resource-resolver-logging.md`](references/resource-resolver-logging.md) when SCR, `ResourceResolver`, or logging are in scope (pattern modules link via the [prerequisites hub](references/aem-cloud-service-pattern-prerequisites.md); do not duplicate long guides inline) +- **DO** preserve environment-specific guards (e.g. `isAuthor()` run mode checks) +- **DO NOT** change business logic inside methods +- **DO NOT** rename classes unless the pattern module explicitly says to +- **DO NOT** invent values — extract from existing code +- **DO NOT** edit files outside the scope agreed with the user (e.g. only BPA targets or paths they named) +- **DO** keep **searches, discovery, and edits** for the customer’s AEM sources inside the **IDE workspace root(s)** currently open; **DO NOT** grep or walk directories outside that boundary to find Java unless the user explicitly points there + +## Manual Pattern Hints (Classification) + +When no BPA list exists, scan imports and types to pick a module: + +| Look for | Pattern | +|----------|---------| +| `org.apache.sling.commons.scheduler.Scheduler` or `scheduler.schedule(` with `Runnable` | `scheduler` | +| `implements ResourceChangeListener` | `resourceChangeListener` | +| `com.day.cq.replication.Replicator` or `org.apache.sling.replication.*` | `replication` | +| **JCR observation:** `javax.jcr.observation.EventListener`, `onEvent(EventIterator)`, `javax.jcr.observation.*` | `eventListener` | +| **OSGi Event Admin:** `org.osgi.service.event.EventHandler`, substantive `handleEvent` (resolver/session/node work) | `eventHandler` | +| `com.day.cq.dam.api.AssetManager` create/remove asset APIs | `assetApi` | +| `org.apache.felix.scr.annotations` | read `references/scr-to-osgi-ds.md` (often combined with a BPA pattern) | +| `getAdministrativeResourceResolver`, `System.out` / `printStackTrace` | read `references/resource-resolver-logging.md` | + +If multiple patterns match, ask which to fix first. + +## Relationship to Migration + +The **`aem-migration`** skill defines **one-pattern-per-session** workflow, BPA/CAM/MCP flows, and user messaging. It **delegates** all detailed transformation steps to this skill’s `references/` modules. It uses a **`{best-practices}`** repo-root path alias to this folder (see its `SKILL.md`). Keep platform truth here; keep orchestration there. diff --git a/skills/aem/cloud-service/skills/best-practices/references/aem-cloud-service-pattern-prerequisites.md b/skills/aem/cloud-service/skills/best-practices/references/aem-cloud-service-pattern-prerequisites.md new file mode 100644 index 00000000..25abf223 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/aem-cloud-service-pattern-prerequisites.md @@ -0,0 +1,17 @@ +# Java / OSGi prerequisites (same skill) + +Before **pattern-specific** steps in other `references/*.md` pattern files, apply these modules when the code touches SCR, `ResourceResolver`, or logging. **Do not paste** their full procedures into other reference files — link here or to the modules below. + +| Topic | Module | +|-------|--------| +| Felix SCR → OSGi Declarative Services | [scr-to-osgi-ds.md](scr-to-osgi-ds.md) | +| `ResourceResolver` + SLF4J logging | [resource-resolver-logging.md](resource-resolver-logging.md) | + +**Repository-root paths** (workspace resolution): + +- `skills/aem/cloud-service/skills/best-practices/references/scr-to-osgi-ds.md` +- `skills/aem/cloud-service/skills/best-practices/references/resource-resolver-logging.md` + +Main skill hub: [`../SKILL.md`](../SKILL.md). + +**Asset API (`assetApi`):** [asset-manager.md](asset-manager.md) and path files only. diff --git a/skills/aem/cloud-service/skills/best-practices/references/asset-manager-create.md b/skills/aem/cloud-service/skills/best-practices/references/asset-manager-create.md new file mode 100644 index 00000000..4395baf0 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/asset-manager-create.md @@ -0,0 +1,238 @@ +# Asset Manager Path A: Create/Upload → Direct Binary Access + +For files using deprecated `createAsset()`, `createAssetForBinary()`, or `getAssetForBinary()`. + +These deprecated APIs are replaced with **Direct Binary Access** via the `@adobe/aem-upload` SDK (client-side) or HTTP API (server-side). + +--- + +## Complete Example: Before and After + +### Before (Legacy AssetManager API) + +```java +package com.example.servlets; + +import org.apache.felix.scr.annotations.Component; +import org.apache.felix.scr.annotations.Reference; +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.api.SlingHttpServletResponse; +import org.apache.sling.api.servlets.SlingAllMethodsServlet; +import com.day.cq.dam.api.Asset; +import com.day.cq.dam.api.AssetManager; + +import javax.servlet.ServletException; +import java.io.IOException; +import java.io.InputStream; + +@Component(immediate = true, metatype = false) +public class CreateAssetServlet extends SlingAllMethodsServlet { + + @Reference + private AssetManager assetManager; + + @Override + protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) + throws ServletException, IOException { + + String assetPath = request.getParameter("path"); + String mimeType = request.getParameter("mimeType"); + InputStream inputStream = request.getInputStream(); + + try { + Asset asset = assetManager.createAsset(assetPath, inputStream, mimeType, true); + response.setContentType("text/plain"); + response.getWriter().write("Asset created: " + asset.getPath()); + } catch (Exception e) { + System.err.println("Error creating asset: " + e.getMessage()); + e.printStackTrace(); + response.setStatus(500); + response.getWriter().write("Error: " + e.getMessage()); + } + } +} +``` + +### After (Cloud Service Compatible - Client-Side Upload) + +**Note:** In AEM Cloud Service, asset creation from InputStream in servlets is deprecated. Migrate to client-side upload using Direct Binary Access. + +**Client-side JavaScript (replaces servlet):** +```javascript +import DirectBinary from '@adobe/aem-upload'; + +async function uploadAsset(file, assetPath, host, token) { + const upload = new DirectBinary.DirectBinaryUpload(); + const options = new DirectBinary.DirectBinaryUploadOptions() + .withUrl(`${host}/api/assets${assetPath}`) + .withUploadFiles([file]) + .withHttpOptions({ + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': file.type + } + }); + + try { + const result = await upload.uploadFiles(options); + return result; + } catch (error) { + console.error('Upload failed:', error); + throw error; + } +} +``` + +**If servlet must remain (redirects to client-side):** +```java +package com.example.servlets; + +import org.osgi.service.component.annotations.Component; +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.api.SlingHttpServletResponse; +import org.apache.sling.api.servlets.SlingAllMethodsServlet; +import org.apache.sling.api.servlets.ServletResolverConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.Servlet; +import javax.servlet.ServletException; +import java.io.IOException; + +@Component(service = Servlet.class, property = { + ServletResolverConstants.SLING_SERVLET_PATHS + "=/bin/createasset" +}) +public class CreateAssetServlet extends SlingAllMethodsServlet { + + private static final Logger LOG = LoggerFactory.getLogger(CreateAssetServlet.class); + + @Override + protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) + throws ServletException, IOException { + + // In Cloud Service, asset creation must use Direct Binary Access + // Redirect client to use @adobe/aem-upload SDK + response.setContentType("application/json"); + response.setStatus(400); + response.getWriter().write("{\"error\":\"Asset creation must use Direct Binary Access. " + + "Use @adobe/aem-upload SDK client-side or HTTP API.\"}"); + LOG.warn("Deprecated createAsset API called - redirecting to Direct Binary Access"); + } +} +``` + +**Key Changes:** +- ✅ Removed `AssetManager.createAsset()` calls +- ✅ Migrated to Direct Binary Access pattern (`@adobe/aem-upload`) +- ✅ Removed Felix SCR → OSGi DS annotations +- ✅ Replaced `System.out/err` → SLF4J Logger +- ✅ Servlet redirects to client-side upload pattern + +--- + +## Pattern prerequisites + +Read [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) for Java/OSGi hygiene. Asset creation/upload scope follows **this file** and `asset-manager.md` only. + +## C1: Replace createAssetForBinary / getAssetForBinary with Direct Binary Access + +**Remove deprecated API usage:** + +```java +// BEFORE (deprecated) +assetManager.createAssetForBinary(binaryFilePath, doSave); +Asset asset = assetManager.getAssetForBinary(binaryFilePath); +if (asset != null) { + response.getWriter().write("Asset created successfully: " + asset.getPath()); +} else { + response.getWriter().write("Failed to create asset."); +} + +// AFTER (Cloud Service — use Direct Binary Access) +// In AEM as a Cloud Service, asset creation must use Direct Binary Access. +// Migrate to client-side upload using @adobe/aem-upload SDK: +// const DirectBinary = require('@adobe/aem-upload'); +// const upload = new DirectBinary.DirectBinaryUpload(); +// const options = new DirectBinary.DirectBinaryUploadOptions() +// .withUrl(targetUrl) +// .withUploadFiles(uploadFiles) +// .withHttpOptions({ headers: { Authorization: ... } }); +// upload.uploadFiles(options).then(...) +// See: https://experienceleague.adobe.com/docs/experience-manager-cloud-service/content/assets/admin/direct-binary-access.html +``` + +**For Java servlets that must remain:** Redirect to client-side upload flow or return instructions. Do not retain deprecated calls. + +## C2: Replace createAsset(path, is, mimeType, overwrite) with Direct Binary Access + +**Remove deprecated API usage:** + +```java +// BEFORE (deprecated) +AssetManager assetManager = req.getResourceResolver().adaptTo(AssetManager.class); +Asset imageAsset = assetManager.createAsset("/content/dam/mysite/test." + fileExt, is, mimeType, true); +resp.setContentType("text/plain"); +resp.getWriter().write("Image Uploaded = " + imageAsset.getName() + " to path = " + imageAsset.getPath()); + +// AFTER (Cloud Service — use Direct Binary Access) +// In AEM as a Cloud Service, asset creation from InputStream is deprecated. +// Migrate to client-side upload using @adobe/aem-upload: +// fetch(sourceUrl).then(r => r.blob()).then(blob => { +// const upload = new DirectBinary.DirectBinaryUpload(); +// const options = new DirectBinary.DirectBinaryUploadOptions() +// .withUrl(targetUrl) +// .withUploadFiles(blob) +// .withHttpOptions({ headers: { Authorization: ... } }); +// upload.uploadFiles(options).then(...); +// }); +``` + +**InputStream handling:** Ensure any `InputStream` is closed in try-with-resources or `finally` block. If migrating away from Java entirely, remove the InputStream logic. + +**ResourceResolver + logging:** Apply [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) for any remaining servlet or service code that opens a resolver or logs errors. + +## C3: Update imports + +**Remove (when deprecated AssetManager usage is removed):** +```java +import com.day.cq.dam.api.Asset; +import com.day.cq.dam.api.AssetManager; +import com.day.cq.dam.api.metadata.MetaDataMap; // if only used for deprecated flow +``` + +**Keep (if AssetManager still used for read-only operations):** +```java +import com.day.cq.dam.api.Asset; +import com.day.cq.dam.api.AssetManager; +``` + +**Add (for logging):** +```java +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +``` + +**Remove (Felix SCR, if migrated):** +```java +import org.apache.felix.scr.annotations.*; +``` + +**Add (OSGi DS, if migrated):** +```java +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.apache.sling.api.servlets.ServletResolverConstants; +import javax.servlet.Servlet; +``` + +--- + +# Validation + +- [ ] No `createAssetForBinary(binaryFilePath, doSave)` or `getAssetForBinary(binaryFilePath)` calls remain +- [ ] No `createAsset(path, is, mimeType, overwrite)` calls remain +- [ ] Direct Binary Access pattern documented or implemented (client-side `@adobe/aem-upload` or equivalent) +- [ ] [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) satisfied for SCR, resolver, logging +- [ ] InputStream resources closed in try-with-resources or `finally` (if any remain) +- [ ] `@Reference` AssetManager removed if no longer needed for create flows +- [ ] Code compiles: `mvn clean compile` diff --git a/skills/aem/cloud-service/skills/best-practices/references/asset-manager-delete.md b/skills/aem/cloud-service/skills/best-practices/references/asset-manager-delete.md new file mode 100644 index 00000000..956be39d --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/asset-manager-delete.md @@ -0,0 +1,242 @@ +# Asset Manager Path B: Delete → HTTP Assets API + +For files using deprecated `removeAssetForBinary()`. + +This deprecated API is replaced with the **HTTP Assets API** `DELETE /api/assets{path}`. + +--- + +## Complete Example: Before and After + +### Before (Legacy AssetManager API) + +```java +package com.example.servlets; + +import org.apache.felix.scr.annotations.Component; +import org.apache.felix.scr.annotations.Reference; +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.api.SlingHttpServletResponse; +import org.apache.sling.api.servlets.SlingAllMethodsServlet; +import com.day.cq.dam.api.AssetManager; + +import javax.servlet.ServletException; +import java.io.IOException; + +@Component(immediate = true, metatype = false) +public class DeleteAssetServlet extends SlingAllMethodsServlet { + + @Reference + private AssetManager assetManager; + + @Override + protected void doDelete(SlingHttpServletRequest request, SlingHttpServletResponse response) + throws ServletException, IOException { + + String binaryFilePath = request.getParameter("path"); + + try { + boolean isDeleted = assetManager.removeAssetForBinary(binaryFilePath, true); + response.setContentType("text/plain"); + if (isDeleted) { + response.getWriter().write("Asset deleted successfully: " + binaryFilePath); + } else { + response.setStatus(404); + response.getWriter().write("Asset not found: " + binaryFilePath); + } + } catch (Exception e) { + System.err.println("Error deleting asset: " + e.getMessage()); + e.printStackTrace(); + response.setStatus(500); + response.getWriter().write("Error: " + e.getMessage()); + } + } +} +``` + +### After (Cloud Service Compatible - Client-Side) + +**Client-side JavaScript:** +```javascript +async function deleteAsset(assetPath, host, credentials) { + try { + const response = await axios.delete(`${host}/api/assets${assetPath}`, { + auth: { + username: credentials.username, + password: credentials.password + } + }); + return response.status === 200 || response.status === 204; + } catch (error) { + if (error.response?.status === 404) { + return false; // Asset not found + } + throw error; + } +} +``` + +### After (Cloud Service Compatible - Server-Side Java) + +**If servlet must remain:** +```java +package com.example.servlets; + +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.impl.client.HttpClientBuilder; +import org.osgi.service.component.annotations.Component; +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.api.SlingHttpServletResponse; +import org.apache.sling.api.servlets.SlingAllMethodsServlet; +import org.apache.sling.api.servlets.ServletResolverConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.Servlet; +import javax.servlet.ServletException; +import java.io.IOException; +import java.util.Base64; + +@Component(service = Servlet.class, property = { + ServletResolverConstants.SLING_SERVLET_PATHS + "=/bin/deleteasset" +}) +public class DeleteAssetServlet extends SlingAllMethodsServlet { + + private static final Logger LOG = LoggerFactory.getLogger(DeleteAssetServlet.class); + + @Override + protected void doDelete(SlingHttpServletRequest request, SlingHttpServletResponse response) + throws ServletException, IOException { + + String assetPath = request.getParameter("path"); + if (assetPath == null || assetPath.isEmpty()) { + response.setStatus(400); + response.getWriter().write("{\"error\":\"path parameter required\"}"); + return; + } + + try { + // Use HTTP Assets API + String host = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort(); + String apiUrl = host + "/api/assets" + assetPath; + + HttpClient client = HttpClientBuilder.create().build(); + HttpDelete deleteRequest = new HttpDelete(apiUrl); + + // Add authentication header (use service user credentials) + String auth = Base64.getEncoder().encodeToString( + (request.getUserPrincipal().getName() + ":password").getBytes() + ); + deleteRequest.setHeader("Authorization", "Basic " + auth); + + org.apache.http.HttpResponse httpResponse = client.execute(deleteRequest); + int statusCode = httpResponse.getStatusLine().getStatusCode(); + + response.setContentType("application/json"); + if (statusCode == 200 || statusCode == 204) { + response.getWriter().write("{\"success\":true,\"message\":\"Asset deleted: " + assetPath + "\"}"); + } else if (statusCode == 404) { + response.setStatus(404); + response.getWriter().write("{\"error\":\"Asset not found: " + assetPath + "\"}"); + } else { + response.setStatus(statusCode); + response.getWriter().write("{\"error\":\"Delete failed with status: " + statusCode + "\"}"); + } + + } catch (Exception e) { + LOG.error("Error deleting asset: {}", assetPath, e); + response.setStatus(500); + response.getWriter().write("{\"error\":\"Internal server error\"}"); + } + } +} +``` + +**Key Changes:** +- ✅ Removed `AssetManager.removeAssetForBinary()` calls +- ✅ Migrated to HTTP Assets API `DELETE /api/assets{path}` +- ✅ Removed Felix SCR → OSGi DS annotations +- ✅ Replaced `System.out/err` → SLF4J Logger +- ✅ Used HttpClient for server-side API calls +- ✅ Proper error handling and status codes + +--- + +## Pattern prerequisites + +Read [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) for Java/OSGi hygiene. Asset delete scope follows **this file** and `asset-manager.md` only. + +## D1: Replace removeAssetForBinary with HTTP Assets API + +**Remove deprecated API usage:** + +```java +// BEFORE (deprecated) +boolean isAssetDeleted = assetManager.removeAssetForBinary(binaryFilePath, doSave); +if (isAssetDeleted) { + response.getWriter().write("Asset deleted successfully: " + binaryFilePath); +} else { + response.getWriter().write("Failed to delete asset."); +} + +// AFTER (Cloud Service — use HTTP Assets API) +// Option A: Call HTTP API from Java (requires HttpClient/HttpURLConnection) +// DELETE {host}/api/assets{path} +// with basic auth +// +// Option B: Migrate to client-side delete using HTTP API: +// const response = await axios.delete(`${host}/api/assets${assetPath}`, { +// auth: { username, password } +// }); +``` + +**HTTP API delete example (client-side):** +```javascript +const response = await axios.delete(`${host}/api/assets${assetPath}`, { + auth: { username, password } +}); +``` + +**ResourceResolver + logging:** Apply [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) for any remaining servlet or service code. + +## D2: Update imports + +**Remove (when deprecated AssetManager delete usage is removed):** +```java +import com.day.cq.dam.api.AssetManager; // if no longer needed +``` + +**Keep (if AssetManager still used for other operations):** +```java +import com.day.cq.dam.api.AssetManager; +``` + +**Add (for logging):** +```java +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +``` + +**Remove (Felix SCR, if migrated):** +```java +import org.apache.felix.scr.annotations.*; +``` + +**Add (OSGi DS, if migrated):** +```java +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.apache.sling.api.servlets.ServletResolverConstants; +import javax.servlet.Servlet; +``` + +--- + +# Validation + +- [ ] No `removeAssetForBinary(binaryFilePath, doSave)` calls remain +- [ ] HTTP Assets API `DELETE /api/assets{path}` used (client or server) +- [ ] [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) satisfied for SCR, resolver, logging +- [ ] `@Reference` AssetManager removed if no longer needed for delete flows +- [ ] Code compiles: `mvn clean compile` diff --git a/skills/aem/cloud-service/skills/best-practices/references/asset-manager.md b/skills/aem/cloud-service/skills/best-practices/references/asset-manager.md new file mode 100644 index 00000000..b1e5c259 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/asset-manager.md @@ -0,0 +1,97 @@ +# Asset Manager API Migration Pattern + +Migrates legacy AEM Asset Manager API usage to Cloud Service compatible patterns. + +**Before you start:** Java baseline ([scr-to-osgi-ds.md](scr-to-osgi-ds.md), [resource-resolver-logging.md](resource-resolver-logging.md)) via [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md). This file **classifies** deprecated `AssetManager` usage and routes to path modules (`asset-manager-create.md`, `asset-manager-delete.md`); asset API scope stays limited to that pattern. + +**Two paths based on operation type:** +- **Path A (Create/Upload):** Uses deprecated `createAsset()`, `createAssetForBinary()`, or `getAssetForBinary()` — migrates to **Direct Binary Access** via `@adobe/aem-upload` SDK +- **Path B (Delete):** Uses deprecated `removeAssetForBinary()` — migrates to **HTTP Assets API** `DELETE /api/assets{path}` + +--- + +## Quick Examples + +### Path A Example (Create/Upload) + +**Before:** +```java +AssetManager assetManager = resolver.adaptTo(AssetManager.class); +Asset asset = assetManager.createAssetForBinary(binaryFilePath, true); +``` + +**After (Client-side):** +```javascript +const DirectBinary = require('@adobe/aem-upload'); +const upload = new DirectBinary.DirectBinaryUpload(); +const options = new DirectBinary.DirectBinaryUploadOptions() + .withUrl(`${host}/api/assets${path}`) + .withUploadFiles([file]) + .withHttpOptions({ headers: { Authorization: `Bearer ${token}` } }); +await upload.uploadFiles(options); +``` + +### Path B Example (Delete) + +**Before:** +```java +AssetManager assetManager = resolver.adaptTo(AssetManager.class); +boolean deleted = assetManager.removeAssetForBinary(binaryFilePath, true); +``` + +**After (Client-side):** +```javascript +await axios.delete(`${host}/api/assets${assetPath}`, { + auth: { username, password } +}); +``` + +**After (Server-side Java):** +```java +// Use HttpClient to call DELETE /api/assets{path} +HttpClient client = HttpClientBuilder.create().build(); +HttpDelete delete = new HttpDelete(host + "/api/assets" + assetPath); +delete.setHeader("Authorization", "Basic " + base64Credentials); +HttpResponse response = client.execute(delete); +``` + +--- + +## Classification + +**Classify BEFORE making any changes.** + +### Use Path A when ANY of these are true: +- File calls `assetManager.createAsset(path, inputStream, mimeType, overwrite)` +- File calls `assetManager.createAssetForBinary(binaryFilePath, doSave)` +- File calls `assetManager.getAssetForBinary(binaryFilePath)` +- File uses `resourceResolver.adaptTo(AssetManager.class)` for asset creation or upload + +**If Path A → read `asset-manager-create.md` and follow its steps.** + +### Use Path B when ANY of these are true: +- File calls `assetManager.removeAssetForBinary(binaryFilePath, doSave)` +- File uses `AssetManager` exclusively for delete operations + +**If Path B → read `asset-manager-delete.md` and follow its steps.** + +### Mixed operations (both create and delete): +If the file uses BOTH create/upload AND delete operations, process **Path A first**, then **Path B**. Read both path files sequentially. + +### Already compliant — skip migration: +- File only uses `AssetManager.getAsset(path)` for read operations (metadata, renditions) — no migration needed + +## Asset-Specific Rules + +- **CLASSIFY FIRST** — determine Path A, Path B, or Mixed before making any changes +- **DO** replace deprecated `createAssetForBinary` / `getAssetForBinary` with Direct Binary Access +- **DO** replace deprecated `removeAssetForBinary` with HTTP Assets API DELETE +- **DO** replace deprecated `createAsset(path, is, mimeType, overwrite)` with Direct Binary Access +- **DO** use `@adobe/aem-upload` SDK for client-side uploads +- **DO** use HTTP API for server-side delete operations when migrating servlets +- **DO NOT** call deprecated AssetManager methods for create/remove +- **DO NOT** keep inline asset creation from InputStream in Java servlets + +## IMPORTANT + +**Read ONLY the path file that matches your classification. Do NOT read both (unless Mixed).** diff --git a/skills/aem/cloud-service/skills/best-practices/references/event-migration-path-a.md b/skills/aem/cloud-service/skills/best-practices/references/event-migration-path-a.md new file mode 100644 index 00000000..057bdd87 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/event-migration-path-a.md @@ -0,0 +1,465 @@ +# Event Migration Path A: JCR EventListener → EventHandler + JobConsumer + +For classes that implement `javax.jcr.observation.EventListener` with `onEvent(EventIterator)`. + +This path converts the JCR observation listener to an OSGi EventHandler, then offloads business logic to a Sling JobConsumer. + +--- + +## Complete Example: Before and After + +### Before (Legacy JCR EventListener) + +```java +package com.example.listeners; + +import org.apache.felix.scr.annotations.Component; +import org.apache.felix.scr.annotations.Reference; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import javax.jcr.observation.Event; +import javax.jcr.observation.EventListener; +import javax.jcr.observation.EventIterator; + +@Component(immediate = true) +public class ACLModificationListener implements EventListener { + + @Reference + private ResourceResolverFactory resourceResolverFactory; + + @Override + public void onEvent(EventIterator events) { + try { + ResourceResolver resolver = resourceResolverFactory.getAdministrativeResourceResolver(null); + if (resolver != null) { + while (events.hasNext()) { + Event event = events.nextEvent(); + if (event.getType() == Event.PROPERTY_CHANGED) { + String path = event.getPath(); + if (path.contains("/rep:policy")) { + System.out.println("ACL modified at: " + path); + // business logic: update replication date + } + } + } + resolver.close(); + } + } catch (Exception e) { + System.err.println("Error handling ACL modification: " + e.getMessage()); + e.printStackTrace(); + } + } +} +``` + +### After (Cloud Service Compatible) + +**File 1: ACLModificationEventHandler.java** (EventHandler) + +```java +package com.example.listeners; + +import org.osgi.framework.Constants; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.event.Event; +import org.osgi.service.event.EventConstants; +import org.osgi.service.event.EventHandler; +import org.apache.sling.event.jobs.JobManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + +@Component( + service = EventHandler.class, + immediate = true, + property = { + Constants.SERVICE_DESCRIPTION + "=Event Handler for ACL modifications", + EventConstants.EVENT_TOPIC + "=org/apache/sling/api/resource/Resource/CHANGED", + EventConstants.EVENT_FILTER + "=(path=/*/rep:policy)" + } +) +public class ACLModificationEventHandler implements EventHandler { + + private static final Logger LOG = LoggerFactory.getLogger(ACLModificationEventHandler.class); + private static final String JOB_TOPIC = "com/example/acl/modification/job"; + + @Reference + private JobManager jobManager; + + @Override + public void handleEvent(Event event) { + try { + String path = (String) event.getProperty("path"); + LOG.debug("Resource event: {} for path: {}", event.getTopic(), path); + + Map jobProperties = new HashMap<>(); + jobProperties.put("path", path); + jobManager.addJob(JOB_TOPIC, jobProperties); + } catch (Exception e) { + LOG.error("Error handling event", e); + } + } +} +``` + +**File 2: ACLModificationJobConsumer.java** (JobConsumer) + +```java +package com.example.listeners; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.apache.sling.event.jobs.consumer.JobResult; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; + +@Component( + service = JobConsumer.class, + immediate = true, + property = { + JobConsumer.PROPERTY_TOPICS + "=" + "com/example/acl/modification/job" + } +) +public class ACLModificationJobConsumer implements JobConsumer { + + private static final Logger LOG = LoggerFactory.getLogger(ACLModificationJobConsumer.class); + + @Reference + private ResourceResolverFactory resolverFactory; + + @Override + public JobResult process(final Job job) { + String path = (String) job.getProperty("path"); + LOG.info("Processing ACL modification job for path: {}", path); + + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "event-handler-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return JobResult.FAILED; + } + + // Business logic: update replication date + LOG.info("ACL modified at: {}", path); + // ... existing business logic from onEvent() goes here ... + + return JobResult.OK; + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + return JobResult.FAILED; + } catch (Exception e) { + LOG.error("Error processing job", e); + return JobResult.FAILED; + } + } +} +``` + +**Key Changes:** +- ✅ Converted JCR `EventListener` → OSGi `EventHandler` +- ✅ Split into EventHandler + JobConsumer +- ✅ EventHandler is lightweight — only creates jobs +- ✅ Business logic moved to JobConsumer `process()` method +- ✅ Replaced `getAdministrativeResourceResolver()` → `getServiceResourceResolver()` with SUBSERVICE +- ✅ Replaced `System.out/err` → SLF4J Logger +- ✅ Event topics correctly mapped (JCR → OSGi) +- ✅ Preserved business logic unchanged + +--- + +## Pattern prerequisites + +Read [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) before the steps below. + +## A1: Convert JCR EventListener to OSGi EventHandler + +Replace JCR observation API with OSGi event API: + +```java +// BEFORE (JCR Observation) +import javax.jcr.observation.Event; +import javax.jcr.observation.EventListener; +import javax.jcr.observation.EventIterator; + +public class ACLModificationListener implements EventListener { + @Override + public void onEvent(EventIterator events) { + while (events.hasNext()) { + Event event = events.nextEvent(); + if (event.getType() == Event.PROPERTY_CHANGED) { + String path = event.getPath(); + // business logic... + } + } + } +} + +// AFTER (OSGi EventHandler — lightweight) +import org.osgi.service.event.Event; +import org.osgi.service.event.EventHandler; +import org.osgi.service.event.EventConstants; + +@Component(service = EventHandler.class, immediate = true, property = { + Constants.SERVICE_DESCRIPTION + "=Event Handler for ACL modifications", + EventConstants.EVENT_TOPIC + "=org/apache/sling/api/resource/Resource/CHANGED", + EventConstants.EVENT_FILTER + "=(path=/*/rep:policy)" +}) +public class ACLModificationEventHandler implements EventHandler { + @Reference + private JobManager jobManager; + + @Override + public void handleEvent(Event event) { + String path = (String) event.getProperty("path"); + // offload to job — business logic goes to JobConsumer (step A3) + } +} +``` + +**JCR event type to OSGi resource topic mapping:** + +| JCR Event Type | OSGi Resource Event Topic | +|---------------|---------------------------| +| `Event.NODE_ADDED` | `org/apache/sling/api/resource/Resource/ADDED` | +| `Event.NODE_REMOVED` | `org/apache/sling/api/resource/Resource/REMOVED` | +| `Event.PROPERTY_CHANGED` | `org/apache/sling/api/resource/Resource/CHANGED` | +| `Event.PROPERTY_ADDED` | `org/apache/sling/api/resource/Resource/CHANGED` | +| `Event.PROPERTY_REMOVED` | `org/apache/sling/api/resource/Resource/CHANGED` | + +**JCR event data to OSGi event property mapping:** + +| JCR Event API | OSGi Event API | +|--------------|----------------| +| `event.getPath()` | `(String) event.getProperty("path")` | +| `event.getIdentifier()` | `(String) event.getProperty("propertyName")` or use in EVENT_FILTER | +| `event.getType()` | Determined by topic (no need to check type) | + +## A2: Make EventHandler lightweight — offload to Sling Job + +The `handleEvent()` method should ONLY: +1. Extract event data (path, properties, etc.) +2. Create a Sling Job with those properties +3. Return immediately + +```java +@Override +public void handleEvent(Event event) { + try { + String path = (String) event.getProperty("path"); + LOG.debug("Resource event: {} for path: {}", event.getTopic(), path); + + Map jobProperties = new HashMap<>(); + jobProperties.put("path", path); + jobManager.addJob(JOB_TOPIC, jobProperties); + } catch (Exception e) { + LOG.error("Error handling event", e); + } +} +``` + +**Add JobManager injection:** +```java +@Reference +private JobManager jobManager; + +private static final String JOB_TOPIC = "com/example/event/job"; +``` + +**Remove ResourceResolverFactory from EventHandler** — it moves to the JobConsumer. + +## A3: Create the JobConsumer class + +Create a NEW class that implements `JobConsumer` to handle the business logic: + +```java +package com.example.listeners; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Collections; + +@Component( + service = JobConsumer.class, + immediate = true, + property = { + JobConsumer.PROPERTY_TOPICS + "=" + "com/example/event/job" + } +) +public class ACLModificationJobConsumer implements JobConsumer { + + private static final Logger LOG = LoggerFactory.getLogger(ACLModificationJobConsumer.class); + + @Reference + private ResourceResolverFactory resolverFactory; + + @Override + public JobResult process(final Job job) { + String path = (String) job.getProperty("path"); + LOG.info("Processing job for path: {}", path); + + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "event-handler-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return JobResult.FAILED; + } + + // === EXISTING BUSINESS LOGIC FROM onEvent() GOES HERE === + + return JobResult.OK; + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + return JobResult.FAILED; + } catch (Exception e) { + LOG.error("Error processing job", e); + return JobResult.FAILED; + } + } +} +``` + +**Key rules for JobConsumer:** +- Job topic MUST match the topic used in the EventHandler +- Move ALL business logic from `onEvent()` into `process(Job)` +- Move business-logic `@Reference` fields here (e.g., `ResourceResolverFactory`) +- Extract job properties via `job.getProperty("key")` or `(Type) job.getProperty("key")` +- Return `JobResult.OK` on success, `JobResult.FAILED` on failure +- Resolver + logging in JobConsumer per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) + +## A4: Add TopologyEventListener for replication handlers (if applicable) + +If the original JCR listener observed replication-related events and should only run on one instance, add `TopologyEventListener`: + +```java +@Component(service = { EventHandler.class, TopologyEventListener.class }, immediate = true, property = { + EventConstants.EVENT_TOPIC + "=" + ReplicationEvent.EVENT_TOPIC, + EventConstants.EVENT_FILTER + "(" + ReplicationAction.PROPERTY_TYPE + "=ACTIVATE)" +}) +public class PublishDateEventHandler implements EventHandler, TopologyEventListener { + + private volatile boolean isLeader = false; + + @Override + public void handleTopologyEvent(TopologyEvent event) { + if (event.getType() == TopologyEvent.Type.TOPOLOGY_CHANGED + || event.getType() == TopologyEvent.Type.TOPOLOGY_INIT) { + isLeader = event.getNewView().getLocalInstance().isLeader(); + } + } + + @Override + public void handleEvent(Event event) { + if (isLeader) { + Map jobProperties = new HashMap<>(); + jobProperties.put("path", ReplicationEvent.fromEvent(event).getReplicationAction().getPath()); + jobManager.addJob(JOB_TOPIC, jobProperties); + } + } +} +``` + +**Only add this if:** +- The handler processes replication events (`ReplicationEvent.EVENT_TOPIC`) +- The handler should only fire on one node in the cluster +- The original code had leader-check logic or similar singleton behavior + +## A5: Update imports + +**EventHandler class — Remove:** +```java +import javax.jcr.observation.Event; +import javax.jcr.observation.EventListener; +import javax.jcr.observation.EventIterator; +import org.apache.felix.scr.annotations.*; +import org.apache.sling.api.resource.ResourceResolverFactory; // moves to JobConsumer +``` + +**EventHandler class — Add:** +```java +import org.osgi.service.event.Event; +import org.osgi.service.event.EventConstants; +import org.osgi.service.event.EventHandler; +import org.osgi.framework.Constants; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.apache.sling.event.jobs.JobManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.HashMap; +import java.util.Map; +``` + +**If using TopologyEventListener, also add:** +```java +import org.apache.sling.discovery.TopologyEvent; +import org.apache.sling.discovery.TopologyEventListener; +``` + +**JobConsumer class — Remove:** +```java +import org.apache.felix.scr.annotations.*; +``` + +**JobConsumer class — Add:** +```java +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Collections; +``` + +--- + +# Validation + +## EventHandler Checklist + +- [ ] No `import javax.jcr.observation.*` remains +- [ ] SCR→DS per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) +- [ ] `implements EventHandler` (not `EventListener`) +- [ ] `@Component(service = EventHandler.class, property = { EVENT_TOPIC... })` is present +- [ ] No business logic in `handleEvent()` — only event data extraction + job creation +- [ ] No `ResourceResolver`, `Session`, or `Node` operations in `handleEvent()` +- [ ] `@Reference JobManager` is present +- [ ] `jobManager.addJob(TOPIC, properties)` is called +- [ ] Event topics correctly mapped from JCR to OSGi +- [ ] Event filtering preserves original filter logic (paths, types, property names) +- [ ] Logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) +- [ ] Replication handlers implement `TopologyEventListener` and check `isLeader` (if applicable) + +## JobConsumer Checklist + +- [ ] Implements `JobConsumer` +- [ ] Has `@Component(service = JobConsumer.class, property = { PROPERTY_TOPICS... })` +- [ ] Job topic matches the EventHandler topic +- [ ] Business logic from original `onEvent()` is preserved +- [ ] Returns `JobResult.OK` or `JobResult.FAILED` +- [ ] Resolver + logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) +- [ ] Code compiles: `mvn clean compile` diff --git a/skills/aem/cloud-service/skills/best-practices/references/event-migration-path-b.md b/skills/aem/cloud-service/skills/best-practices/references/event-migration-path-b.md new file mode 100644 index 00000000..ca3ba810 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/event-migration-path-b.md @@ -0,0 +1,469 @@ +# Event Migration Path B: OSGi EventHandler with Inline Logic → Lightweight + JobConsumer + +For classes that already implement `org.osgi.service.event.EventHandler` but have business logic (ResourceResolver, JCR Session, Node operations) directly inside `handleEvent()`. + +This path keeps the EventHandler class but offloads all business logic to a new Sling JobConsumer. + +--- + +## Complete Example: Before and After + +### Before (Legacy OSGi EventHandler with Inline Logic) + +```java +package com.example.listeners; + +import org.apache.felix.scr.annotations.Component; +import org.apache.felix.scr.annotations.Reference; +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ModifiableValueMap; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.osgi.service.event.Event; +import org.osgi.service.event.EventConstants; +import org.osgi.service.event.EventHandler; + +import java.util.Calendar; +import java.util.Collections; + +@Component( + immediate = true, + property = { + EventConstants.EVENT_TOPIC + "=org/apache/sling/api/resource/Resource/CHANGED" + } +) +public class ReplicationDateEventHandler implements EventHandler { + + @Reference + private ResourceResolverFactory resourceResolverFactory; + + @Override + public void handleEvent(Event event) { + String path = (String) event.getProperty("path"); + try { + ResourceResolver resolver = resourceResolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "replication-service")); + + if (resolver != null) { + // Business logic: update replication date + Resource resource = resolver.getResource(path + "/jcr:content"); + if (resource != null) { + ModifiableValueMap map = resource.adaptTo(ModifiableValueMap.class); + map.put("cq:lastReplicated", Calendar.getInstance()); + resolver.commit(); + } + resolver.close(); + } + } catch (Exception e) { + System.err.println("Error updating replication date: " + e.getMessage()); + e.printStackTrace(); + } + } +} +``` + +### After (Cloud Service Compatible) + +**File 1: ReplicationDateEventHandler.java** (Lightweight EventHandler) + +```java +package com.example.listeners; + +import org.osgi.framework.Constants; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.event.Event; +import org.osgi.service.event.EventConstants; +import org.osgi.service.event.EventHandler; +import org.apache.sling.event.jobs.JobManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + +@Component( + service = EventHandler.class, + immediate = true, + property = { + Constants.SERVICE_DESCRIPTION + "=Event Handler for replication date updates", + EventConstants.EVENT_TOPIC + "=org/apache/sling/api/resource/Resource/CHANGED" + } +) +public class ReplicationDateEventHandler implements EventHandler { + + private static final Logger LOG = LoggerFactory.getLogger(ReplicationDateEventHandler.class); + private static final String JOB_TOPIC = "com/example/replication/date/update"; + + @Reference + private JobManager jobManager; + + @Override + public void handleEvent(Event event) { + try { + String path = (String) event.getProperty("path"); + LOG.debug("Resource event: {} for path: {}", event.getTopic(), path); + + Map jobProperties = new HashMap<>(); + jobProperties.put("path", path); + jobManager.addJob(JOB_TOPIC, jobProperties); + } catch (Exception e) { + LOG.error("Error handling event", e); + } + } +} +``` + +**File 2: ReplicationDateJobConsumer.java** (JobConsumer) + +```java +package com.example.listeners; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ModifiableValueMap; +import org.apache.sling.api.resource.PersistenceException; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.apache.sling.event.jobs.consumer.JobResult; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Calendar; +import java.util.Collections; + +@Component( + service = JobConsumer.class, + immediate = true, + property = { + JobConsumer.PROPERTY_TOPICS + "=" + "com/example/replication/date/update" + } +) +public class ReplicationDateJobConsumer implements JobConsumer { + + private static final Logger LOG = LoggerFactory.getLogger(ReplicationDateJobConsumer.class); + + @Reference + private ResourceResolverFactory resolverFactory; + + @Override + public JobResult process(final Job job) { + String path = (String) job.getProperty("path"); + LOG.info("Processing replication date update job for path: {}", path); + + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "event-handler-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return JobResult.FAILED; + } + + // Business logic: update replication date + Resource resource = resolver.getResource(path + "/jcr:content"); + if (resource != null) { + ModifiableValueMap map = resource.adaptTo(ModifiableValueMap.class); + map.put("cq:lastReplicated", Calendar.getInstance()); + resolver.commit(); + LOG.debug("Updated replication date for: {}", path); + } + + return JobResult.OK; + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + return JobResult.FAILED; + } catch (PersistenceException e) { + LOG.error("Failed to commit changes", e); + return JobResult.FAILED; + } catch (Exception e) { + LOG.error("Error processing job", e); + return JobResult.FAILED; + } + } +} +``` + +**Key Changes:** +- ✅ Split EventHandler + JobConsumer +- ✅ EventHandler is lightweight — only creates jobs +- ✅ Business logic moved to JobConsumer `process()` method +- ✅ Replaced `System.out/err` → SLF4J Logger +- ✅ Used try-with-resources for ResourceResolver +- ✅ Preserved business logic unchanged + +--- + +## Pattern prerequisites + +Read [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) before the steps below. + +## B1: Make EventHandler lightweight — offload to Sling Job + +The `handleEvent()` method should ONLY: +1. Extract event data (path, properties, event type, etc.) +2. Create a Sling Job with those properties +3. Return immediately + +**Move ALL business logic out of handleEvent().** + +**Replication event example:** +```java +// BEFORE (inline business logic in handler) +@Override +public void handleEvent(Event event) { + if (ReplicationEvent.fromEvent(event).getReplicationAction().getType().equals(ReplicationActionType.ACTIVATE)) { + try (ResourceResolver resourceResolver = resourceResolverFactory.getServiceResourceResolver(AUTH_INFO)) { + Resource resource = resourceResolver.getResource(event.getPath() + "/jcr:content"); + if (resource != null) { + ModifiableValueMap map = resource.adaptTo(ModifiableValueMap.class); + map.put("cq:lastReplicated", Calendar.getInstance()); + resource.getResourceResolver().commit(); + } + } catch (LoginException | PersistenceException ex) { + LOG.error("Error", ex); + } + } +} + +// AFTER (lightweight — just creates a job) +@Override +public void handleEvent(Event event) { + try { + String path = ReplicationEvent.fromEvent(event).getReplicationAction().getPath(); + LOG.debug("Resource event: {} for path: {}", event.getTopic(), path); + + if (ReplicationEvent.fromEvent(event).getReplicationAction().getType().equals(ReplicationActionType.ACTIVATE)) { + Map jobProperties = new HashMap<>(); + jobProperties.put("path", path); + if (isLeader) { + jobManager.addJob(JOB_TOPIC, jobProperties); + } + } + } catch (Exception e) { + LOG.error("Error handling event", e); + } +} +``` + +**Workflow event example:** +```java +// BEFORE (inline business logic) +@Override +public void handleEvent(Event event) { + WorkflowEvent wfevent = (WorkflowEvent) event; + if (wfevent.getEventType().equals(WorkflowEvent.WORKFLOW_COMPLETED_EVENT)) { + String path = (String) event.getProperty("path"); + Session session = resourceResolver.adaptTo(Session.class); + Node node = session.getNode(path); + node.setProperty("property", "Updated Value"); + session.save(); + } +} + +// AFTER (lightweight) +@Override +public void handleEvent(Event event) { + WorkflowEvent wfevent = (WorkflowEvent) event; + if (wfevent.getEventType().equals(WorkflowEvent.WORKFLOW_COMPLETED_EVENT)) { + String path = (String) event.getProperty("path"); + Map jobProperties = new HashMap<>(); + jobProperties.put("path", path); + jobManager.addJob("workflow/completion/job", jobProperties); + } +} +``` + +**Add JobManager injection:** +```java +@Reference +private JobManager jobManager; + +private static final String JOB_TOPIC = "com/example/event/job"; +``` + +**Remove ResourceResolverFactory from EventHandler** — it moves to the JobConsumer. + +## B2: Create the JobConsumer class + +Create a NEW class that implements `JobConsumer` to handle the business logic: + +```java +package com.example.listeners; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Collections; + +@Component( + service = JobConsumer.class, + immediate = true, + property = { + JobConsumer.PROPERTY_TOPICS + "=" + "com/example/event/job" + } +) +public class ReplicationJobConsumer implements JobConsumer { + + private static final Logger LOG = LoggerFactory.getLogger(ReplicationJobConsumer.class); + + @Reference + private ResourceResolverFactory resolverFactory; + + @Override + public JobResult process(final Job job) { + String path = (String) job.getProperty("path"); + LOG.info("Processing job for path: {}", path); + + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "event-handler-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return JobResult.FAILED; + } + + // === EXISTING BUSINESS LOGIC FROM handleEvent() GOES HERE === + + return JobResult.OK; + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + return JobResult.FAILED; + } catch (Exception e) { + LOG.error("Error processing job", e); + return JobResult.FAILED; + } + } +} +``` + +**Key rules for JobConsumer:** +- Job topic MUST match the topic used in the EventHandler +- Move ALL business logic from `handleEvent()` into `process(Job)` +- Move business-logic `@Reference` fields here (e.g., `ResourceResolverFactory`) +- Extract job properties via `job.getProperty("key")` or `(Type) job.getProperty("key")` +- Return `JobResult.OK` on success, `JobResult.FAILED` on failure +- Resolver + logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) + +## B3: Add TopologyEventListener for replication handlers (if applicable) + +If the event handler processes replication events and should only run on one instance in a cluster, add `TopologyEventListener`: + +```java +@Component(service = { EventHandler.class, TopologyEventListener.class }, immediate = true, property = { + EventConstants.EVENT_TOPIC + "=" + ReplicationEvent.EVENT_TOPIC, + EventConstants.EVENT_FILTER + "(" + ReplicationAction.PROPERTY_TYPE + "=ACTIVATE)" +}) +public class PublishDateEventHandler implements EventHandler, TopologyEventListener { + + private volatile boolean isLeader = false; + + @Override + public void handleTopologyEvent(TopologyEvent event) { + if (event.getType() == TopologyEvent.Type.TOPOLOGY_CHANGED + || event.getType() == TopologyEvent.Type.TOPOLOGY_INIT) { + isLeader = event.getNewView().getLocalInstance().isLeader(); + } + } + + @Override + public void handleEvent(Event event) { + if (isLeader) { + Map jobProperties = new HashMap<>(); + jobProperties.put("path", ReplicationEvent.fromEvent(event).getReplicationAction().getPath()); + jobManager.addJob(JOB_TOPIC, jobProperties); + } + } +} +``` + +**Only add this if:** +- The handler processes replication events (`ReplicationEvent.EVENT_TOPIC`) +- The handler should only fire on one node in the cluster +- The original code had leader-check logic or similar singleton behavior + +## B4: Update imports + +**EventHandler class — Remove:** +```java +import org.apache.felix.scr.annotations.*; +import org.apache.sling.api.resource.ResourceResolverFactory; // moves to JobConsumer +``` + +**EventHandler class — Add (if not already present):** +```java +import org.osgi.service.event.Event; +import org.osgi.service.event.EventConstants; +import org.osgi.service.event.EventHandler; +import org.osgi.framework.Constants; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.apache.sling.event.jobs.JobManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.HashMap; +import java.util.Map; +``` + +**If using TopologyEventListener, also add:** +```java +import org.apache.sling.discovery.TopologyEvent; +import org.apache.sling.discovery.TopologyEventListener; +``` + +**JobConsumer class — Remove:** +```java +import org.apache.felix.scr.annotations.*; +``` + +**JobConsumer class — Add:** +```java +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Collections; +``` + +--- + +# Validation + +## EventHandler Checklist + +- [ ] SCR→DS per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) +- [ ] `@Component(service = EventHandler.class, property = { EVENT_TOPIC... })` is present +- [ ] No business logic in `handleEvent()` — only event data extraction + job creation +- [ ] No `ResourceResolver`, `Session`, or `Node` operations in `handleEvent()` +- [ ] `@Reference JobManager` is present +- [ ] `jobManager.addJob(TOPIC, properties)` is called +- [ ] Event filtering preserves original filter logic (paths, types, property names) +- [ ] Logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) +- [ ] Replication handlers implement `TopologyEventListener` and check `isLeader` (if applicable) + +## JobConsumer Checklist + +- [ ] Implements `JobConsumer` +- [ ] Has `@Component(service = JobConsumer.class, property = { PROPERTY_TOPICS... })` +- [ ] Job topic matches the EventHandler topic +- [ ] Business logic from original `handleEvent()` is preserved +- [ ] Returns `JobResult.OK` or `JobResult.FAILED` +- [ ] Resolver + logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) +- [ ] Code compiles: `mvn clean compile` diff --git a/skills/aem/cloud-service/skills/best-practices/references/event-migration.md b/skills/aem/cloud-service/skills/best-practices/references/event-migration.md new file mode 100644 index 00000000..4efa4ae7 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/event-migration.md @@ -0,0 +1,149 @@ +# Event Migration Pattern + +Migrates two legacy styles into one Cloud Service–compatible pattern — **lightweight OSGi `EventHandler` + Sling `JobConsumer`**: + +1. **JCR observation (`eventListener` / BPA):** `javax.jcr.observation.EventListener` listening to **repository** changes via `onEvent(EventIterator)`. +2. **OSGi Event Admin (`eventHandler` / BPA):** `org.osgi.service.event.EventHandler` with **`handleEvent`** — often already OSGi, but must not hold heavy JCR/resolver work inline. + +**Before path files:** [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) (SCR→DS, resolver, logging). + +**Two paths based on source pattern:** +- **Path A (JCR observation → OSGi):** Source uses **`javax.jcr.observation.EventListener`** — replace JCR observation with OSGi topics + offload work to JobConsumer. +- **Path B (OSGi `EventHandler` with inline logic):** Source already uses **`org.osgi.service.event.EventHandler`** but runs resolver/session/node code inside **`handleEvent()`** — offload to JobConsumer only. + +--- + +## Quick Examples + +### Path A Example (JCR EventListener) + +**Before:** +```java +public class MyListener implements EventListener { + @Override + public void onEvent(EventIterator events) { + while (events.hasNext()) { + Event event = events.nextEvent(); + ResourceResolver resolver = factory.getAdministrativeResourceResolver(null); + // business logic + } + } +} +``` + +**After (Split into 2 classes):** + +**EventHandler.java:** +```java +@Component(service = EventHandler.class, property = { + EventConstants.EVENT_TOPIC + "=org/apache/sling/api/resource/Resource/CHANGED" +}) +public class MyEventHandler implements EventHandler { + @Reference private JobManager jobManager; + + @Override + public void handleEvent(Event event) { + Map props = Map.of("path", event.getProperty("path")); + jobManager.addJob("my/job/topic", props); + } +} +``` + +**JobConsumer.java:** +```java +@Component(service = JobConsumer.class, property = { + JobConsumer.PROPERTY_TOPICS + "=my/job/topic" +}) +public class MyJobConsumer implements JobConsumer { + @Override + public JobResult process(Job job) { + // business logic from onEvent() + return JobResult.OK; + } +} +``` + +### Path B Example (OSGi EventHandler with Inline Logic) + +**Before:** +```java +@Component(service = EventHandler.class) +public class MyHandler implements EventHandler { + @Override + public void handleEvent(Event event) { + ResourceResolver resolver = factory.getServiceResourceResolver(...); + // business logic directly in handler + resolver.commit(); + } +} +``` + +**After (Split into 2 classes):** + +**EventHandler.java:** +```java +@Component(service = EventHandler.class) +public class MyHandler implements EventHandler { + @Reference private JobManager jobManager; + + @Override + public void handleEvent(Event event) { + jobManager.addJob("my/job/topic", Map.of("path", event.getProperty("path"))); + } +} +``` + +**JobConsumer.java:** +```java +@Component(service = JobConsumer.class, property = { + JobConsumer.PROPERTY_TOPICS + "=my/job/topic" +}) +public class MyJobConsumer implements JobConsumer { + @Override + public JobResult process(Job job) { + // business logic from handleEvent() + return JobResult.OK; + } +} +``` + +--- + +## Classification + +**Classify BEFORE making any changes.** + +### Use Path A when ALL of these are true: +- Class implements `javax.jcr.observation.EventListener` +- Has `onEvent(EventIterator)` method +- Uses `import javax.jcr.observation.*` + +**If Path A → read `resources/event-migration-path-a.md` and follow its steps.** + +### Use Path B when ANY of these are true: +- Class already implements `org.osgi.service.event.EventHandler` +- Has `handleEvent(Event)` with inline business logic (ResourceResolver, JCR Session, Node operations) +- Replication event handler using `ReplicationEvent.EVENT_TOPIC` with inline processing +- Workflow event handler using `WorkflowEvent` with Session/Node operations in handler + +**If Path B → read `resources/event-migration-path-b.md` and follow its steps.** + +### Already compliant — skip migration: +- Class implements `EventHandler` and `handleEvent()` ONLY calls `jobManager.addJob()` — already uses the correct pattern + +## Event-Specific Rules + +- **CLASSIFY FIRST** — determine Path A or Path B before making any changes +- **DO** convert JCR `EventListener` to OSGi `EventHandler` (Path A only) +- **DO** offload ALL business logic from `handleEvent()` / `onEvent()` to a `JobConsumer` +- **DO** keep `handleEvent()` lightweight — only extract event data and create a job +- **DO** map JCR event types to OSGi resource event topics (Path A only) +- **DO** preserve event filtering logic (paths, property names, event types) +- **DO** add `TopologyEventListener` for replication handlers that should only run on leader node +- **DO** distribute `@Reference` fields: infrastructure services (e.g., `JobManager`) stay in EventHandler, business logic services (e.g., `ResourceResolverFactory`) move to JobConsumer +- **DO NOT** put ResourceResolver, JCR Session, or Node operations in the EventHandler +- **DO NOT** change the business logic — move it as-is to the JobConsumer + +## IMPORTANT + +**Read ONLY the path file that matches your classification. Do NOT read both.** diff --git a/skills/aem/cloud-service/skills/best-practices/references/replication.md b/skills/aem/cloud-service/skills/best-practices/references/replication.md new file mode 100644 index 00000000..d4536dc1 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/replication.md @@ -0,0 +1,387 @@ +# Replication API Migration Pattern + +Migrates legacy replication code to Cloud Service compatible pattern: **Sling Distribution API** instead of Sling Replication / CQ Replication APIs. + +**Before transformation steps:** [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md). + +**Source patterns handled:** +- Sling Replication Agent API: `ReplicationAgent`, `ReplicationAgentConfiguration`, `ReplicationAgentException`, `ReplicationResult`, `SimpleReplicationAgent` — `agent.replicate(resolver, ReplicationActionType.ADD, path)` +- CQ Replication API: `com.day.cq.replication.Replicator`, `ReplicationAction` — `replicator.replicate(resolver, new ReplicationAction(ReplicationActionType.ACTIVATE, path))` + +**Target pattern:** +- Sling Distribution API: `DistributionAgent`, `DistributionRequest`, `SimpleDistributionRequest` +- `distributionAgent.execute(new SimpleDistributionRequest(DistributionRequestType.ADD, path))` +- Uses `getServiceResourceResolver()` with SUBSERVICE; resolver lifecycle and logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) + +## Classification + +Identify which source pattern the file uses: +- **Sling Replication Agent:** Has `ReplicationAgent`, `ReplicationAgentException`, `ReplicationResult`, `agent.replicate(resolver, ReplicationActionType.*, path)` +- **CQ Replicator:** Has `com.day.cq.replication.Replicator`, `ReplicationAction`, `replicator.replicate(resolver, action)` + +If the file already uses `DistributionAgent` and `DistributionRequest`/`SimpleDistributionRequest`, it may not need migration — verify and skip if already compliant. + +## Pattern-Specific Rules + +- **DO** replace ReplicationAgent/Replicator with DistributionAgent +- **DO** replace ReplicationAction/ReplicationResult with DistributionRequest/SimpleDistributionRequest +- **DO** map ReplicationActionType to DistributionRequestType (e.g., ACTIVATE → ADD) +- **DO** use `@Reference(target = "(name=agent-name)")` to target the specific Distribution Agent +- **DO NOT** use administrative resolver or console logging — follow [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) + +--- + +## Complete Example: Before and After + +### Example 1: CQ Replicator → Sling Distribution Agent + +#### Before (Legacy CQ Replicator) + +```java +package com.example.replication; + +import org.apache.felix.scr.annotations.Component; +import org.apache.felix.scr.annotations.Reference; +import org.apache.felix.scr.annotations.Service; +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import com.day.cq.replication.ReplicationAction; +import com.day.cq.replication.Replicator; +import com.day.cq.replication.ReplicationActionType; + +import java.util.HashMap; +import java.util.Map; + +@Component(immediate = true) +@Service +public class PropertyNodeReplicationService { + + @Reference + private Replicator replicator; + + @Reference + private ResourceResolverFactory resourceResolverFactory; + + public void replicatePropertyNode(String propertyNodePath) { + ResourceResolver resolver = null; + try { + Map authInfo = new HashMap<>(); + authInfo.put(ResourceResolverFactory.USER, "replication-service"); + authInfo.put(ResourceResolverFactory.PASSWORD, "password"); + + resolver = resourceResolverFactory.getAdministrativeResourceResolver(authInfo); + + if (resolver != null) { + ReplicationAction action = new ReplicationAction(ReplicationActionType.ACTIVATE, propertyNodePath); + replicator.replicate(resolver, action); + System.out.println("Property Node Replication successful for path: " + propertyNodePath); + } + } catch (Exception e) { + System.err.println("Property Node Replication failed for path: " + propertyNodePath); + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(); + } finally { + if (resolver != null && resolver.isLive()) { + resolver.close(); + } + } + } +} +``` + +#### After (Cloud Service Compatible) + +```java +package com.example.replication; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.distribution.agent.api.DistributionAgent; +import org.apache.sling.distribution.agent.api.DistributionAgentException; +import org.apache.sling.distribution.agent.api.DistributionRequest; +import org.apache.sling.distribution.agent.api.SimpleDistributionRequest; +import org.apache.sling.distribution.agent.api.DistributionRequestType; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; + +@Component(service = PropertyNodeReplicationService.class) +public class PropertyNodeReplicationService { + + private static final Logger LOG = LoggerFactory.getLogger(PropertyNodeReplicationService.class); + + @Reference(target = "(name=myPropertyDistributionAgent)") + private DistributionAgent distributionAgent; + + @Reference + private ResourceResolverFactory resolverFactory; + + public void replicatePropertyNode(String propertyNodePath) { + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "property-node-distribution-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return; + } + + DistributionRequest request = new SimpleDistributionRequest( + DistributionRequestType.ADD, + propertyNodePath + ); + distributionAgent.execute(request); + LOG.info("Property Node Distribution successful for path: {}", propertyNodePath); + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + } catch (DistributionAgentException e) { + LOG.error("Distribution failed for path: {}", propertyNodePath, e); + } catch (Exception e) { + LOG.error("Error during distribution", e); + } + } +} +``` + +### Example 2: Sling Replication Agent → Sling Distribution Agent + +#### Before (Legacy Sling Replication Agent) + +```java +package com.example.replication; + +import org.apache.felix.scr.annotations.Component; +import org.apache.felix.scr.annotations.Reference; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.replication.agent.api.ReplicationAgent; +import org.apache.sling.replication.agent.api.ReplicationAgentException; +import org.apache.sling.replication.agent.api.ReplicationResult; +import org.apache.sling.replication.agent.api.ReplicationActionType; + +import java.util.HashMap; +import java.util.Map; + +@Component(immediate = true) +public class ContentReplicationService { + + @Reference + private ReplicationAgent agent; + + @Reference + private ResourceResolverFactory resourceResolverFactory; + + public void replicateContent(String contentPath) { + try { + ResourceResolver resolver = resourceResolverFactory.getAdministrativeResourceResolver(null); + if (resolver != null) { + ReplicationResult result = agent.replicate(resolver, ReplicationActionType.ADD, contentPath); + if (result.isSuccessful()) { + System.out.println("Forward Replication successful for path: " + contentPath); + } else { + System.err.println("Forward Replication failed for path: " + contentPath); + } + resolver.close(); + } + } catch (ReplicationAgentException e) { + System.err.println("ReplicationAgentException occurred: " + e.getMessage()); + e.printStackTrace(); + } catch (Exception e) { + System.err.println("Exception occurred: " + e.getMessage()); + e.printStackTrace(); + } + } +} +``` + +#### After (Cloud Service Compatible) + +```java +package com.example.replication; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.distribution.agent.api.DistributionAgent; +import org.apache.sling.distribution.agent.api.DistributionAgentException; +import org.apache.sling.distribution.agent.api.DistributionRequest; +import org.apache.sling.distribution.agent.api.SimpleDistributionRequest; +import org.apache.sling.distribution.agent.api.DistributionRequestType; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; + +@Component(service = ContentReplicationService.class) +public class ContentReplicationService { + + private static final Logger LOG = LoggerFactory.getLogger(ContentReplicationService.class); + + @Reference(target = "(name=my-distribution-agent)") + private DistributionAgent distributionAgent; + + @Reference + private ResourceResolverFactory resolverFactory; + + public void replicateContent(String contentPath) { + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "content-distribution-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return; + } + + DistributionRequest request = new SimpleDistributionRequest( + DistributionRequestType.ADD, + contentPath + ); + distributionAgent.execute(request); + LOG.info("Forward Distribution successful for path: {}", contentPath); + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + } catch (DistributionAgentException e) { + LOG.error("Distribution failed for path: {}", contentPath, e); + } catch (Exception e) { + LOG.error("Error during distribution", e); + } + } +} +``` + +**Key Changes:** +- ✅ Replaced `Replicator`/`ReplicationAgent` → `DistributionAgent` +- ✅ Replaced `ReplicationAction`/`ReplicationResult` → `DistributionRequest`/`SimpleDistributionRequest` +- ✅ Mapped `ReplicationActionType.ACTIVATE` → `DistributionRequestType.ADD` +- ✅ Added `@Reference(target = "(name=agent-name)")` for agent selection +- ✅ Replaced `getAdministrativeResourceResolver()` → `getServiceResourceResolver()` with SUBSERVICE +- ✅ Removed USER/PASSWORD from authInfo (Cloud Service uses SUBSERVICE only) +- ✅ Replaced `System.out/err` → SLF4J Logger +- ✅ Used try-with-resources for ResourceResolver +- ✅ `DistributionAgent.execute()` no longer requires ResourceResolver parameter (agent uses its own service user) + +--- + +# Transformation Steps + +## P0: Pattern prerequisites + +Read [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) and apply SCR→DS and ResourceResolver/logging **before** replication-specific steps below. + +## P1: Replace ReplicationAgent/Replicator with DistributionAgent + +**For Sling Replication Agent (ReplicationAgent):** + +```java +// BEFORE (Sling Replication Agent) +@Reference +private ReplicationAgent agent; + +ReplicationResult result = agent.replicate(resolver, ReplicationActionType.ADD, propertyNodePath); +if (result.isSuccessful()) { + System.out.println("Property Node Replication successful for path: " + propertyNodePath); +} else { + System.out.println("Property Node Replication failed for path: " + propertyNodePath); +} + +// AFTER (Sling Distribution Agent) +@Reference(target = "(name=myPropertyDistributionAgent)") +private DistributionAgent distributionAgent; + +DistributionRequest request = new SimpleDistributionRequest(DistributionRequestType.ADD, propertyNodePath); +distributionAgent.execute(request); +LOG.info("Property Node Distribution successful for path: {}", propertyNodePath); +``` + +**For CQ Replicator:** + +```java +// BEFORE (CQ Replicator) +@Reference +private Replicator replicator; + +ReplicationAction action = new ReplicationAction(ReplicationActionType.ACTIVATE, contentPath); +replicator.replicate(resolver, action); +System.out.println("Forward Replication successful for path: " + contentPath); + +// AFTER (Sling Distribution Agent) +@Reference(target = "(name=my-distribution-agent)") +private DistributionAgent distributionAgent; + +DistributionRequest request = new SimpleDistributionRequest(DistributionRequestType.ADD, contentPath); +distributionAgent.execute(request); +LOG.info("Forward Distribution successful for path: {}", contentPath); +``` + +**ReplicationActionType to DistributionRequestType mapping:** + +| ReplicationActionType | DistributionRequestType | +|----------------------|-------------------------| +| `ACTIVATE` | `ADD` | +| `DEACTIVATE` | `DELETE` | +| `ADD` | `ADD` | +| `DELETE` | `DELETE` | + +**Note:** `DistributionAgent.execute(request)` does not require a ResourceResolver parameter — the agent uses its own service user. If the resolver is needed for other logic, retain it per [resource-resolver-logging.md](resource-resolver-logging.md) (try-with-resources, service user). + +## P2: Update imports + +**Remove (Sling Replication Agent):** +```java +import org.apache.sling.replication.agent.api.ReplicationAgent; +import org.apache.sling.replication.agent.api.ReplicationAgentConfiguration; +import org.apache.sling.replication.agent.api.ReplicationAgentException; +import org.apache.sling.replication.agent.api.ReplicationResult; +import org.apache.sling.replication.agent.impl.SimpleReplicationAgent; +``` + +**Remove (CQ Replicator):** +```java +import com.day.cq.replication.ReplicationAction; +import com.day.cq.replication.Replicator; +``` + +**Remove (after SCR→DS migration per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md)):** +```java +import org.apache.felix.scr.annotations.*; // must be gone when done +``` + +**Add:** +```java +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.distribution.agent.api.DistributionAgent; +import org.apache.sling.distribution.agent.api.DistributionAgentException; +import org.apache.sling.distribution.agent.api.DistributionRequest; +import org.apache.sling.distribution.agent.api.SimpleDistributionRequest; +import org.apache.sling.distribution.agent.api.DistributionRequestType; +import org.osgi.framework.Constants; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Collections; +import java.util.Map; +``` + +--- + +# Validation + +## Replication/Distribution Checklist + +- [ ] No `ReplicationAgent`, `Replicator`, `ReplicationAction`, or `ReplicationResult` remains +- [ ] Uses `DistributionAgent` with `@Reference(target = "(name=agent-name)")` +- [ ] Uses `DistributionRequest` / `SimpleDistributionRequest` with `DistributionRequestType` +- [ ] [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) satisfied (SCR→DS, resolver/logging, auth maps) +- [ ] `scheduler.concurrent=false` is set (if using scheduler) +- [ ] Code compiles: `mvn clean compile` diff --git a/skills/aem/cloud-service/skills/best-practices/references/resource-change-listener.md b/skills/aem/cloud-service/skills/best-practices/references/resource-change-listener.md new file mode 100644 index 00000000..521a83c1 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/resource-change-listener.md @@ -0,0 +1,695 @@ +# Resource Change Listener / Event Listener Migration Pattern + +Migrates legacy JCR observation listeners and OSGi event handlers with inline business logic to Cloud Service compatible pattern: **lightweight EventHandler + Sling JobConsumer**. + +**Source patterns handled:** +- JCR `javax.jcr.observation.EventListener` with `onEvent(EventIterator)` +- OSGi `org.osgi.service.event.EventHandler` with inline business logic in `handleEvent(Event)` + +**Target pattern:** +- OSGi `EventHandler` (lightweight — receives event, creates Sling Job) +- Sling `JobConsumer` (handles business logic asynchronously) + +**Before transformation steps:** [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md). + +## Classification + +No sub-paths — all source patterns transform to the same target (EventHandler + JobConsumer split). + +Identify which source pattern the file uses: +- **JCR EventListener:** Has `implements EventListener`, `import javax.jcr.observation.*`, `onEvent(EventIterator)` +- **OSGi EventHandler with inline logic:** Has `implements EventHandler`, `handleEvent(Event)`, and business logic (ResourceResolver, JCR Session, etc.) directly inside `handleEvent()` + +If the file already has `implements EventHandler` and already offloads to a Sling Job (i.e., `handleEvent()` only calls `jobManager.addJob()`), it may not need migration — verify and skip if already compliant. + +## Pattern-Specific Rules + +- **DO** convert JCR `EventListener` to OSGi `EventHandler` +- **DO** offload ALL business logic from `handleEvent()` to a `JobConsumer` +- **DO** keep `handleEvent()` lightweight — it should only extract event data and create a job +- **DO** map JCR event types to OSGi resource event topics +- **DO** preserve event filtering logic (paths, property names, event types) +- **DO NOT** put ResourceResolver, JCR Session, or Node operations in the EventHandler +- **DO NOT** change the business logic — move it as-is to the JobConsumer + +--- + +## Complete Example: Before and After + +### Example 1: JCR EventListener → OSGi EventHandler + JobConsumer + +#### Before (Legacy JCR EventListener) + +```java +package com.example.listeners; + +import org.apache.felix.scr.annotations.Component; +import org.apache.felix.scr.annotations.Reference; +import org.apache.felix.scr.annotations.Service; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; + +import javax.jcr.observation.Event; +import javax.jcr.observation.EventIterator; +import javax.jcr.observation.EventListener; +import java.util.Calendar; + +@Component(immediate = true) +@Service +public class ACLModificationListener implements EventListener { + + @Reference + private ResourceResolverFactory resourceResolverFactory; + + @Override + public void onEvent(EventIterator events) { + try { + ResourceResolver resolver = resourceResolverFactory.getAdministrativeResourceResolver(null); + if (resolver != null) { + while (events.hasNext()) { + Event event = events.nextEvent(); + if (event.getType() == Event.PROPERTY_CHANGED) { + String path = event.getPath(); + if (path.contains("/rep:policy")) { + // Business logic: update replication date + System.out.println("ACL modified at: " + path); + // ... update replication date logic ... + } + } + } + resolver.close(); + } + } catch (Exception e) { + System.err.println("Error handling ACL modification: " + e.getMessage()); + e.printStackTrace(); + } + } +} +``` + +#### After (Cloud Service Compatible) + +**File 1: ACLModificationEventHandler.java** (EventHandler) + +```java +package com.example.listeners; + +import org.apache.sling.api.resource.Resource; +import org.osgi.framework.Constants; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.event.Event; +import org.osgi.service.event.EventConstants; +import org.osgi.service.event.EventHandler; +import org.apache.sling.event.jobs.JobManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + +@Component( + service = EventHandler.class, + immediate = true, + property = { + Constants.SERVICE_DESCRIPTION + "=Event Handler for ACL modifications", + EventConstants.EVENT_TOPIC + "=org/apache/sling/api/resource/Resource/CHANGED", + EventConstants.EVENT_FILTER + "=(path=/*/rep:policy)" + } +) +public class ACLModificationEventHandler implements EventHandler { + + private static final Logger LOG = LoggerFactory.getLogger(ACLModificationEventHandler.class); + private static final String JOB_TOPIC = "com/example/acl/modification/job"; + + @Reference + private JobManager jobManager; + + @Override + public void handleEvent(Event event) { + try { + String path = (String) event.getProperty("path"); + LOG.debug("Resource event: {} for path: {}", event.getTopic(), path); + + Map jobProperties = new HashMap<>(); + jobProperties.put("path", path); + jobManager.addJob(JOB_TOPIC, jobProperties); + } catch (Exception e) { + LOG.error("Error handling event", e); + } + } +} +``` + +**File 2: ACLModificationJobConsumer.java** (JobConsumer) + +```java +package com.example.listeners; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.apache.sling.event.jobs.consumer.JobResult; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; + +@Component( + service = JobConsumer.class, + immediate = true, + property = { + JobConsumer.PROPERTY_TOPICS + "=" + "com/example/acl/modification/job" + } +) +public class ACLModificationJobConsumer implements JobConsumer { + + private static final Logger LOG = LoggerFactory.getLogger(ACLModificationJobConsumer.class); + + @Reference + private ResourceResolverFactory resolverFactory; + + @Override + public JobResult process(final Job job) { + String path = (String) job.getProperty("path"); + LOG.info("Processing ACL modification job for path: {}", path); + + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "event-handler-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return JobResult.FAILED; + } + + // Business logic: update replication date + LOG.info("ACL modified at: {}", path); + // ... existing business logic from onEvent() goes here ... + + return JobResult.OK; + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + return JobResult.FAILED; + } catch (Exception e) { + LOG.error("Error processing job", e); + return JobResult.FAILED; + } + } +} +``` + +### Example 2: OSGi EventHandler with Inline Logic → Lightweight EventHandler + JobConsumer + +#### Before (Legacy OSGi EventHandler with Inline Logic) + +```java +package com.example.listeners; + +import org.apache.felix.scr.annotations.Component; +import org.apache.felix.scr.annotations.Reference; +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.osgi.service.event.Event; +import org.osgi.service.event.EventConstants; +import org.osgi.service.event.EventHandler; + +import java.util.Calendar; +import java.util.Collections; + +@Component( + immediate = true, + property = { + EventConstants.EVENT_TOPIC + "=org/apache/sling/api/resource/Resource/CHANGED" + } +) +public class ReplicationDateEventHandler implements EventHandler { + + @Reference + private ResourceResolverFactory resourceResolverFactory; + + @Override + public void handleEvent(Event event) { + String path = (String) event.getProperty("path"); + try { + ResourceResolver resolver = resourceResolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "replication-service")); + + if (resolver != null) { + // Business logic: update replication date + Resource resource = resolver.getResource(path + "/jcr:content"); + if (resource != null) { + ModifiableValueMap map = resource.adaptTo(ModifiableValueMap.class); + map.put("cq:lastReplicated", Calendar.getInstance()); + resolver.commit(); + } + resolver.close(); + } + } catch (Exception e) { + System.err.println("Error updating replication date: " + e.getMessage()); + e.printStackTrace(); + } + } +} +``` + +#### After (Cloud Service Compatible) + +**File 1: ReplicationDateEventHandler.java** (Lightweight EventHandler) + +```java +package com.example.listeners; + +import org.osgi.framework.Constants; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.event.Event; +import org.osgi.service.event.EventConstants; +import org.osgi.service.event.EventHandler; +import org.apache.sling.event.jobs.JobManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + +@Component( + service = EventHandler.class, + immediate = true, + property = { + Constants.SERVICE_DESCRIPTION + "=Event Handler for replication date updates", + EventConstants.EVENT_TOPIC + "=org/apache/sling/api/resource/Resource/CHANGED" + } +) +public class ReplicationDateEventHandler implements EventHandler { + + private static final Logger LOG = LoggerFactory.getLogger(ReplicationDateEventHandler.class); + private static final String JOB_TOPIC = "com/example/replication/date/update"; + + @Reference + private JobManager jobManager; + + @Override + public void handleEvent(Event event) { + try { + String path = (String) event.getProperty("path"); + LOG.debug("Resource event: {} for path: {}", event.getTopic(), path); + + Map jobProperties = new HashMap<>(); + jobProperties.put("path", path); + jobManager.addJob(JOB_TOPIC, jobProperties); + } catch (Exception e) { + LOG.error("Error handling event", e); + } + } +} +``` + +**File 2: ReplicationDateJobConsumer.java** (JobConsumer) + +```java +package com.example.listeners; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ModifiableValueMap; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.api.resource.PersistenceException; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.apache.sling.event.jobs.consumer.JobResult; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Calendar; +import java.util.Collections; + +@Component( + service = JobConsumer.class, + immediate = true, + property = { + JobConsumer.PROPERTY_TOPICS + "=" + "com/example/replication/date/update" + } +) +public class ReplicationDateJobConsumer implements JobConsumer { + + private static final Logger LOG = LoggerFactory.getLogger(ReplicationDateJobConsumer.class); + + @Reference + private ResourceResolverFactory resolverFactory; + + @Override + public JobResult process(final Job job) { + String path = (String) job.getProperty("path"); + LOG.info("Processing replication date update job for path: {}", path); + + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "event-handler-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return JobResult.FAILED; + } + + // Business logic: update replication date + Resource resource = resolver.getResource(path + "/jcr:content"); + if (resource != null) { + ModifiableValueMap map = resource.adaptTo(ModifiableValueMap.class); + map.put("cq:lastReplicated", Calendar.getInstance()); + resolver.commit(); + LOG.debug("Updated replication date for: {}", path); + } + + return JobResult.OK; + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + return JobResult.FAILED; + } catch (PersistenceException e) { + LOG.error("Failed to commit changes", e); + return JobResult.FAILED; + } catch (Exception e) { + LOG.error("Error processing job", e); + return JobResult.FAILED; + } + } +} +``` + +**Key Changes:** +- ✅ Converted JCR `EventListener` → OSGi `EventHandler` (Example 1) +- ✅ Split EventHandler + JobConsumer (both examples) +- ✅ EventHandler is lightweight — only creates jobs +- ✅ Business logic moved to JobConsumer `process()` method +- ✅ Replaced `getAdministrativeResourceResolver()` → `getServiceResourceResolver()` with SUBSERVICE +- ✅ Replaced `System.out/err` → SLF4J Logger +- ✅ Event topics correctly mapped (JCR → OSGi) +- ✅ Preserved business logic unchanged + +--- + +# Transformation Steps + +## Pattern prerequisites + +Read [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) before the steps below. + +## R1: Convert JCR EventListener to OSGi EventHandler (if applicable) + +**Skip this step if the file already implements `EventHandler`.** + +If the file implements JCR `EventListener`, convert to OSGi `EventHandler`: + +```java +// BEFORE (JCR Observation) +import javax.jcr.observation.Event; +import javax.jcr.observation.EventListener; +import javax.jcr.observation.EventIterator; + +public class ACLModificationListener implements EventListener { + @Override + public void onEvent(EventIterator events) { + while (events.hasNext()) { + Event event = events.nextEvent(); + if (event.getType() == Event.PROPERTY_CHANGED) { + String path = event.getPath(); + // business logic... + } + } + } +} + +// AFTER (OSGi EventHandler — lightweight) +import org.osgi.service.event.Event; +import org.osgi.service.event.EventHandler; +import org.osgi.service.event.EventConstants; + +@Component(service = EventHandler.class, immediate = true, property = { + Constants.SERVICE_DESCRIPTION + "=Event Handler for ACL modifications", + EventConstants.EVENT_TOPIC + "=org/apache/sling/api/resource/Resource/CHANGED", + EventConstants.EVENT_FILTER + "=(path=/*/rep:policy)" +}) +public class ACLModificationEventHandler implements EventHandler { + @Reference + private JobManager jobManager; + + @Override + public void handleEvent(Event event) { + String path = (String) event.getProperty("path"); + // offload to job (business logic goes to JobConsumer) + } +} +``` + +**JCR event type to OSGi resource topic mapping:** + +| JCR Event Type | OSGi Resource Event Topic | +|---------------|--------------------------| +| `Event.NODE_ADDED` | `org/apache/sling/api/resource/Resource/ADDED` | +| `Event.NODE_REMOVED` | `org/apache/sling/api/resource/Resource/REMOVED` | +| `Event.PROPERTY_CHANGED` | `org/apache/sling/api/resource/Resource/CHANGED` | +| `Event.PROPERTY_ADDED` | `org/apache/sling/api/resource/Resource/CHANGED` | +| `Event.PROPERTY_REMOVED` | `org/apache/sling/api/resource/Resource/CHANGED` | + +**JCR event data to OSGi event property mapping:** + +| JCR Event API | OSGi Event API | +|--------------|----------------| +| `event.getPath()` | `(String) event.getProperty("path")` | +| `event.getIdentifier()` | `(String) event.getProperty("resourceType")` | +| `event.getType()` | Determined by topic (no need to check type) | + +## R2: Make EventHandler lightweight — offload to Sling Job + +The `handleEvent()` method should ONLY: +1. Extract event data (path, properties, etc.) +2. Create a Sling Job with those properties +3. Return immediately + +**Move ALL business logic out of handleEvent():** + +```java +// BEFORE (inline business logic in handler) +@Override +public void handleEvent(Event event) { + String path = (String) event.getProperty("path"); + try (ResourceResolver resolver = resourceResolverFactory.getServiceResourceResolver(AUTH_INFO)) { + Resource resource = resolver.getResource(path + "/jcr:content"); + if (resource != null) { + ModifiableValueMap map = resource.adaptTo(ModifiableValueMap.class); + map.put("cq:lastReplicated", Calendar.getInstance()); + resolver.commit(); + } + } catch (LoginException | PersistenceException ex) { + LOG.error("Error", ex); + } +} + +// AFTER (lightweight — just creates a job) +@Override +public void handleEvent(Event event) { + try { + String path = ReplicationEvent.fromEvent(event).getReplicationAction().getPath(); + LOG.debug("Resource event: {} for path: {}", event.getTopic(), path); + + Map jobProperties = new HashMap<>(); + jobProperties.put("path", path); + jobManager.addJob(JOB_TOPIC, jobProperties); + } catch (Exception e) { + LOG.error("Error handling event", e); + } +} +``` + +**Add JobManager injection:** +```java +@Reference +private JobManager jobManager; + +private static final String JOB_TOPIC = "com/example/acl/modification/job"; +``` + +**Remove ResourceResolverFactory from EventHandler** — it moves to the JobConsumer. + +## R3: Create the JobConsumer class + +Create a NEW class that implements `JobConsumer` to handle the business logic: + +```java +package com.example.listeners; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Collections; + +@Component( + service = JobConsumer.class, + immediate = true, + property = { + JobConsumer.PROPERTY_TOPICS + "=" + "com/example/acl/modification/job" + } +) +public class ACLModificationJobConsumer implements JobConsumer { + + private static final Logger LOG = LoggerFactory.getLogger(ACLModificationJobConsumer.class); + + @Reference + private ResourceResolverFactory resolverFactory; + + @Override + public JobResult process(final Job job) { + String path = (String) job.getProperty("path"); + LOG.info("Processing job for path: {}", path); + + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "event-handler-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return JobResult.FAILED; + } + + // === EXISTING BUSINESS LOGIC FROM onEvent()/handleEvent() GOES HERE === + + return JobResult.OK; + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + return JobResult.FAILED; + } catch (Exception e) { + LOG.error("Error processing job", e); + return JobResult.FAILED; + } + } +} +``` + +**Key rules for JobConsumer:** +- Job topic MUST match the topic used in the EventHandler +- Move ALL business logic from `onEvent()`/`handleEvent()` into `process(Job)` +- Move business-logic `@Reference` fields here (e.g., `ResourceResolverFactory`) +- Extract job properties via `job.getProperty("key")` or `(Type) job.getProperty("key")` +- Return `JobResult.OK` on success, `JobResult.FAILED` on failure +- Resolver + logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) + +## R4: Add TopologyEventListener for leader election (if applicable) + +If the event handler should only run on one instance in a cluster (e.g., replication handlers), add `TopologyEventListener`: + +```java +@Component(service = { EventHandler.class, TopologyEventListener.class }, immediate = true, property = { + EventConstants.EVENT_TOPIC + "=" + ReplicationEvent.EVENT_TOPIC +}) +public class PublishDateEventHandler implements EventHandler, TopologyEventListener { + + private volatile boolean isLeader = false; + + @Override + public void handleTopologyEvent(TopologyEvent event) { + if (event.getType() == TopologyEvent.Type.TOPOLOGY_CHANGED + || event.getType() == TopologyEvent.Type.TOPOLOGY_INIT) { + isLeader = event.getNewView().getLocalInstance().isLeader(); + } + } + + @Override + public void handleEvent(Event event) { + if (isLeader) { + // create job... + } + } +} +``` + +**Only add this if:** +- The handler processes replication events +- The handler should only fire on one node in the cluster +- The original code had leader-check logic + +## R5: Update imports + +**EventHandler class — Remove:** +```java +import javax.jcr.observation.Event; +import javax.jcr.observation.EventListener; +import javax.jcr.observation.EventIterator; +import org.apache.felix.scr.annotations.*; +import org.apache.sling.api.resource.ResourceResolverFactory; // moves to JobConsumer +``` + +**EventHandler class — Add:** +```java +import org.osgi.service.event.Event; +import org.osgi.service.event.EventConstants; +import org.osgi.service.event.EventHandler; +import org.osgi.framework.Constants; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.apache.sling.event.jobs.JobManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.HashMap; +import java.util.Map; +``` + +**If using TopologyEventListener, also add:** +```java +import org.apache.sling.discovery.TopologyEvent; +import org.apache.sling.discovery.TopologyEventListener; +``` + +**JobConsumer class — Add:** +```java +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Collections; +``` + +--- + +# Validation + +## EventHandler Checklist + +- [ ] No `import javax.jcr.observation.*` remains (if converted from JCR EventListener) +- [ ] SCR→DS per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) +- [ ] No business logic in `handleEvent()` — only event data extraction + job creation +- [ ] No `ResourceResolver`, `Session`, or `Node` operations in `handleEvent()` +- [ ] `@Component(service = EventHandler.class, property = { EVENT_TOPIC... })` is present +- [ ] `@Reference JobManager` is present +- [ ] `jobManager.addJob(TOPIC, properties)` is called +- [ ] Event topics are correctly mapped (JCR → OSGi) +- [ ] Event filtering preserves original filter logic (paths, types) +- [ ] Logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) + +## JobConsumer Checklist + +- [ ] Implements `JobConsumer` +- [ ] Has `@Component(service = JobConsumer.class, property = { PROPERTY_TOPICS... })` +- [ ] Job topic matches the EventHandler topic +- [ ] Business logic from original `onEvent()`/`handleEvent()` is preserved +- [ ] Returns `JobResult.OK` or `JobResult.FAILED` +- [ ] Resolver + logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) +- [ ] Code compiles: `mvn clean compile` diff --git a/skills/aem/cloud-service/skills/best-practices/references/resource-resolver-logging.md b/skills/aem/cloud-service/skills/best-practices/references/resource-resolver-logging.md new file mode 100644 index 00000000..cf3d41c9 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/resource-resolver-logging.md @@ -0,0 +1,95 @@ +# ResourceResolver & logging (AEM as a Cloud Service) + +**Part of `aem-best-practices`.** Read this module when fixing Sling resource access or logging; pattern files link here instead of repeating the same rules. + +**Expectations** for backend Java on **AEM as a Cloud Service**: + +1. Do **not** use `ResourceResolverFactory.getAdministrativeResourceResolver(...)`. +2. Use **`getServiceResourceResolver`** with a **`SUBSERVICE`** mapping to an OSGi service user with the right ACLs. +3. Close resolvers predictably — prefer **try-with-resources**. +4. Use **SLF4J**; do **not** use `System.out`, `System.err`, or `e.printStackTrace()`. + +## When to Use + +- Migration or review touching `ResourceResolver` or `ResourceResolverFactory` +- Replacing legacy auth maps (`USER`/`PASSWORD`) with service users +- OSGi components, servlets, jobs, listeners + +## ResourceResolver: service user (not administrative) + +```java +// DISALLOWED on Cloud Service (remove / replace) +ResourceResolver r = factory.getAdministrativeResourceResolver(null); + +// PREFERRED +import java.util.Collections; + +try (ResourceResolver resolver = factory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "my-service-user"))) { + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return; + } + // work with resolver +} catch (LoginException e) { + LOG.error("Failed to open resource resolver", e); +} +``` + +**Notes:** + +- **`SUBSERVICE`** must match a **service user** in your project. Reuse an existing subservice name when one exists for the same concern. +- If you see `getWriteResourceResolver()` or similar deprecated APIs, replace with the **service resolver** pattern where supported by your SDK. +- Prefer **subservice only** for Cloud Service patterns; remove **`USER` / `PASSWORD`** from `authInfo` unless a pattern module documents an exception. + +## try-with-resources + +```java +// BEFORE (manual close) +ResourceResolver resolver = null; +try { + resolver = factory.getServiceResourceResolver(authInfo); + // ... +} finally { + if (resolver != null && resolver.isLive()) { + resolver.close(); + } +} + +// AFTER +try (ResourceResolver resolver = factory.getServiceResourceResolver(authInfo)) { + // ... +} catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); +} +``` + +Also close other closeables (`InputStream`, `Session` where applicable) with try-with-resources or `finally`. + +## Logging: SLF4J + +```java +private static final Logger LOG = LoggerFactory.getLogger(MyClass.class); +``` + +| Legacy | Use instead | +|--------|-------------| +| `System.out.println("x")` | `LOG.info("x")` (or `debug` / `warn`) | +| `System.err.println("x")` | `LOG.error("x")` or `LOG.warn("x")` | +| `e.printStackTrace()` | `LOG.error("Context message", e)` | +| `java.util.logging` in bundle code | Prefer SLF4J | + +Log exceptions as the last argument: `LOG.error("msg", e)`. + +## Validation checklist + +- [ ] No `getAdministrativeResourceResolver(` (unless an approved exception exists elsewhere) +- [ ] `ResourceResolver` closed via try-with-resources or equivalent +- [ ] `SUBSERVICE` / service user valid for the operation +- [ ] `private static final Logger LOG = LoggerFactory.getLogger(...)` where logging is needed +- [ ] No `System.out`, `System.err`, or `printStackTrace()` in production paths + +## See also + +- **SCR → OSGi DS:** [scr-to-osgi-ds.md](scr-to-osgi-ds.md) +- **Pattern index:** [`../SKILL.md`](../SKILL.md) diff --git a/skills/aem/cloud-service/skills/best-practices/references/scheduler-path-a.md b/skills/aem/cloud-service/skills/best-practices/references/scheduler-path-a.md new file mode 100644 index 00000000..c37ff90c --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/scheduler-path-a.md @@ -0,0 +1,216 @@ +# Scheduler Path A: @SlingScheduled (Simple Schedulers) + +For schedulers with hardcoded cron, single schedule, and `implements Runnable`. + +--- + +## Pattern prerequisites + +Read [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) and apply the linked prerequisite modules before the scheduler-specific steps below. + +## A1: Update @Component annotation + +```java +// BEFORE +@Component(immediate = true) +// OR +@Component(service = Job.class, immediate = true) + +// AFTER +@Component(service = Runnable.class) +``` + +Only change the `@Component` parameters. Do NOT remove the import for `@Component`. + +## A2: Remove Scheduler injection + +Remove the `@Reference` Scheduler field entirely: + +```java +// REMOVE these lines +@Reference +private Scheduler scheduler; +``` + +## A3: Remove scheduler.schedule() and scheduler.unschedule() calls + +Remove all `scheduler.schedule(...)`, `scheduler.unschedule(...)`, `scheduler.EXPR(...)` calls. Remove helper methods that only exist for scheduling (e.g., `addScheduler()`, `removeScheduler()`). Keep the `@Activate` annotation and method, but remove the scheduling calls inside it. + +```java +// BEFORE +@Activate +protected void activate() { + scheduler.schedule(this, scheduler.NOW(-1), CRON); + System.out.println("Activated"); +} + +// AFTER +@Activate +protected void activate() { + LOG.info("Scheduler activated"); +} +``` + +(Apply **logging** changes per [resource-resolver-logging.md](resource-resolver-logging.md), not ad hoc.) + +## A4: Remove @Modified method (if it only re-registers schedules) + +If the `@Modified` method only calls `removeScheduler()` + `addScheduler()` (or equivalent), remove it entirely since `@SlingScheduled` handles scheduling automatically. + +```java +// REMOVE if it only re-registers schedules +@Modified +protected void modified(Config config) { + removeScheduler(); + addScheduler(config); +} +``` + +If `@Modified` has other business logic (e.g., updating config fields), keep the method but remove the scheduling calls: + +```java +// KEEP but simplify +@Modified +protected void modified(Config config) { + this.myParameter = config.myParameter(); + LOG.info("Configuration modified, myParameter='{}'", myParameter); +} +``` + +## A5: Extract cron expression and add @SlingScheduled + +Find the existing cron expression in the code. Look for: +- String constants or inline cron strings used in `scheduler.schedule()` calls +- `@Property(name = "scheduler.expression", value = "...")` annotations (legacy SCR — may already be migrated via DS metatype) +- Any scheduler configuration properties with hardcoded defaults + +```java +// BEFORE +@Override +public void run() { + // existing logic +} + +// AFTER +@Override +@SlingScheduled(expression = "*/30 * * * * ?") // use the EXISTING cron from the code +public void run() { + // existing logic (will be wrapped in A6) +} +``` + +**Extract the exact cron expression from the code:** +- From `scheduler.schedule(this, ..., "0 0 2 * * ?")` -> use `"0 0 2 * * ?"` +- From `@Property(name = "scheduler.expression", value = "*/30 * * * * ?")` -> use `"*/30 * * * * ?"` +- From `scheduler.EXPR("0 * * * * ?")` -> use `"0 * * * * ?"` + +## A6: Add ResourceResolver handling + +Follow [resource-resolver-logging.md](resource-resolver-logging.md) for resolver acquisition and try-with-resources. Wrap the `run()` method body: + +```java +// AFTER +@Override +@SlingScheduled(expression = "*/30 * * * * ?") +public void run() { + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "scheduler-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver, skipping execution"); + return; + } + + LOG.debug("Running scheduled job"); + // existing job logic here + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + } +} +``` + +**Add ResourceResolverFactory injection (if not already present):** +```java +@Reference +private ResourceResolverFactory resolverFactory; +``` + +## A7: Update @Activate method + +Remove all scheduling logic. If using `@Designate`, change parameter from `Map` to the Config interface: + +```java +// BEFORE +@Activate +protected void activate(final Map config) { + configure(config); + addScheduler(config); +} + +// AFTER (OSGi DS) +@Activate +protected void activate(final Config config) { + myParameter = config.myParameter(); + LOG.info("Scheduler activated, myParameter='{}'", myParameter); +} +``` + +## A8: Update imports + +**Remove:** +```java +import org.apache.sling.commons.scheduler.Scheduler; +import org.apache.sling.commons.scheduler.ScheduleOptions; +import org.apache.sling.commons.scheduler.Job; +import org.apache.sling.commons.scheduler.JobContext; +import org.apache.sling.commons.osgi.PropertiesUtil; // if no longer needed +``` + +(Remove Felix SCR imports per **SCR→DS** skill.) + +**Add (if not already present):** +```java +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.commons.scheduler.SlingScheduled; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.metatype.annotations.AttributeDefinition; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Collections; +``` + +**DO NOT** remove or change any other imports that are still used. + +## A9: Add @Deactivate method (if missing) + +```java +@Deactivate +protected void deactivate() { + LOG.info("Scheduler deactivated"); +} +``` + +--- + +# Validation Checklist + +- [ ] No `import org.apache.sling.commons.scheduler.Scheduler;` remains +- [ ] No `import org.apache.sling.commons.scheduler.ScheduleOptions;` remains +- [ ] No Felix SCR annotations remain (`org.apache.felix.scr.annotations.*`) — per SCR→DS skill +- [ ] No `scheduler.schedule(` calls remain +- [ ] No `scheduler.unschedule(` calls remain +- [ ] No `scheduler.EXPR(` calls remain +- [ ] Resolver + logging checklist satisfied — per [resource-resolver-logging.md](resource-resolver-logging.md) +- [ ] `@Component(service = Runnable.class)` is present +- [ ] `@SlingScheduled(expression = "...")` is on `run()` method +- [ ] `ResourceResolverFactory` injected via `@Reference` if `run()` uses a resolver +- [ ] `@Deactivate` method is present +- [ ] Code compiles: `mvn clean compile` diff --git a/skills/aem/cloud-service/skills/best-practices/references/scheduler-path-b.md b/skills/aem/cloud-service/skills/best-practices/references/scheduler-path-b.md new file mode 100644 index 00000000..5a11374b --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/scheduler-path-b.md @@ -0,0 +1,568 @@ +# Scheduler Path B: Sling Job (Complex Schedulers) + +For schedulers with config-driven crons, multiple schedules, `implements Job`, or `ScheduleOptions.config()`. + +This path splits the original class into TWO classes: +1. **Scheduler class** — registers/unregisters Sling Jobs via `JobManager` +2. **JobConsumer class** — executes the business logic when the job fires + +## Pattern prerequisites + +Read [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) and apply linked prerequisite modules before the steps below. + +--- + +## Complete Example: Before and After + +### Before (Legacy Pattern) + +```java +package com.example.schedulers; + +import org.apache.felix.scr.annotations.Activate; +import org.apache.felix.scr.annotations.Component; +import org.apache.felix.scr.annotations.Deactivate; +import org.apache.felix.scr.annotations.Modified; +import org.apache.felix.scr.annotations.Reference; +import org.apache.felix.scr.annotations.Service; +import org.apache.sling.commons.scheduler.Job; +import org.apache.sling.commons.scheduler.JobContext; +import org.apache.sling.commons.scheduler.Scheduler; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.osgi.service.metatype.annotations.AttributeDefinition; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; + +import java.util.Map; + +@Component(immediate = true, metatype = true) +@Service(value = Job.class) +@Designate(ocd = AssetPurgeScheduler.Config.class) +public class AssetPurgeScheduler implements Job { + + @Reference + private Scheduler scheduler; + + @Reference + private ResourceResolverFactory resourceResolverFactory; + + private String cronExpression; + private String assetPath; + + @Activate + protected void activate(Map config) { + cronExpression = (String) config.get("cronExpression"); + assetPath = (String) config.get("assetPath"); + addScheduler(); + } + + @Modified + protected void modified(Map config) { + removeScheduler(); + cronExpression = (String) config.get("cronExpression"); + assetPath = (String) config.get("assetPath"); + addScheduler(); + } + + @Deactivate + protected void deactivate() { + removeScheduler(); + } + + private void addScheduler() { + Map jobProperties = new HashMap<>(); + jobProperties.put("assetPath", assetPath); + + scheduler.schedule(this, scheduler.EXPR(cronExpression), jobProperties); + System.out.println("Scheduled asset purge with cron: " + cronExpression); + } + + private void removeScheduler() { + scheduler.unschedule(this); + } + + @Override + public void execute(JobContext context) { + try { + String path = (String) context.getConfiguration().get("assetPath"); + ResourceResolver resolver = resourceResolverFactory.getAdministrativeResourceResolver(null); + if (resolver != null) { + // Business logic: purge assets at path + System.out.println("Purging assets at: " + path); + resolver.close(); + } + } catch (Exception e) { + System.err.println("Error purging assets: " + e.getMessage()); + e.printStackTrace(); + } + } + + @ObjectClassDefinition(name = "Asset Purge Scheduler Configuration") + public @interface Config { + @AttributeDefinition(name = "Cron Expression") + String cronExpression() default "0 0 2 * * ?"; + + @AttributeDefinition(name = "Asset Path") + String assetPath() default "/content/dam"; + } +} +``` + +### After (Cloud Service Compatible) + +**File 1: AssetPurgeScheduler.java** (Scheduler class) + +```java +package com.example.schedulers; + +import org.apache.sling.event.jobs.JobBuilder; +import org.apache.sling.event.jobs.JobManager; +import org.apache.sling.event.jobs.ScheduledJobInfo; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Modified; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.metatype.annotations.AttributeDefinition; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +@Component(immediate = true) +@Designate(ocd = AssetPurgeScheduler.Config.class) +public class AssetPurgeScheduler { + + private static final Logger LOG = LoggerFactory.getLogger(AssetPurgeScheduler.class); + private static final String JOB_TOPIC = "com/example/asset/purge"; + + @Reference + private JobManager jobManager; + + @Activate + @Modified + protected void activate(Config config) { + LOG.info("AssetPurgeScheduler activated"); + unscheduleExistingJobs(); + if (config.enabled()) { + scheduleJob(config); + } + } + + @Deactivate + protected void deactivate() { + unscheduleExistingJobs(); + LOG.info("AssetPurgeScheduler deactivated"); + } + + private void scheduleJob(Config config) { + Map jobProperties = new HashMap<>(); + jobProperties.put("assetPath", config.assetPath()); + + JobBuilder.ScheduleBuilder scheduleBuilder = jobManager + .createJob(JOB_TOPIC) + .properties(jobProperties) + .schedule(); + scheduleBuilder.cron(config.cronExpression()); + + ScheduledJobInfo info = scheduleBuilder.add(); + if (info == null) { + LOG.error("Failed to create scheduled job"); + } else { + LOG.info("Scheduled job created with cron: {}", config.cronExpression()); + } + } + + private void unscheduleExistingJobs() { + Collection jobs = jobManager.getScheduledJobs(JOB_TOPIC, 0, null); + for (ScheduledJobInfo job : jobs) { + job.unschedule(); + } + } + + @ObjectClassDefinition(name = "Asset Purge Scheduler Configuration") + public @interface Config { + @AttributeDefinition(name = "Enabled") + boolean enabled() default true; + + @AttributeDefinition(name = "Cron Expression") + String cronExpression() default "0 0 2 * * ?"; + + @AttributeDefinition(name = "Asset Path") + String assetPath() default "/content/dam"; + } +} +``` + +**File 2: AssetPurgeJobConsumer.java** (JobConsumer class) + +```java +package com.example.schedulers; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.apache.sling.event.jobs.consumer.JobResult; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; + +@Component( + service = JobConsumer.class, + property = { + JobConsumer.PROPERTY_TOPICS + "=" + "com/example/asset/purge" + } +) +public class AssetPurgeJobConsumer implements JobConsumer { + + private static final Logger LOG = LoggerFactory.getLogger(AssetPurgeJobConsumer.class); + + @Reference + private ResourceResolverFactory resolverFactory; + + @Override + public JobResult process(final Job job) { + String assetPath = job.getProperty("assetPath", String.class); + LOG.info("Processing asset purge job for path: {}", assetPath); + + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "scheduler-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return JobResult.FAILED; + } + + // Business logic: purge assets at path + LOG.info("Purging assets at: {}", assetPath); + // ... existing business logic here ... + + return JobResult.OK; + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + return JobResult.FAILED; + } catch (Exception e) { + LOG.error("Error executing scheduled job", e); + return JobResult.FAILED; + } + } +} +``` + +**Key Changes:** +- ✅ Split single class into Scheduler + JobConsumer +- ✅ Removed `implements Job` → Scheduler class no longer implements Job +- ✅ Replaced `Scheduler` → `JobManager` with `createJob().schedule().cron()` +- ✅ Business logic moved to JobConsumer `process()` method +- ✅ Replaced `getAdministrativeResourceResolver()` → `getServiceResourceResolver()` with SUBSERVICE +- ✅ Replaced `System.out/err` → SLF4J Logger +- ✅ Job properties extracted via `job.getProperty()` instead of `JobContext.getConfiguration()` +- ✅ Preserved business logic unchanged + +--- + +## B1: Create the Scheduler class (job registration) + +Transform the existing class into a job registration class: + +```java +// BEFORE +@Component(service = Job.class, immediate = true) +@Designate(ocd = SchedulerConfig.class) +public class AssetPurgeScheduler implements Job { + @Reference Scheduler scheduler; + + @Activate + private void activate(SchedulerConfig config) { + addScheduler(config); + } + + @Override + public void execute(JobContext context) { + // business logic + } +} + +// AFTER +@Component(immediate = true) +@Designate(ocd = SchedulerConfig.class) +public class AssetPurgeScheduler { + private static final Logger LOG = LoggerFactory.getLogger(AssetPurgeScheduler.class); + private static final String JOB_TOPIC = "com/example/asset/purge"; + + @Reference + private JobManager jobManager; + + @Activate + @Modified + protected void activate(SchedulerConfig config) { + LOG.info("Scheduler activated"); + unscheduleExistingJobs(); + if (config.enabled()) { + scheduleJob(config); + } + } + + @Deactivate + protected void deactivate() { + unscheduleExistingJobs(); + LOG.info("Scheduler deactivated"); + } + + private void scheduleJob(SchedulerConfig config) { + Map jobProperties = new HashMap<>(); + jobProperties.put("assetPath", config.assetPath()); + + JobBuilder.ScheduleBuilder scheduleBuilder = jobManager + .createJob(JOB_TOPIC) + .properties(jobProperties) + .schedule(); + scheduleBuilder.cron(config.cronExpression()); + + ScheduledJobInfo info = scheduleBuilder.add(); + if (info == null) { + LOG.error("Failed to create scheduled job"); + } else { + LOG.info("Scheduled job created with cron: {}", config.cronExpression()); + } + } + + private void unscheduleExistingJobs() { + Collection jobs = jobManager.getScheduledJobs(JOB_TOPIC, 0, null); + for (ScheduledJobInfo job : jobs) { + job.unschedule(); + } + } +} +``` + +**Key changes:** +- Remove `implements Job` or `implements Runnable` +- Replace `@Reference Scheduler scheduler` with `@Reference JobManager jobManager` +- Remove `@Component(service = ...)` — use `@Component(immediate = true)` only +- Keep `@Activate` and `@Deactivate` +- Keep `@Modified` — it re-registers jobs with new config values +- Move business logic out (goes to JobConsumer in B2) +- Keep environment guards (e.g., `isAuthor()` run mode check) in the Scheduler class +- Keep infrastructure `@Reference` fields (e.g., `SlingSettingsService`) in Scheduler; move business `@Reference` fields (e.g., `ExampleService`, `ResourceResolverFactory`) to JobConsumer + +**Idempotency guard (recommended):** Add a `doesScheduledJobExist()` check before scheduling: + +```java +@Activate +@Modified +protected void activate(SchedulerConfig config) { + if (isAuthor() && config.enabled() && !doesScheduledJobExist()) { + scheduleJob(config); + } +} + +private boolean doesScheduledJobExist() { + Collection jobs = jobManager.getScheduledJobs(JOB_TOPIC, 0, null); + return !jobs.isEmpty(); +} +``` + +**Note on `canRunConcurrently()`:** The original code may use `scheduleOptions.canRunConcurrently(false)`. Sling Jobs do not have a direct equivalent — concurrency is controlled via job queue configuration in OSGi. This setting can be safely dropped during migration. + +## B2: Create the JobConsumer class (business logic) + +Create a NEW class that implements `JobConsumer`: + +```java +package com.example.schedulers; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.apache.sling.event.jobs.consumer.JobResult; +import org.apache.sling.event.jobs.consumer.JobUtil; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Collections; + +@Component( + service = JobConsumer.class, + property = { + JobConsumer.PROPERTY_TOPICS + "=" + "com/example/asset/purge" + } +) +public class AssetPurgeJobConsumer implements JobConsumer { + + private static final Logger LOG = LoggerFactory.getLogger(AssetPurgeJobConsumer.class); + + @Reference + private ResourceResolverFactory resolverFactory; + + @Override + public JobResult process(final Job job) { + String assetPath = job.getProperty("assetPath", String.class); + LOG.info("Processing asset purge job for path: {}", assetPath); + + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "scheduler-service"))) { + + if (resolver == null) { + LOG.warn("Could not acquire resource resolver"); + return JobResult.FAILED; + } + + // === EXISTING BUSINESS LOGIC FROM run()/execute() GOES HERE === + + return JobResult.OK; + + } catch (LoginException e) { + LOG.error("Failed to get resource resolver", e); + return JobResult.FAILED; + } catch (Exception e) { + LOG.error("Error executing scheduled job", e); + return JobResult.FAILED; + } + } +} +``` + +**Key rules for JobConsumer:** +- Job topic MUST match the topic used in the Scheduler class +- Move ALL business logic from `run()` or `execute(JobContext)` into `process(Job)` +- Move business-logic `@Reference` fields here (e.g., `ExampleService`, `ResourceResolverFactory`) +- Extract job properties via `job.getProperty("key", Type.class)` or `JobUtil.getProperty(job, "key", Type.class)` (both valid) +- Map `jobContext.getConfiguration().get("key")` (old) to `job.getProperty("key", Type.class)` (new) +- Return `JobResult.OK` on success, `JobResult.FAILED` on failure +- Resolver + logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) + +## B3: Handle multiple cron expressions (if applicable) + +If the original scheduler has multiple cron expressions (e.g., per locale), create separate scheduled jobs for each: + +```java +// BEFORE: multiple scheduler.schedule() calls +scheduler.schedule(this, enScheduleOptions); // English +scheduler.schedule(this, frScheduleOptions); // French +scheduler.schedule(this, inScheduleOptions); // Indian + +// AFTER: multiple jobManager.createJob() calls +private void scheduleJobs(SchedulerConfig config) { + scheduleJob(config.enCronExpression(), config.enAssetPath(), "en"); + scheduleJob(config.frCronExpression(), config.frAssetPath(), "fr"); + scheduleJob(config.inCronExpression(), config.inAssetPath(), "in"); +} + +private void scheduleJob(String cron, String assetPath, String locale) { + Map props = new HashMap<>(); + props.put("assetPath", assetPath); + props.put("locale", locale); + + JobBuilder.ScheduleBuilder builder = jobManager + .createJob(JOB_TOPIC) + .properties(props) + .schedule(); + builder.cron(cron); + builder.add(); + LOG.info("Scheduled {} job with cron: {}", locale, cron); +} +``` + +## B4: Update imports + +**Scheduler class — Remove:** +```java +import org.apache.sling.commons.scheduler.Scheduler; +import org.apache.sling.commons.scheduler.ScheduleOptions; +import org.apache.sling.commons.scheduler.Job; +import org.apache.sling.commons.scheduler.JobContext; +import org.apache.felix.scr.annotations.*; +import org.apache.sling.commons.osgi.PropertiesUtil; +``` + +**Scheduler class — Add:** +```java +import org.apache.sling.event.jobs.JobBuilder; +import org.apache.sling.event.jobs.JobManager; +import org.apache.sling.event.jobs.ScheduledJobInfo; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Modified; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.metatype.annotations.Designate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +``` + +**JobConsumer class — Add:** +```java +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.event.jobs.Job; +import org.apache.sling.event.jobs.consumer.JobConsumer; +import org.apache.sling.event.jobs.consumer.JobResult; +import org.apache.sling.event.jobs.consumer.JobUtil; // optional, for JobUtil.getProperty() +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Collections; +``` + +## B5: Verify @Activate, @Modified, @Deactivate lifecycle + +```java +@Activate +@Modified +protected void activate(SchedulerConfig config) { + unscheduleExistingJobs(); + if (config.enabled()) { + scheduleJob(config); + } +} + +@Deactivate +protected void deactivate() { + unscheduleExistingJobs(); +} +``` + +--- + +# Validation Checklist + +**Scheduler class:** +- [ ] No `import org.apache.sling.commons.scheduler.Scheduler;` remains +- [ ] No `implements Runnable` or `implements Job` remains +- [ ] No `scheduler.schedule(` calls remain +- [ ] SCR→DS complete per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) +- [ ] Uses `@Reference JobManager jobManager` +- [ ] Uses `jobManager.createJob(TOPIC).properties(...).schedule().cron(...).add()` +- [ ] Has `unscheduleExistingJobs()` method +- [ ] `@Activate` and `@Deactivate` properly manage job lifecycle +- [ ] `@Modified` re-registers jobs if present +- [ ] Logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) + +**JobConsumer class:** +- [ ] Implements `JobConsumer` +- [ ] Has `@Component(service = JobConsumer.class, property = {"job.topics=..."})` +- [ ] Job topic matches the Scheduler class topic +- [ ] Business logic from original `run()`/`execute()` is preserved +- [ ] Returns `JobResult.OK` or `JobResult.FAILED` +- [ ] Resolver + logging per [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) +- [ ] Code compiles: `mvn clean compile` diff --git a/skills/aem/cloud-service/skills/best-practices/references/scheduler.md b/skills/aem/cloud-service/skills/best-practices/references/scheduler.md new file mode 100644 index 00000000..712b5423 --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/scheduler.md @@ -0,0 +1,137 @@ +# Scheduler Migration Pattern + +Migrates AEM schedulers from legacy patterns to Cloud Service compatible patterns. + +**Before path-specific steps:** [aem-cloud-service-pattern-prerequisites.md](aem-cloud-service-pattern-prerequisites.md) (SCR→DS, resolver, logging). + +**Two paths based on complexity:** +- **Path A (@SlingScheduled):** Simple schedulers — hardcoded cron, single schedule, `implements Runnable` +- **Path B (Sling Job):** Complex schedulers — config-driven crons, multiple schedules, `implements Job` + +--- + +## Quick Examples + +### Path A Example (Simple Scheduler) + +**Before:** +```java +@Component(service = Runnable.class) +public class MyScheduler implements Runnable { + @Reference private Scheduler scheduler; + + @Activate + protected void activate() { + scheduler.schedule(this, scheduler.EXPR("*/30 * * * * ?")); + } + + @Override + public void run() { + ResourceResolver resolver = resolverFactory.getAdministrativeResourceResolver(null); + // business logic + } +} +``` + +**After:** +```java +@Component(service = Runnable.class) +public class MyScheduler implements Runnable { + @Reference private ResourceResolverFactory resolverFactory; + + @Override + @SlingScheduled(expression = "*/30 * * * * ?") + public void run() { + try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "scheduler-service"))) { + // business logic + } + } +} +``` + +### Path B Example (Complex Scheduler) + +**Before:** +```java +@Component(service = Job.class) +public class MyScheduler implements Job { + @Reference private Scheduler scheduler; + + @Activate + protected void activate(Config config) { + scheduler.schedule(this, scheduler.EXPR(config.cronExpression())); + } + + @Override + public void execute(JobContext context) { + // business logic + } +} +``` + +**After (Split into 2 classes):** + +**Scheduler.java:** +```java +@Component(immediate = true) +public class MyScheduler { + @Reference private JobManager jobManager; + + @Activate + protected void activate(Config config) { + jobManager.createJob("my/job/topic") + .properties(Map.of("param", config.param())) + .schedule().cron(config.cronExpression()).add(); + } +} +``` + +**JobConsumer.java:** +```java +@Component(service = JobConsumer.class, property = { + JobConsumer.PROPERTY_TOPICS + "=my/job/topic" +}) +public class MyJobConsumer implements JobConsumer { + @Override + public JobResult process(Job job) { + // business logic from execute() + return JobResult.OK; + } +} +``` + +--- + +## Classification + +**Classify BEFORE making any changes.** + +### Use Path A when ALL of these are true: +- Cron expression is a hardcoded string constant (not from runtime configuration) +- Only one schedule/cron per class +- Class implements `Runnable` (not `Job`) +- No complex scheduling logic (no `ScheduleOptions.config()`, no job properties) + +**If Path A → read `resources/scheduler-path-a.md` and follow its steps.** + +### Use Path B when ANY of these are true: +- Cron expression comes from runtime configuration (e.g., `config.cronExpression()`) +- Multiple cron expressions or schedules in one class +- Class implements `org.apache.sling.commons.scheduler.Job` (not `Runnable`) +- Scheduling uses `ScheduleOptions.config()` to pass job properties +- Business logic needs access to job context/properties at execution time +- `@Modified` method re-registers schedules with new config values + +**If Path B → read `resources/scheduler-path-b.md` and follow its steps.** + +## Scheduler-Specific Rules + +- **CLASSIFY FIRST** — determine Path A or Path B before making any changes +- **DO NOT** invent cron expressions — extract from existing code or @Property annotations +- **DO NOT** use `@SlingScheduled` with runtime config values — it requires compile-time constants +- **DO** distribute `@Reference` fields correctly in Path B: business logic services (e.g., `ExampleService`, `ResourceResolverFactory`) go to JobConsumer, infrastructure services (e.g., `SlingSettingsService`, `JobManager`) stay in Scheduler class + +## IMPORTANT + +**Read ONLY the path file that matches your classification. Do NOT read both.** diff --git a/skills/aem/cloud-service/skills/best-practices/references/scr-to-osgi-ds.md b/skills/aem/cloud-service/skills/best-practices/references/scr-to-osgi-ds.md new file mode 100644 index 00000000..f5a569fe --- /dev/null +++ b/skills/aem/cloud-service/skills/best-practices/references/scr-to-osgi-ds.md @@ -0,0 +1,156 @@ +# Felix SCR → OSGi Declarative Services (AEM as a Cloud Service) + +**Part of `aem-best-practices`.** Read this module when converting components; do not duplicate these steps inside scheduler, replication, event, or asset pattern files. + +**Explicit guideline:** AEM as a Cloud Service expects **OSGi Declarative Services** with `org.osgi.service.component.annotations` (and metatype where configuration applies). **Felix SCR** (`org.apache.felix.scr.annotations`) is legacy. + +## When to Use + +- Any Java class still using `org.apache.felix.scr.annotations.*` +- Reviews and refactors: ensure no SCR imports remain + +## Build / module notes + +- Remove Felix SCR Maven plugin / `scr` processing if present; ensure **`bnd-maven-plugin`** or equivalent declares DS component metadata. +- Dependencies: `org.osgi:org.osgi.service.component.annotations` and `org.osgi:org.osgi.service.metatype.annotations` (aligned with AEM SDK / Cloud Service BOM). + +## Step 1: Remove Felix SCR imports + +Remove: + +```java +import org.apache.felix.scr.annotations.Activate; +import org.apache.felix.scr.annotations.Component; +import org.apache.felix.scr.annotations.Deactivate; +import org.apache.felix.scr.annotations.Modified; +import org.apache.felix.scr.annotations.Properties; +import org.apache.felix.scr.annotations.Property; +import org.apache.felix.scr.annotations.Reference; +import org.apache.felix.scr.annotations.Service; +``` + +Add DS imports as needed: + +```java +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Modified; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.metatype.annotations.AttributeDefinition; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +``` + +## Step 2: Replace `@Component` and `@Service` + +**Felix SCR often combined `@Component` + `@Service`.** OSGi DS uses a single `@Component` with `service = { ... }`. + +```java +// BEFORE (Felix SCR) +@Component(immediate = true, metatype = true, label = "...", description = "...") +@Service(value = MyService.class) +public class MyComponent implements MyService { } + +// AFTER (OSGi DS) +@Component(service = MyService.class, immediate = true) +public class MyComponent implements MyService { } +``` + +For multiple service interfaces: + +```java +@Component(service = { Runnable.class, MyApi.class }) +``` + +## Step 3: Replace `@Reference` + +```java +// BEFORE +@Reference +private SomeService someService; + +@Reference(cardinality = ReferenceCardinality.OPTIONAL) +private OptionalService optional; + +// AFTER +@Reference +private SomeService someService; + +@Reference(cardinality = ReferenceCardinality.OPTIONAL) +private OptionalService optional; +``` + +Use `org.osgi.service.component.annotations.Reference` and `ReferenceCardinality` / `ReferencePolicy` from the **OSGi** package, not Felix. + +**Targeted references:** + +```java +@Reference(target = "(name=my-agent)") +private DistributionAgent agent; +``` + +## Step 4: Replace `@Activate` / `@Deactivate` / `@Modified` signatures + +Prefer **typed configuration** with `@Designate` + OCD instead of `Map`. + +```java +// BEFORE (Felix SCR) +@Activate +protected void activate(Map config) { + String x = PropertiesUtil.toString(config.get("x"), "default"); +} + +// AFTER (OSGi DS + metatype) +@Activate +protected void activate(MyConfig config) { + String x = config.x(); +} + +@ObjectClassDefinition(name = "My Component") +public @interface MyConfig { + @AttributeDefinition(description = "Example") + String x() default "default"; +} + +@Component(service = MyService.class) +@Designate(ocd = MyComponent.MyConfig.class) +public class MyComponent implements MyService { +``` + +If you must keep a transition period with `Map`, use `org.osgi.service.component.annotations.Activate` with `ComponentContext` or `Map` from **`org.osgi.service.component`** — not Felix APIs. + +## Step 5: Replace `@Property` / `@Properties` with metatype + +Static Felix `@Property` fields become **`@ObjectClassDefinition`** + methods with `@AttributeDefinition`. + +```java +// BEFORE (Felix SCR) +@Property(label = "Cron", description = "...") +public static final String CRON = "scheduler.expression"; + +// AFTER (OSGi DS) +@ObjectClassDefinition(name = "Scheduler Config") +public @interface Config { + @AttributeDefinition(name = "Cron", description = "...") + String scheduler_expression() default "0 0 2 * * ?"; +} +``` + +Bind with `@Designate(ocd = MyComponent.Config.class)` on the component class. + +For rare `property = { "key=value" }` only (no metatype), you may keep inline `property` on `@Component` — prefer OCD for user-facing config. + +## Step 6: Validation checklist + +- [ ] No `import org.apache.felix.scr.annotations.*` remains +- [ ] `@Component` uses `org.osgi.service.component.annotations` +- [ ] Services declared via `@Component(service = ...)` (not Felix `@Service`) +- [ ] `@Reference` / lifecycle annotations are OSGi DS packages +- [ ] Metatype uses `@ObjectClassDefinition` / `@AttributeDefinition` / `@Designate` where configuration existed +- [ ] Project still builds (`mvn clean compile`) and components start without SCR descriptor dependency + +## See also + +- **ResourceResolver + logging:** [resource-resolver-logging.md](resource-resolver-logging.md) +- **Pattern index:** [`../SKILL.md`](../SKILL.md) diff --git a/skills/aem/cloud-service/skills/migration/.claude-plugin/plugin.json b/skills/aem/cloud-service/skills/migration/.claude-plugin/plugin.json new file mode 100644 index 00000000..b3a4a325 --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "aem-migration", + "description": "Orchestrates legacy AEM to Cloud Service migration: BPA CSV/cache, CAM/MCP targets, one-pattern-per-session workflow. Pattern transformation procedures live in the aem-best-practices package in this repository—not in this plugin.", + "version": "1.0.0", + "author": { + "name": "Adobe" + }, + "repository": "https://github.com/adobe/skills", + "license": "Apache-2.0", + "keywords": ["aem", "cloud-service", "migration", "bpa", "cam", "code-transformation", "adobe"] +} diff --git a/skills/aem/cloud-service/skills/migration/README.md b/skills/aem/cloud-service/skills/migration/README.md new file mode 100644 index 00000000..6af73bb6 --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/README.md @@ -0,0 +1,50 @@ +# AEM as a Cloud Service — Code Migration + +This plugin orchestrates migration **from legacy AEM (6.x, AMS, or on-prem) to AEM as a Cloud Service**: Best Practices Analyzer (BPA) data, Cloud Acceleration Manager (CAM) via MCP when available, and a one-pattern-per-session workflow. + +**Target platform** is always **AEM as a Cloud Service**. Source is legacy AEM; ambiguous top-level “migration” is avoided by scoping this under `skills/aem/cloud-service/skills/migration/`. + +Transformation rules and pattern modules live in **`aem-best-practices`** (folder `skills/aem/cloud-service/skills/best-practices/`) — read its main `SKILL.md` and `references/` before editing code. + +**Install both plugins for typical migrations:** **`aem-migration`** handles BPA/CAM orchestration and target lists; it does **not** ship the step-by-step pattern refactors. **`aem-best-practices`** holds those modules (`references/*.md`). Install **only** **`aem-migration`** if your agent already has access to the same files (for example you have the full `adobe/skills` repo open and paths like `{best-practices}` resolve). + +**First run:** In chat, name **one BPA pattern** (e.g. scheduler) and either a **CSV path**, **CAM/MCP**, or **concrete Java files**. See **Quick start** in `SKILL.md` for copy-paste prompts and the CAM happy path in `references/cam-mcp.md`. + +## Skills + +### aem-migration + +- BPA collection, CSV, and CAM/MCP flows (CAM tool schemas and retries: `references/cam-mcp.md`) +- Manual flow and pattern auto-detection +- Delegates detailed transformations to **`aem-best-practices`** + +## Installation + +### Claude Code Plugins + +```bash +/plugin install aem-migration@adobe-skills +/plugin install aem-best-practices@adobe-skills +``` + +### Vercel Skills + +```bash +npx skills add https://github.com/adobe/skills/tree/main/skills/aem/cloud-service/skills/migration --all +npx skills add https://github.com/adobe/skills/tree/main/skills/aem/cloud-service/skills/best-practices --all +``` + +### upskill + +```bash +gh upskill adobe/skills --path skills/aem/cloud-service/skills/migration --all +gh upskill adobe/skills --path skills/aem/cloud-service/skills/best-practices --all +``` + +## Prerequisites + +- AEM project with Maven/Gradle +- Access to sources to migrate +- BPA results recommended (CSV or CAM) + +For issues, see the main [Adobe Skills repository](https://github.com/adobe/skills). diff --git a/skills/aem/cloud-service/skills/migration/SKILL.md b/skills/aem/cloud-service/skills/migration/SKILL.md new file mode 100644 index 00000000..d02a5cb9 --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/SKILL.md @@ -0,0 +1,188 @@ +--- +name: migration +description: Orchestrates legacy AEM Java (6.x, AMS, on-prem) to AEM as a Cloud Service migration using BPA CSV or cache, CAM/MCP target discovery, and one-pattern-per-session workflow. OSGi configs → Cloud Manager — scan ui.config, .cfg.json, secrets, $[secret:]/$[env:] — agent follows references/osgi-cfg-json-cloud-manager.md. Use for BPA/CAM findings, Cloud Service blockers, or fixes for scheduler, ResourceChangeListener, replication, EventListener, OSGi EventHandler, DAM AssetManager. Java transformation steps live in the best-practices skill—read it and the right references/ modules before editing code. +--- + +# AEM as a Cloud Service — Code Migration + +**Source → target:** Legacy **AEM 6.x / AMS / on-prem** → **AEM as a Cloud Service**. Scoped under `skills/aem/cloud-service/skills/migration/` so this is not confused with Edge Delivery or 6.5 LTS. + +This skill is **orchestration**: BPA data, CAM/MCP, **one pattern per session**, and target discovery. **Transformation rules and steps** live in **`aem-best-practices`** — read that skill and the right `references/*.md` before editing code. + +**Setup:** Install **`aem-best-practices`** alongside this skill when needed so the agent can load pattern modules. Skip the extra install only if those files are already available (e.g. full `adobe/skills` checkout with resolvable `{best-practices}` paths). See this plugin’s **README** for install commands for both plugins. + +## Quick start (for the person driving the agent) + +**One pattern per chat/session** — if you ask to “fix everything,” the skill will ask you to pick first (e.g. scheduler vs replication). + +| You have… | Say something like… | What happens | +|-----------|---------------------|--------------| +| A **BPA CSV** | *“Fix **scheduler** findings using `./path/to/bpa.csv`”* | Fastest path: CSV → cached collection → files | +| **CAM + MCP** only | *“Get **scheduler** findings from CAM; I’ll pick the project when you list them.”* | Agent lists projects → you confirm → MCP fetch ([cam-mcp.md](references/cam-mcp.md)) | +| **Just a few files** | *“Migrate **scheduler** in `core/.../MyJob.java`”* | Manual flow: no BPA required | +| **OSGi → Cloud Manager** | *“**Scan my config files and create Cloud Manager environment secrets or variables.**”* | Agent **auto-reads** [references/osgi-cfg-json-cloud-manager.md](references/osgi-cfg-json-cloud-manager.md) (full Adobe-aligned rules inlined there); no BPA pattern id | + +**Starter prompts (copy-paste):** + +- *“Use the migration skill: **scheduler** only, BPA CSV at `./reports/bpa.csv`, then apply best-practices reference modules before editing.”* +- *“**Replication** only from CAM; list projects first, I’ll pick one.”* +- *“**Manual:** **event listener** migration for `.../Listener.java` — read best-practices module first.”* +- *“Scan my config files and create Cloud Manager environment secrets or variables.”* + + +## Path convention (Adobe Skills monorepo) + +From the **repository root** (parent of the `skills/` directory): + +| Symbol | Path | +|--------|------| +| **`{best-practices}`** | `skills/aem/cloud-service/skills/best-practices/` | + +Examples: `{best-practices}/SKILL.md`, `{best-practices}/references/scheduler.md`. + +## Workspace scope (IDE) — user code only + +Applies to **finding and editing the user’s AEM project** (Java, bundles, config), not to reading installed skill files under `{best-practices}`. + +- Treat the **current IDE workspace root folder(s)** (single- or multi-root) as the **only** boundary for searches, globs, `grep`, and file reads/writes for migration targets. +- **Do not** search parent directories, sibling folders on disk, `~`, other clones, or arbitrary absolute paths to “discover” sources unless the user **explicitly** names those paths or asks you to include them. +- **BPA CSV / CAM targets:** If a `filePath` or class-to-file mapping does not resolve under a workspace root, **stop** and tell the user which paths are missing — do not hunt elsewhere on the filesystem. Ask them to open the correct project in the IDE or adjust paths. +- **Manual flow:** Only migrate files the user named that live under the workspace (or paths they explicitly provided). Do not expand scope by searching outside the workspace. + +## Required delegation (do this first) + +**Branch A — OSGi configs → Cloud Manager** (no Java BPA pattern this session): If the user asks to **scan config files**, **create / set up Cloud Manager environment secrets or variables**, move **passwords or secrets** out of **OSGi / `.cfg.json` / `ui.config`**, or mentions **`$[secret:]`** / **`$[env:]`** for AEM CS, then **read [references/osgi-cfg-json-cloud-manager.md](references/osgi-cfg-json-cloud-manager.md) immediately** and follow the **product rules and workflow** defined in that file (Adobe AEM as a Cloud Service OSGi + Cloud Manager behavior is reproduced there—no external doc URL required). Sleek prompts are enough — **no** need to name the reference file. **Skip** branch B for that work. + +**Branch B — Java / BPA pattern migration:** + +1. Read **`{best-practices}/SKILL.md`** — critical rules, Java baseline links, **Pattern Reference Modules** table, **Manual Pattern Hints**. +2. Read **`{best-practices}/references/.md`** for the **single** active BPA pattern (see table in that `SKILL.md`). +3. When code uses SCR, `ResourceResolver`, or console logging, read **`{best-practices}/references/scr-to-osgi-ds.md`** and **`{best-practices}/references/resource-resolver-logging.md`** (or the hub **`{best-practices}/references/aem-cloud-service-pattern-prerequisites.md`**). + +Do not transform **Java** until the pattern module is read (branch B). Branch A does not require `{best-practices}` pattern modules. + +## When to Use This Skill + +- Migrate legacy AEM Java toward **Cloud Service–compatible** patterns +- Drive work from **BPA** (CSV or cached collection) or **CAM via MCP** +- Enforce **one pattern type per session** +- **OSGi → Cloud Manager:** **Branch A** — scan scoped **`.cfg.json`**, apply **`$[secret:…]`** / **`$[env:…]`** per rules in **[references/osgi-cfg-json-cloud-manager.md](references/osgi-cfg-json-cloud-manager.md)**; gitignored handoff; **no** secret values in chat. + +### OSGi configs and Cloud Manager (no BPA pattern id) + +Sleek user prompts are enough (see Quick start). **Agent:** **Branch A** → read the reference → **One-prompt workflow**; obey the **inlined Adobe AEM CS rules** in that file (value types, placeholders, CM API/CLI, custom-properties-only, repoinit, runmode context, local SDK secrets). Ambiguous or Adobe-owned PIDs → **`needs_user_review`**, not guesses. + +## Prerequisites + +- Project source and Maven/Gradle build +- BPA CSV or MCP access optional but recommended + +## BPA findings — flow + +Scripts run via **`getBpaFindings`** (see **Calling the helper**); do not reimplement collection logic by hand unless the helper is unavailable. + +1. **Collection exists** → reuse; tell the user counts/age when useful. +2. **User gave BPA CSV path** → parse, build collection, then use targets. +3. **No CSV; MCP available** → follow [references/cam-mcp.md](references/cam-mcp.md): `list-projects`, user confirms project, then `fetch-cam-bpa-findings`. +4. **Nothing works** → ask for CSV path or explicit Java files (manual flow). + +### CAM via MCP (summary) + +Use **`fetch-cam-bpa-findings`** only after **`list-projects`** and **explicit user confirmation** of which project row to use (prefer **`projectId`** from that list). Do not pass an unconfirmed project name string. **Full tool schemas, REST notes, retries, and error handling:** [references/cam-mcp.md](references/cam-mcp.md). + +### What the user might say + +- *"Fix scheduler using ./reports/bpa.csv"* → CSV path known +- *"Fix scheduler"* → collection → MCP → ask for CSV +- *"Migrate `core/.../Foo.java`"* → manual flow + +### Calling the helper + +Scripts live under **`./scripts/`** (next to this `SKILL.md`). + +```javascript +const { getBpaFindings } = require('./scripts/bpa-findings-helper.js'); + +const result = await getBpaFindings(pattern, { + bpaFilePath: './cleaned_file6.csv', + collectionsDir: './unified-collections', + projectId: '...', + mcpFetcher: mcpFunction +}); +``` + +**`result`:** `success`, `source` (`'unified-collection' | 'bpa-file' | 'mcp-server' | …`), `message`, `targets` (when successful). + +### Collection caching + +Collections live under **`./unified-collections/`**. If a collection exists and the user supplies a **new** CSV, ask whether to reuse or re-process. + +### Reading a BPA CSV + +Filter rows where **`pattern`** matches the session pattern. Typical columns: `pattern`, `filePath`, `message`. + +### MCP errors and fallback + +**Critical:** On MCP failure, **stop the workflow immediately** and give the user the **exact tool error message** (verbatim), including “not found” / 404-style project errors. **Do not** continue with migration steps, infer a different CAM project from the workspace, or switch to manual/local migration on your own. + +**Exception:** enablement restriction errors (prefix documented in [references/cam-mcp.md](references/cam-mcp.md)) must be shown **verbatim** with no paraphrase and no automatic fallback until the user addresses them. + +After stopping, you may summarize what failed in plain language and, if helpful, re-show projects from **`list-projects`**. **Only** continue when the user **explicitly** directs the next step (e.g. correct project id/name from the list, BPA CSV path, or specific Java files for manual flow). + +For retries, error categories, and when user-directed CSV/manual paths are allowed, follow [references/cam-mcp.md](references/cam-mcp.md); still **no silent fallback**. Never hide tool errors from the user. + +**Optional prompt after stop (user must reply):** *"Reply with the CAM project to use (id or name from the list), a path to your BPA CSV, or the Java files for a manual migration."* + +## Pattern modules + +Do **not** duplicate the pattern table here. Use **`{best-practices}/SKILL.md` → Pattern Reference Modules** (`references/.md`). + +## Workflow + +### One pattern per session + +If the user asks to fix everything or BPA mixes patterns, **ask which pattern first**. Prefer one commit per pattern session. + +### Step 1: Pattern id + +Map the request to a BPA id: `scheduler`, `resourceChangeListener`, `replication`, `eventListener`, `eventHandler`, `assetApi`. If unclear, use **Manual Pattern Hints** in **`{best-practices}/SKILL.md`** or ask the user to pick one of those. + +### Step 2: Availability + +If the id is missing from the best-practices table, say the pattern is not supported yet. + +### Step 3: BPA targets + +Run **`getBpaFindings`** (with `bpaFilePath` when provided). Internally: cache → CSV → MCP → manual **only when each step is applicable and succeeds**; if MCP fails, obey **MCP errors and fallback** (stop; no silent chain). For MCP details, [references/cam-mcp.md](references/cam-mcp.md). + +### Step 4: Read before edits + +**STOP.** Read **`{best-practices}/SKILL.md`** and **`{best-practices}/references/.md`** for the active pattern. + +### Step 5: Process each file + +Resolve each target **only inside the IDE workspace** (see **Workspace scope (IDE)**). Read source → classify with the module → apply steps **in order** → check lints → next file. + +### Step 6: Report + +Summarize files touched, sub-paths, failures. + +### Manual flow (no BPA) + +User-named files → classify (best-practices hints or ask) → confirm module exists → read **`{best-practices}/SKILL.md`** + module → transform → report. + +## Quick reference + +**Source priority (when choosing how to obtain targets):** unified collection → BPA CSV → MCP → manual paths. **Not** an automatic cascade after MCP errors — if MCP fails, stop and wait for user direction (see **MCP errors and fallback**). + +**User-facing snippets:** *"Using existing BPA collection (N findings)…"* / *"Processing your BPA report…"* / *"Fetched findings from CAM."* / optional prompt after MCP stop above. + +### CLI (development only) + +From this skill’s directory: + +```bash +node scripts/bpa-findings-helper.js scheduler ./unified-collections +node scripts/bpa-findings-helper.js scheduler ./unified-collections ./cleaned_file6.csv +node scripts/unified-collection-reader.js all ./unified-collections +``` diff --git a/skills/aem/cloud-service/skills/migration/references/cam-mcp.md b/skills/aem/cloud-service/skills/migration/references/cam-mcp.md new file mode 100644 index 00000000..a5d5ac7a --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/references/cam-mcp.md @@ -0,0 +1,164 @@ +# CAM / MCP (Cloud Adoption Service) + +Read this file when **fetching BPA targets via MCP** instead of a CSV or cached collection. Parent skill: `../SKILL.md`. + +## Happy path (what the user should see) + +1. Agent calls **`list-projects`** and shows **name**, **id**, and **description**. +2. **You pick** a project (even if only one is listed — confirms the right CAM context). +3. Agent calls **`fetch-cam-bpa-findings`** with that project and the **one pattern** for this session (`scheduler`, `assetApi`, etc., or `all` then filtered). +4. Agent maps returned targets to Java files and continues the migration workflow in `../SKILL.md`. + +### Project name and ID — non-negotiable + +- **Never** call **`fetch-cam-bpa-findings`** with a `projectId` or `projectName` that the user typed until it is **matched to a row** from the latest **`list-projects`** response, **or** the user has explicitly confirmed the intended project after you showed **name**, **id**, and **description** for all listed projects. +- If the user gave a name before you listed projects, still run **`list-projects`**, show the table, and **wait for them to confirm** which project (by id or unambiguous name) — do not assume a fuzzy match. +- **Do not** infer the CAM project from the open workspace, repository name, or sample code (e.g. WKND) when using MCP. + +### MCP errors — stop first (especially project-not-found) + +On **any** MCP failure, **stop the migration workflow immediately**. **Quote the tool error verbatim** in your reply to the user (including 404-style messages such as *No project found matching "…"*). **Do not** continue with BPA processing, manual file migration, or "local codebase" assumptions on your own. + +**Exception:** [enablement restriction errors](#enablement-restriction-errors-mandatory-handling) below — follow that section exactly (verbatim to user; no retry; no silent fallback). + +**After** that verbatim report, you may briefly explain what went wrong (e.g. unknown project name) and show **relevant rows from `list-projects`** if you already have them. **Only** if the user **explicitly** asks to switch approach (e.g. provides a BPA CSV path, picks another project from the list, or names specific Java files for manual flow) may you proceed — that is a **new** user-directed step, not an automatic fallback. + +For other failures (auth, timeout, 5xx), still quote errors verbatim; use retries only where the table below allows, then **stop** and ask how the user wants to continue (CSV, different project, or manual files) — do not silently pivot. + +**Below:** tool shapes and maintainer notes for the agent. You can skip the TypeScript until you need parameter details. + +--- + +## Enablement restriction errors (mandatory handling) + +Some **AEM Cloud Service Migration MCP** deployments return an error when the server is not enabled for the requested org, project, or operation. When the tool error **starts with**: + +`The MCP Server is restricted and isn't able to operate on the given` + +**You must:** + +1. **Output that error message to the user verbatim** — same text, in full, including any contact or enablement details the server appended. Do not paraphrase, summarize, or "translate" it into your own words. +2. **Do not retry** the same tool call to "work around" this response. +3. **Do not silently fall back** to CSV or manual paths as if MCP had merely failed — the user may need to complete enablement or follow the instructions embedded in the error first. After they confirm they have addressed it, you may continue (including retrying MCP if appropriate). + +If your MCP server documentation adds other error prefixes or codes with the same "no paraphrase / no silent fallback" rule, treat those the same way and keep this file aligned with that documentation. + +--- + +## Rules before any tool call + +1. Call **`list-projects`** first; show **name**, **id**, and **description** to the user. +2. **Wait for explicit project choice** (even if only one project), then call **`fetch-cam-bpa-findings`** using the **confirmed** `projectId` (preferred) or a name that the user affirmed against that list — do not pass an unconfirmed string the user guessed. +3. Map the session's **single pattern** to the tool's `pattern` argument (`scheduler`, `assetApi`, `eventListener`, `resourceChangeListener`, `eventHandler`, or `all`). If you used `all`, filter `targets` to the active pattern. + +## REST (maintainer context) + +The MCP server calls **Adobe AEM Cloud Adoption Service**, for example: + +- `GET {base}/projects` — projects for the authenticated IMS org. +- `GET {base}/projects/{projectId}/bpaReportCodeTransformerData/subtype/{subtype}` — aggregated identifiers per BPA subtype (e.g. `sling.commons.scheduler` for scheduler). + +Auth headers typically include `Authorization: Bearer …`, `x-api-key`, and `x-gw-ims-org-id` (often `ident@AdobeOrg`). Subtype mapping is implemented in the MCP server. + +**Deeper docs:** `aemcs-migration-mcp/docs/cam-cloud-adoption-api-contract.md`; controllers `ProjectController`, `BpaReportCodeTransformerDataController` in `aem-cloud-adoption-service`. + +--- + +## Tool: `list-projects` + +Lists CAM projects. **Always call this before `fetch-cam-bpa-findings`.** + +**Response (illustrative):** + +```typescript +{ + success: true; + projects: Array<{ + id: string; + name: string; + description: string; + }>; +} +``` + +--- + +## Tool: `fetch-cam-bpa-findings` + +**Request (illustrative — confirm against live MCP tool schema):** + +```typescript +{ + projectId: string; // required after user confirms project (from list-projects) + projectName?: string; // only if user affirmed this name against list-projects; never pass an unconfirmed guess + pattern?: "scheduler" | "assetApi" | "eventListener" | "resourceChangeListener" | "eventHandler" | "all"; + environment?: "dev" | "stage" | "prod"; + apiToken?: string; + imsOrgId?: string; + apiKey?: string; +} +``` + +**Success response (shape may vary by server version):** + +```typescript +{ + success: true; + environment?: "dev" | "stage" | "prod"; + projectId: string; + targets: Array<{ + pattern: string; + className: string; + identifier: string; + issue: string; + severity?: string; + }>; + summary?: Record; +} +``` + +**Error response:** + +```typescript +{ + success: false; + error: string; + errorDetails?: { message: string; name: string; code?: string }; + troubleshooting?: string[]; + suggestion?: string[]; +} +``` + +**Example:** + +```javascript +const result = await fetchCamBpaFindings({ + projectId: "", + pattern: "scheduler", + environment: "prod" +}); +``` + +--- + +## Retries and agent behavior + +**MCP tool:** Often implements exponential backoff (e.g. up to 3 attempts, ~30s timeout, backoff 2s / 4s / 8s). **Confirm in server implementation** if behavior changes. + +**Agent:** + +1. If the failure matches [Enablement restriction errors](#enablement-restriction-errors-mandatory-handling), handle it **only** as described there (verbatim output; no retry; no silent CSV/manual fallback). +2. Check `result.success` before using `result.targets`. +3. If `pattern` was `all`, filter `targets` to the **one pattern** chosen for this session. +4. Use `className` (and any file paths the server returns) to locate Java sources **only under the current IDE workspace root(s)**. If a path does not exist there, report it and ask the user — do not search outside the open project. +5. On other failures, **stop**; quote the error **verbatim**. Use retries only per the table. **Do not** automatically continue with CSV or manual migration — wait for the user to choose the next step after they have seen the error. + +| Situation | Retry? | Action | +|-----------|--------|--------| +| Error starts with `The MCP Server is restricted and isn't able to operate on the given` | No | [Verbatim to user](#enablement-restriction-errors-mandatory-handling); stop automatic fallback | +| Auth 401 / 403 | No | Quote error verbatim; stop. Ask how to proceed (credentials, CSV, or named files) only after stopping. | +| 404 / "no project found" / unknown `projectId` | No | Quote error verbatim; stop. Show `list-projects` results again if available; **require** user to confirm the correct project or another source (CSV / explicit file list). **No** automatic "local workspace" migration. | +| Network / timeout | Once | Retry after ~2s, then quote error verbatim and stop if still failing. | +| 5xx | Once | Retry after ~2s, then quote error verbatim and stop if still failing. | +| 400 | No | Quote error verbatim; stop; ask user to fix parameters or pick another path. | +| 200 empty targets | No | Report honestly; stop. Offer options (other pattern, CSV, explicit files) **only** as choices for the user — do not start editing the repo without BPA targets unless the user picks manual files. | diff --git a/skills/aem/cloud-service/skills/migration/references/osgi-cfg-json-cloud-manager.md b/skills/aem/cloud-service/skills/migration/references/osgi-cfg-json-cloud-manager.md new file mode 100644 index 00000000..da08fc4e --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/references/osgi-cfg-json-cloud-manager.md @@ -0,0 +1,167 @@ +# OSGi configs: scan → Cloud Manager environment secrets / variables + +**Agent:** The parent skill loads this file for prompts such as *“scan my config files and create Cloud Manager environment secrets or variables.”* Users do **not** name this path. + +The sections below through **Cloud Manager and deployment** reproduce the rules from **Adobe Experience Manager as a Cloud Service** product documentation for configuring OSGi (deploying topic: OSGi configuration with secret and environment-specific values). **Follow them when editing configs or advising on Cloud Manager.** + +--- + +## OSGi in the AEM project + +OSGi manages bundles and their configurations. Settings are defined in **configuration files that are part of the AEM code project**. Cloud Manager is used to configure **environment variables** that back OSGi placeholders. + +### Configuration files (`.cfg.json`) + +- Configuration changes belong in the project’s code packages (**`ui.config`**) as **`.cfg.json`** files under **runmode-specific** config folders, for example under paths like **`/apps//config./`** (in the content tree; in Maven this is commonly under **`ui.config`** / **`osgiconfig`**). +- The format is **JSON**, using the **`.cfg.json`** format defined by the **Apache Sling** OSGi configuration installer. +- OSGi configurations target components by **Persistent Identity (PID)**. The PID usually matches the **fully qualified Java class name** of the OSGi component implementation. Example file path: + `.../config/com.example.workflow.impl.ApprovalWorkflow.cfg.json` +- **Factory configurations** use the **`-.cfg.json`** naming convention. + +**Superseded formats:** Older AEM versions allowed **`.cfg`**, **`.config`**, and XML **`sling:OsgiConfig`**. On AEM as a Cloud Service these are **superseded** by **`.cfg.json`**. This skill’s automated edits apply **only** to **`.cfg.json`** after any legacy content is converted elsewhere. + +**Cloud runtime note:** On AEM as a Cloud Service, effective OSGi configuration is not held like a classic on-prem **`/apps`**-only model; use the environment’s **Developer Console** (Status → Configurations in the status dump) to inspect what is applied. + +### Runmodes (context for configs) + +- **AEM 6.x** allowed **custom runmodes**; **AEM as a Cloud Service does not**. Only the **documented Cloud Service runmode set** applies. Differences between Cloud environments that runmodes cannot express are handled with **OSGi configuration environment variables** (`$[env:…]` / `$[secret:…]`). +- Runmode-specific folders live under **`/apps//`** using names like **`config..`** (and combinations such as **`config.author`**, **`config.author.dev`**). Configs apply when the folder’s runmodes **match** the instance. +- If **multiple** configs apply to the **same PID**, the one with the **highest number of matching runmodes** wins. Resolution is at **PID** level: you **cannot** split properties for the same PID across two folders—one winning file applies to the **whole** PID. +- **Preview:** A **`config.preview`** folder is **not** declared like **`config.publish`**. The **preview** tier **inherits** OSGi configuration from **publish**. +- **Local SDK:** Runmodes can be set at startup, e.g. **`-r publish,dev`** on the quickstart JAR. + +**Verifying effective config:** In Cloud Service, use **Developer Console** → select **Pod** → **Status** → **Status Dump** → **Configurations** → **Get Status**. Match **`pid`** to the **`.cfg.json`** filename and compare **`properties`** to the repo for the runmode under review. + +--- + +## Types of OSGi configuration values + +Three kinds (a single **`.cfg.json`** may mix them): + +1. **Inline values** — hard-coded in JSON and stored in Git, e.g. + `{ "connection.timeout": 1000 }` +2. **Secret values** — must **not** be stored in Git, e.g. + `{ "api-key": "$[secret:server-api-key]" }` +3. **Environment-specific values** — vary between **development** environments in ways runmodes cannot target (Cloud Service has a single **`dev`** runmode), e.g. + `{ "url": "$[env:server-url]" }` + +Example combining all three: + +```json +{ + "connection.timeout": 1000, + "api-key": "$[secret:server-api-key]", + "url": "$[env:server-url]" +} +``` + +### When to use which type + +- **Inline** is the **default**. Prefer inline when possible: values live in Git with history, deploy with code, and need no extra CM coordination. **Start with inline**; use secrets or env-specific placeholders **only when the use case requires it**. + +**`$[env:ENV_VAR_NAME]` (non-secret)** — Use **only** when values differ for **preview vs publish** or **across development** environments (including local SDK and Cloud **dev**). For **Stage and Production**, **avoid** non-secret `$[env:…]` except where preview must differ from publish; use **inline** values in **`config.stage`** / **`config.prod`** for non-secrets. **Do not** use `$[env:…]` to push routine **runtime** changes to Stage/Prod without source control. + +**`$[secret:SECRET_VAR_NAME]`** — **Required** for any **secret** OSGi value (passwords, private API keys, anything that must not be in Git). Use for **all** Cloud environments including **Stage and Production**. + +### Custom code only — no Adobe override + +**`$[env:…]`** must be used **only** for OSGi properties related to **customer custom code**. It **must not** be used to **override Adobe-defined OSGi configuration**. Treat **`$[secret:…]`** the same way for this skill: **do not** introduce placeholders on **Adobe/product** PIDs unless the user explicitly confirms an allowed exception. + +### Repoinit + +**Placeholders cannot be used in repoinit statements.** Do not add `$[secret:…]` or `$[env:…]` to **Repository Initializer** content. Skip **`org.apache.sling.jcr.repoinit.RepositoryInitializer*`** files for placeholder injection. + +### Placeholder syntax + +- Environment (non-secret): **`$[env:ENV_VAR_NAME]`** +- Secret: **`$[secret:SECRET_VAR_NAME]`** + +If no value is set in Cloud Manager, the placeholder may remain unreplaced. **Default** (for both env and secret placeholders): + +```text +$[env:ENV_VAR_NAME;default=] +``` + +(with the same pattern for secrets when a default is appropriate per product behavior). + +### Variable names and values (Cloud Manager) + +Applies to **both** `$[env:…]` and `$[secret:…]` variable names: + +| Rule | Requirement | +|------|-------------| +| Name length | **2–100** characters | +| Name pattern | **`[a-zA-Z_][a-zA-Z_0-9]*`** | +| Value length | Values **must not exceed 2048** characters | +| Count | **Up to 200** variables per environment | + +**Reserved prefixes:** Names starting with **`INTERNAL_`**, **`ADOBE_`**, or **`CONST_`** are reserved—customer variables with those prefixes are **ignored**. Customers **must not reference** **`INTERNAL_`** or **`ADOBE_`** variables. + +**`AEM_`:** Variables with prefix **`AEM_`** are **product-defined** public API. Customers may **use and set** those Adobe provides but **must not define new** custom variables with the **`AEM_`** prefix. + +### Local development + +- **Non-secret `$[env:…]`:** Define normal process environment variables before starting AEM (e.g. `export ENV_VAR_NAME=my_value`). A small shell script run before startup is recommended; non-secret values may be shared in source control if appropriate. +- **`$[secret:…]`:** Each secret needs a **plain text file** named **exactly** after the variable (e.g. for `$[secret:server_password]` a file **`server_password`**). **No file extension.** Store all such files in one directory and set Sling **`org.apache.felix.configadmin.plugin.interpolation.secretsdir`** to that directory in **`crx-quickstart/conf/sling.properties`** (framework property, not Felix web console), e.g. + `org.apache.felix.configadmin.plugin.interpolation.secretsdir=${sling.home}/secretsdir` + +### Author vs publish (same PID, different values) + +Use separate **`config.author`** and **`config.publish`** folders. Prefer the **same variable name** in both with **`$[env:ENV_VAR_NAME;default=`** where the default matches the tier, and bind values per tier in Cloud Manager using the API **`service`** parameter (**author**, **publish**, or **preview**). Alternatively use distinct names such as **`author_`** and **`publish_`**. + +--- + +## Cloud Manager API and CLI + +- **API role:** The Cloud Manager API caller needs **Deployment Manager - Cloud Service** (other roles may not run all operations). +- **Set variables:** **`PATCH /program/{programId}/environment/{environmentId}/variables`** — deploys variables like a pipeline deploy; **author and publish restart** and pick up values after a few minutes. Body is a JSON array of objects with **`name`**, **`value`**, and **`type`**: use **`string`** (default) for **`$[env:…]`**, **`secretString`** for **`$[secret:…]`**. +- **Default values** for interpolation are **not** set via this API—they belong in the **OSGi placeholder** (e.g. `;default=…`). +- **List:** **`GET`** the same **`…/variables`** path. **Delete:** **`PATCH`** with the variable included and an **empty** value. +- **CLI:** `aio cloudmanager:list-environment-variables ENVIRONMENT_ID` + `aio cloudmanager:set-environment-variables ENVIRONMENT_ID --variable NAME "value" --secret NAME "value"` + `aio cloudmanager:set-environment-variables ENVIRONMENT_ID --delete NAME …` + +Environment variables can also be maintained in the **Cloud Manager** UI (**Environment variables**). + +### Deployment and governance + +Secret and env-specific values **live outside Git**. Customers should **govern** them as part of the release process. Variable API calls **do not** run the same **quality gates** as a full code pipeline. Set variables **before** or when deploying code that depends on them. The API may **fail** while a pipeline is running; errors may be non-specific. + +**Additive changes:** Prefer **new variable names** when rotating values so older deployments never pick up wrong values; remove old names only after releases are stable. This also helps **rollbacks** and **disaster recovery** when redeploying older code. + +--- + +## This skill: repository scope and workflow + +**In scope for automated edits** + +- **`.cfg.json` only**, under **`ui.config`** or **`ui.apps/.../jcr_root/...`** where the file’s **parent directory name starts with** `config`. + +**Out of scope for automated edits** + +- Legacy **`.cfg`**, **`.config`**, XML OSGi (convert to **`.cfg.json`** first). +- Repoinit and Adobe-owned OSGi override (see above). +- Reorganizing runmode folder structure. + +**One-prompt workflow** + +1. Glob scoped **`.cfg.json`** as above. +2. For **custom** PIDs only: replace high-confidence **secrets** with **`"$[secret:VAR]"`**; replace eligible **non-secrets** with **`"$[env:VAR]"`** only when Adobe’s rules above allow. Skip ambiguous or Adobe-owned configs → **`needs_user_review`** in the handoff file. +3. Write gitignored **`cloudmanager-osgi-secrets.local.json`** with **`variables`**: **`name`**, **`value`**, **`cm_type`** (`secretString` | `string`), **`placeholder`**, **`cfg_json_path`**, **`json_property`**; optional **`needs_user_review`**. Respect **200** variables and **2048**-char values. +4. Do **not** print secret values in chat. Remind user: set CM variables, then **delete** the handoff file; **git history** may still contain old secrets. + +### Handoff file (security) + +- Filename suggestion: **`cloudmanager-osgi-secrets.local.json`** at AEM repo root. +- Include **`_do_not_commit`** warning; add **`.gitignore`** entry if missing. +- User deletes file after Cloud Manager is updated. + +### Detection heuristics (agent) + +Treat as secret candidates when keys suggest sensitivity (`password`, `secret`, `apikey`, `token`, `clientsecret`, `credential`, `privatekey`, etc.) or values are obviously secret; exclude hostnames, public IDs, and non-secret flags unless keys indicate otherwise. + +--- + +## One-line summary + +**Scan** scoped **`.cfg.json`** → apply Adobe rules above for **`$[secret:]`** / **`$[env:]`** on **custom** properties only → gitignored handoff with **`cm_type`** → **no** secrets in chat. diff --git a/skills/aem/cloud-service/skills/migration/scripts/README.md b/skills/aem/cloud-service/skills/migration/scripts/README.md new file mode 100644 index 00000000..f51d4090 --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/scripts/README.md @@ -0,0 +1,115 @@ +# BPA Local Collection Scripts + +Scripts for managing BPA (Best Practices Analyzer) findings locally. The skill handles these automatically — you should not need to run them manually during normal use. + +## How It Works + +When the skill needs BPA findings, it calls `bpa-findings-helper.js` which handles everything: + +``` +User provides BPA CSV path? + │ + ┌────┴────────────────────────────────────┐ + │ │ + ▼ ▼ +Collection already exists? No CSV path given + ┌────┴────┐ ┌────┴────┐ + YES NO YES NO + │ │ │ │ + ▼ ▼ Collection Try MCP + Use it Parse CSV & exists? server + directly create collection Use it or ask user +``` + +**Key behavior:** +- BPA CSV files are parsed **once** and saved as a unified collection +- Subsequent runs reuse the existing collection instantly +- If a new BPA file is provided when a collection exists, the skill asks the user which to use + +## Scripts + +### `bpa-findings-helper.js` (main entry point) + +Orchestrates finding BPA data. Called by the skill internally. + +```javascript +const { getBpaFindings } = require('./scripts/bpa-findings-helper.js'); + +const result = await getBpaFindings('scheduler', { + bpaFilePath: './cleaned_file6.csv', // optional + collectionsDir: './unified-collections', // default + projectId: '...', // optional, for MCP + mcpFetcher: mcpFunction // optional, for MCP +}); + +// result.success → true/false +// result.source → 'unified-collection' | 'bpa-file' | 'mcp-server' | error +// result.message → human-readable status string +// result.targets → array of BPA findings (when successful) +``` + +### `bpa-local-parser.js` + +Parses BPA CSV files and creates unified collections. Used internally by the helper, but can also be run directly: + +```bash +node bpa-local-parser.js [output-directory] +``` + +### `unified-collection-reader.js` + +Reads unified collections and returns findings. Used internally by the helper, but can also be run directly: + +```bash +node unified-collection-reader.js [pattern] [collections-directory] +``` + +## Testing + +```bash +# From the scripts directory +cd scripts +npm run parse-bpa -- [output-dir] +npm run read-unified -- [pattern] [collections-dir] +``` + +## File Formats + +### Input: BPA CSV + +```csv +code,type,subtype,importance,identifier,message,context +DG,development.guideline,unsupported.asset.api,MAJOR,com.example.MyClass,Uses deprecated API... +``` + +### Output: Unified Collection (`unified-collection.json`) + +Matches the cloud-adoption-service format with MongoDB-safe field names (dots → underscores). Includes metadata (timestamp, totalFindings) for display: + +```json +{ + "subtypes": { + "unsupported_asset_api": { + "com_day_cq_dam_api_AssetManager_createAsset": [ + "com.example.MyClass" + ] + } + }, + "meta": { + "timestamp": "2026-03-18T06:16:37.755Z", + "source": "local-bpa-parser", + "totalFindings": 1, + "subtypeCount": 1 + } +} +``` + +## Supported Patterns + +| Pattern | BPA Subtype | +|---------|------------| +| scheduler | sling.commons.scheduler | +| assetApi | unsupported.asset.api | +| eventListener | javax.jcr.observation.EventListener | +| resourceChangeListener | org.apache.sling.api.resource.observation.ResourceChangeListener | +| eventHandler | org.osgi.service.event.EventHandler | diff --git a/skills/aem/cloud-service/skills/migration/scripts/bpa-findings-helper.js b/skills/aem/cloud-service/skills/migration/scripts/bpa-findings-helper.js new file mode 100644 index 00000000..f1fa223a --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/scripts/bpa-findings-helper.js @@ -0,0 +1,268 @@ +/** + * BPA Findings Helper + * + * Provides a seamless interface for getting BPA findings. The skill calls this + * helper and it handles everything automatically: + * + * 1. Check if unified collection already exists → use it + * 2. If not, and BPA CSV path provided → parse it, create collection, then use it + * 3. If no BPA path → try MCP server + * 4. If nothing available → return guidance for manual flow + */ + +const path = require('path'); +const fs = require('fs'); +const { hasUnifiedCollection, fetchUnifiedBpaFindings, getAvailablePatterns, getUnifiedCollectionSummary } = require('./unified-collection-reader.js'); +const { validateBpaFile, parseBpaFile, createUnifiedCollection } = require('./bpa-local-parser.js'); + +const DEFAULT_COLLECTIONS_DIR = './unified-collections'; + +/** + * Get BPA findings with automatic collection management. + * + * Flow: + * 1. Unified collection exists? → Use it directly + * 2. BPA CSV file provided? → Create collection, then use it + * 3. MCP server available? → Fetch from MCP + * 4. Nothing available → Return manual flow guidance + * + * @param {string} pattern - Pattern to fetch ('scheduler', 'assetApi', 'all', etc.) + * @param {Object} options + * @param {string} [options.bpaFilePath] - Path to BPA CSV file + * @param {string} [options.collectionsDir] - Where to store/read unified collections + * @param {string} [options.projectId] - Cloud Manager project ID (for MCP fallback) + * @param {string} [options.environment] - Environment (for MCP) + * @param {Function} [options.mcpFetcher] - MCP fetcher function + * @returns {Object} BPA findings result with `source` and `message` fields + */ +async function getBpaFindings(pattern = 'all', options = {}) { + const { + bpaFilePath, + collectionsDir = DEFAULT_COLLECTIONS_DIR, + projectId, + environment = 'prod', + mcpFetcher + } = options; + + // ── 1. Check for existing unified collection ── + if (hasUnifiedCollection(collectionsDir)) { + const availablePatterns = getAvailablePatterns(collectionsDir); + const hasPattern = pattern === 'all' + ? availablePatterns.length > 0 + : availablePatterns.includes(pattern); + + if (hasPattern) { + const summary = getUnifiedCollectionSummary(collectionsDir); + const result = fetchUnifiedBpaFindings(pattern, collectionsDir); + result.source = 'unified-collection'; + result.message = `Using existing BPA collection (${summary?.totalFindings || result.targets.length} findings across ${availablePatterns.length} patterns, created ${formatTimestamp(summary?.timestamp)})`; + return result; + } + } + + // ── 2. BPA CSV file provided → parse and create collection ── + if (bpaFilePath) { + try { + validateBpaFile(bpaFilePath); + + const bpaData = parseBpaFile(bpaFilePath); + const summary = createUnifiedCollection(bpaData, collectionsDir); + + const availablePatterns = getAvailablePatterns(collectionsDir); + const hasPattern = pattern === 'all' + ? availablePatterns.length > 0 + : availablePatterns.includes(pattern); + + if (!hasPattern) { + return { + success: false, + source: 'bpa-file', + error: `Pattern '${pattern}' not found in BPA report`, + message: `Processed BPA report but pattern '${pattern}' was not found. Available patterns: ${availablePatterns.join(', ')}`, + availablePatterns + }; + } + + const result = fetchUnifiedBpaFindings(pattern, collectionsDir); + result.source = 'bpa-file'; + result.message = `Processed BPA report (${summary?.totalFindings || 0} findings across ${summary?.subtypes?.length || 0} patterns). Collection saved for future use.`; + return result; + } catch (error) { + return { + success: false, + source: 'bpa-file-error', + error: `Failed to process BPA file: ${error.message}`, + message: `Could not process BPA file at ${bpaFilePath}: ${error.message}`, + troubleshooting: [ + 'Verify the file exists and is a valid BPA CSV', + 'Expected CSV headers: code, type, subtype, importance, identifier, message, context' + ] + }; + } + } + + // ── 3. Try MCP server ── + if (mcpFetcher && projectId) { + try { + const result = await mcpFetcher({ projectId, pattern, environment }); + if (result) { + result.source = 'mcp-server'; + result.message = `Fetched findings from MCP server (project: ${projectId})`; + return result; + } + } catch (error) { + return { + success: false, + source: 'mcp-error', + error: `MCP server error: ${error.message}`, + message: `Could not fetch from MCP server: ${error.message}`, + troubleshooting: [ + 'Check MCP server connectivity', + 'Verify project ID and credentials', + 'Provide a BPA CSV file path as an alternative' + ] + }; + } + } + + // ── 4. Nothing available → guide the user ── + return { + success: false, + source: 'no-source', + error: 'No BPA findings source available', + message: 'No BPA data found. Please provide a BPA CSV file path, or use the manual flow for specific files.', + troubleshooting: [ + 'Provide the path to your BPA CSV report', + 'Or provide MCP server access with projectId', + 'Or point to specific Java files for manual migration' + ] + }; +} + +/** + * Check what BPA sources are currently available. + */ +function checkAvailableSources(options = {}) { + const { + bpaFilePath, + collectionsDir = DEFAULT_COLLECTIONS_DIR, + mcpFetcher, + projectId + } = options; + + const sources = { + unifiedCollection: { + available: hasUnifiedCollection(collectionsDir), + patterns: [], + path: collectionsDir, + summary: null + }, + bpaFile: { + available: false, + path: bpaFilePath || null + }, + mcpServer: { + available: !!(mcpFetcher && projectId), + projectId: projectId || null + } + }; + + if (sources.unifiedCollection.available) { + sources.unifiedCollection.patterns = getAvailablePatterns(collectionsDir); + sources.unifiedCollection.summary = getUnifiedCollectionSummary(collectionsDir); + } + + if (bpaFilePath) { + try { + validateBpaFile(bpaFilePath); + sources.bpaFile.available = true; + } catch (e) { + sources.bpaFile.available = false; + sources.bpaFile.error = e.message; + } + } + + return sources; +} + +/** + * Format an ISO timestamp into a human-readable relative string. + */ +function formatTimestamp(isoTimestamp) { + if (!isoTimestamp) return 'unknown date'; + try { + const date = new Date(isoTimestamp); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + } catch { + return isoTimestamp; + } +} + +/** + * CLI interface for testing + */ +async function main() { + const args = process.argv.slice(2); + const pattern = args[0] || 'all'; + const collectionsDir = args[1] || DEFAULT_COLLECTIONS_DIR; + const bpaFilePath = args[2]; + + console.log('BPA Findings Helper'); + console.log('=================='); + console.log(`Pattern: ${pattern}`); + console.log(`Collections Dir: ${collectionsDir}`); + if (bpaFilePath) console.log(`BPA File: ${bpaFilePath}`); + console.log(''); + + const sources = checkAvailableSources({ collectionsDir, bpaFilePath }); + + console.log('Available Sources:'); + console.log(` Unified Collection: ${sources.unifiedCollection.available ? '✅' : '❌'}`); + if (sources.unifiedCollection.available) { + console.log(` Patterns: ${sources.unifiedCollection.patterns.join(', ')}`); + console.log(` Created: ${sources.unifiedCollection.summary?.timestamp || 'unknown'}`); + } + console.log(` BPA File: ${sources.bpaFile.available ? '✅' : '❌'} ${sources.bpaFile.path || '(not provided)'}`); + console.log(` MCP Server: ${sources.mcpServer.available ? '✅' : '❌'}`); + console.log(''); + + const result = await getBpaFindings(pattern, { collectionsDir, bpaFilePath }); + + console.log(`Source: ${result.source}`); + console.log(`Message: ${result.message}`); + console.log(''); + + if (result.success) { + console.log(`✅ Loaded ${result.targets.length} findings`); + if (result.summary) { + Object.entries(result.summary).forEach(([key, value]) => { + console.log(` ${key}: ${value}`); + }); + } + } else { + console.error(`❌ ${result.error}`); + if (result.troubleshooting?.length > 0) { + console.error(''); + console.error('Troubleshooting:'); + result.troubleshooting.forEach(tip => console.error(` - ${tip}`)); + } + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + getBpaFindings, + checkAvailableSources +}; diff --git a/skills/aem/cloud-service/skills/migration/scripts/bpa-findings-helper.test.js b/skills/aem/cloud-service/skills/migration/scripts/bpa-findings-helper.test.js new file mode 100644 index 00000000..fde5af4c --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/scripts/bpa-findings-helper.test.js @@ -0,0 +1,77 @@ +/** + * Unit tests for BPA findings helper (node --test). + */ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { getBpaFindings, checkAvailableSources } = require('./bpa-findings-helper.js'); + +function tempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'bpa-helper-test-')); +} + +test('getBpaFindings with no CSV, collection, or MCP returns no-source', async () => { + const dir = tempDir(); + const result = await getBpaFindings('scheduler', { collectionsDir: dir }); + assert.equal(result.success, false); + assert.equal(result.source, 'no-source'); +}); + +test('getBpaFindings uses mcpFetcher when projectId is set', async () => { + const dir = tempDir(); + const mcpFetcher = async () => ({ + success: true, + targets: [ + { + pattern: 'scheduler', + className: 'com.example.Job', + identifier: 'org.apache.sling.commons.scheduler', + issue: 'test' + } + ] + }); + const result = await getBpaFindings('scheduler', { + collectionsDir: dir, + projectId: 'proj-1', + mcpFetcher + }); + assert.equal(result.success, true); + assert.equal(result.source, 'mcp-server'); + assert.equal(result.targets.length, 1); + assert.equal(result.targets[0].className, 'com.example.Job'); +}); + +test('getBpaFindings ingests BPA CSV into empty collections dir', async () => { + const dir = tempDir(); + const csvPath = path.join(__dirname, 'fixtures', 'minimal-scheduler-bpa.csv'); + const result = await getBpaFindings('scheduler', { + collectionsDir: dir, + bpaFilePath: csvPath + }); + assert.equal(result.success, true); + assert.equal(result.source, 'bpa-file'); + assert.ok(Array.isArray(result.targets)); + assert.ok(result.targets.length >= 1); + assert.ok( + result.targets.some( + (t) => t.pattern === 'scheduler' && t.className.includes('SampleJob') + ) + ); +}); + +test('checkAvailableSources reflects MCP only when fetcher and projectId present', () => { + const dir = tempDir(); + const noMcp = checkAvailableSources({ collectionsDir: dir }); + assert.equal(noMcp.mcpServer.available, false); + + const withMcp = checkAvailableSources({ + collectionsDir: dir, + mcpFetcher: async () => ({}), + projectId: 'p' + }); + assert.equal(withMcp.mcpServer.available, true); +}); diff --git a/skills/aem/cloud-service/skills/migration/scripts/bpa-local-parser.js b/skills/aem/cloud-service/skills/migration/scripts/bpa-local-parser.js new file mode 100644 index 00000000..04f47ad7 --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/scripts/bpa-local-parser.js @@ -0,0 +1,573 @@ +#!/usr/bin/env node + +/** + * BPA Local Parser Script + * + * Reads BPA CSV files from the local filesystem and creates a unified code transformer collection + * that matches the cloud-adoption-service format. + * + * Usage: + * node bpa-local-parser.js [output-directory] + * + * Example: + * node bpa-local-parser.js ./cleaned_file6.csv ./unified-collections + */ + +const fs = require('fs'); +const path = require('path'); + +// Pattern to subtype mapping (matching cam-bpa-fetcher.ts) +const PATTERN_TO_SUBTYPE = { + scheduler: "sling.commons.scheduler", + assetApi: "unsupported.asset.api", +}; + +// CSV subtype to pattern mapping (based on actual CSV structure) +const CSV_SUBTYPE_TO_PATTERN = { + "unsupported.asset.api": "assetApi", + "javax.jcr.observation.EventListener": "eventListener", + "org.apache.sling.api.resource.observation.ResourceChangeListener": "resourceChangeListener", + "org.osgi.service.event.EventHandler": "eventHandler" +}; + +// Known scheduler identifier +const SCHEDULER_IDENTIFIER = "org.apache.sling.commons.scheduler"; + +/** + * Parse command line arguments + */ +function parseArgs() { + const args = process.argv.slice(2); + + if (args.length < 1) { + console.error('Usage: node bpa-local-parser.js [output-directory]'); + console.error(''); + console.error('Examples:'); + console.error(' node bpa-local-parser.js ./cleaned_file6.csv'); + console.error(' node bpa-local-parser.js ./cleaned_file6.csv ./unified-collections'); + process.exit(1); + } + + return { + bpaFilePath: args[0], + outputDir: args[1] || './unified-collections' + }; +} + +/** + * Validate BPA file exists and is readable + */ +function validateBpaFile(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`BPA file not found: ${filePath}`); + } + + const stats = fs.statSync(filePath); + if (!stats.isFile()) { + throw new Error(`Path is not a file: ${filePath}`); + } + + // Check if it's readable + try { + fs.accessSync(filePath, fs.constants.R_OK); + } catch (error) { + throw new Error(`BPA file is not readable: ${filePath}`); + } +} + +/** + * Parse CSV line respecting quoted fields + */ +function parseCSVLine(line) { + const result = []; + let current = ''; + let inQuotes = false; + let i = 0; + + while (i < line.length) { + const char = line[i]; + + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + // Escaped quote + current += '"'; + i += 2; + } else { + // Toggle quote state + inQuotes = !inQuotes; + i++; + } + } else if (char === ',' && !inQuotes) { + // Field separator + result.push(current); + current = ''; + i++; + } else { + current += char; + i++; + } + } + + // Add the last field + result.push(current); + return result; +} + +/** + * Parse BPA CSV file + */ +function parseBpaFile(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + const lines = content.split('\n').filter(line => line.trim()); + + if (lines.length === 0) { + throw new Error('Empty BPA file'); + } + + // Parse header + const headers = parseCSVLine(lines[0]); + console.log(`CSV Headers: ${headers.join(', ')}`); + + // Validate expected headers + const expectedHeaders = ['code', 'type', 'subtype', 'importance', 'identifier', 'message', 'context']; + const hasRequiredHeaders = expectedHeaders.every(header => headers.includes(header)); + + if (!hasRequiredHeaders) { + console.warn('CSV headers do not match expected format, proceeding with available headers'); + } + + // Parse data rows + const findings = []; + for (let i = 1; i < lines.length; i++) { + const values = parseCSVLine(lines[i]); + if (values.length >= headers.length) { + const finding = {}; + headers.forEach((header, index) => { + finding[header] = values[index] || ''; + }); + findings.push(finding); + } + } + + console.log(`Parsed ${findings.length} findings from CSV`); + return { findings, headers }; + } catch (error) { + throw new Error(`Error parsing BPA CSV file: ${error.message}`); + } +} + +/** + * Extract findings from BPA CSV data + */ +function extractFindings(bpaData) { + const findings = bpaData.findings || []; + + if (findings.length === 0) { + console.warn('No findings found in BPA CSV data'); + return []; + } + + console.log(`Found ${findings.length} findings in BPA CSV report`); + return findings; +} + +/** + * Process scheduler findings from CSV + */ +function processSchedulerFindings(findings) { + const schedulerFindings = findings.filter(finding => + finding.subtype === 'sling.commons.scheduler' + ); + + const identifiers = {}; + const classNames = []; + + schedulerFindings.forEach(finding => { + // Extract class name from identifier + const className = extractClassNameFromCsvFinding(finding); + if (className && !classNames.includes(className)) { + classNames.push(className); + } + }); + + if (classNames.length > 0) { + identifiers[SCHEDULER_IDENTIFIER] = classNames; + } + + return { + subtype: PATTERN_TO_SUBTYPE.scheduler, + identifiers: identifiers + }; +} + +/** + * Process asset API findings from CSV + */ +function processAssetApiFindings(findings) { + const assetApiFindings = findings.filter(finding => + finding.subtype === 'unsupported.asset.api' + ); + + const identifiers = {}; + + assetApiFindings.forEach(finding => { + // Extract class name from identifier (full path) + const className = finding.identifier && isValidClassName(finding.identifier) ? finding.identifier.trim() : null; + + // Extract API method from message + const apiMethod = extractAssetApiMethodFromMessage(finding.message); + + if (className && apiMethod) { + if (!identifiers[apiMethod]) { + identifiers[apiMethod] = []; + } + + if (!identifiers[apiMethod].includes(className)) { + identifiers[apiMethod].push(className); + } + } + }); + + return { + subtype: PATTERN_TO_SUBTYPE.assetApi, + identifiers: identifiers + }; +} + +/** + * Returns true if the value is a valid class name (excludes numeric-only values like "1", "4", "10"). + */ +function isValidClassName(value) { + if (!value || typeof value !== 'string') return false; + const trimmed = value.trim(); + if (!trimmed) return false; + // Exclude pure numbers (counts, line numbers, etc.) + if (/^\d+$/.test(trimmed)) return false; + return true; +} + +/** + * Extract full class identifier from CSV finding (returns complete path, not trimmed). + */ +function extractClassNameFromCsvFinding(finding) { + // Prefer identifier field - return full path as-is + if (finding.identifier && isValidClassName(finding.identifier)) { + return finding.identifier.trim(); + } + + // Try to extract from message if identifier is not useful + if (finding.message) { + const classMatch = finding.message.match(/class\s+([a-zA-Z0-9_.]+)/); + if (classMatch) { + const className = classMatch[1]; + if (isValidClassName(className)) return className; + } + } + + return finding.identifier && isValidClassName(finding.identifier) ? finding.identifier.trim() : null; +} + +/** + * Extract Asset API method from message + */ +function extractAssetApiMethodFromMessage(message) { + if (!message) return null; + + // Look for specific API methods mentioned in the message + const apiMethods = [ + 'com.day.cq.dam.api.AssetManager.createAsset', + 'com.day.cq.dam.api.AssetManager.removeAssetForBinary', + 'com.day.cq.dam.api.AssetManager.createAssetForBinary' + ]; + + for (const method of apiMethods) { + if (message.includes(method)) { + return method; + } + } + + // Generic fallback + if (message.includes('AssetManager')) { + return 'com.day.cq.dam.api.AssetManager'; + } + + return null; +} + +/** + * Process event listener findings from CSV + */ +function processEventListenerFindings(findings) { + const eventListenerFindings = findings.filter(finding => + finding.subtype === 'javax.jcr.observation.EventListener' + ); + + const identifiers = {}; + const classNames = []; + + eventListenerFindings.forEach(finding => { + const className = extractClassNameFromCsvFinding(finding); + if (className && !classNames.includes(className)) { + classNames.push(className); + } + }); + + if (classNames.length > 0) { + identifiers['javax.jcr.observation.EventListener'] = classNames; + } + + return { + subtype: 'javax.jcr.observation.EventListener', + identifiers: identifiers + }; +} + +/** + * Process resource change listener findings from CSV + */ +function processResourceChangeListenerFindings(findings) { + const resourceChangeListenerFindings = findings.filter(finding => + finding.subtype === 'org.apache.sling.api.resource.observation.ResourceChangeListener' + ); + + const identifiers = {}; + const classNames = []; + + resourceChangeListenerFindings.forEach(finding => { + const className = extractClassNameFromCsvFinding(finding); + if (className && !classNames.includes(className)) { + classNames.push(className); + } + }); + + if (classNames.length > 0) { + identifiers['org.apache.sling.api.resource.observation.ResourceChangeListener'] = classNames; + } + + return { + subtype: 'org.apache.sling.api.resource.observation.ResourceChangeListener', + identifiers: identifiers + }; +} + +/** + * Process event handler findings from CSV + */ +function processEventHandlerFindings(findings) { + const eventHandlerFindings = findings.filter(finding => + finding.subtype === 'org.osgi.service.event.EventHandler' + ); + + const identifiers = {}; + const classNames = []; + + eventHandlerFindings.forEach(finding => { + const className = extractClassNameFromCsvFinding(finding); + if (className && !classNames.includes(className)) { + classNames.push(className); + } + }); + + if (classNames.length > 0) { + identifiers['org.osgi.service.event.EventHandler'] = classNames; + } + + return { + subtype: 'org.osgi.service.event.EventHandler', + identifiers: identifiers + }; +} + +/** + * Convert subtype to MongoDB-safe field name (matching cloud-adoption-service) + */ +function toMongoSafeFieldName(fieldName) { + return fieldName ? fieldName.replace(/\./g, '_') : null; +} + +/** + * Convert identifier to MongoDB-safe field name (matching cloud-adoption-service) + */ +function toMongoSafeIdentifier(identifier) { + return identifier ? identifier.replace(/\./g, '_') : null; +} + +/** + * Create unified collection structure (matching cloud-adoption-service format) + */ +function createUnifiedCollection(bpaData, outputDir) { + const findings = extractFindings(bpaData); + + if (findings.length === 0) { + console.warn('No findings to process'); + return; + } + + // Ensure output directory exists + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Initialize unified subtypes structure + const subtypes = {}; + let totalFindings = 0; + + // Process scheduler findings + const schedulerCollection = processSchedulerFindings(findings); + if (Object.keys(schedulerCollection.identifiers).length > 0) { + const mongoSafeSubtype = toMongoSafeFieldName(schedulerCollection.subtype); + subtypes[mongoSafeSubtype] = {}; + + Object.entries(schedulerCollection.identifiers).forEach(([identifier, classNames]) => { + const mongoSafeIdentifier = toMongoSafeIdentifier(identifier); + subtypes[mongoSafeSubtype][mongoSafeIdentifier] = classNames; + totalFindings += classNames.length; + }); + + console.log(`Found ${Object.values(schedulerCollection.identifiers).flat().length} scheduler classes`); + } + + // Process asset API findings + const assetApiCollection = processAssetApiFindings(findings); + if (Object.keys(assetApiCollection.identifiers).length > 0) { + const mongoSafeSubtype = toMongoSafeFieldName(assetApiCollection.subtype); + subtypes[mongoSafeSubtype] = {}; + + Object.entries(assetApiCollection.identifiers).forEach(([identifier, classNames]) => { + const mongoSafeIdentifier = toMongoSafeIdentifier(identifier); + subtypes[mongoSafeSubtype][mongoSafeIdentifier] = classNames; + totalFindings += classNames.length; + }); + + console.log(`Found ${Object.values(assetApiCollection.identifiers).flat().length} asset API classes`); + } + + // Process event listener findings + const eventListenerCollection = processEventListenerFindings(findings); + if (Object.keys(eventListenerCollection.identifiers).length > 0) { + const mongoSafeSubtype = toMongoSafeFieldName(eventListenerCollection.subtype); + subtypes[mongoSafeSubtype] = {}; + + Object.entries(eventListenerCollection.identifiers).forEach(([identifier, classNames]) => { + const mongoSafeIdentifier = toMongoSafeIdentifier(identifier); + subtypes[mongoSafeSubtype][mongoSafeIdentifier] = classNames; + totalFindings += classNames.length; + }); + + console.log(`Found ${Object.values(eventListenerCollection.identifiers).flat().length} event listener classes`); + } + + // Process resource change listener findings + const resourceChangeListenerCollection = processResourceChangeListenerFindings(findings); + if (Object.keys(resourceChangeListenerCollection.identifiers).length > 0) { + const mongoSafeSubtype = toMongoSafeFieldName(resourceChangeListenerCollection.subtype); + subtypes[mongoSafeSubtype] = {}; + + Object.entries(resourceChangeListenerCollection.identifiers).forEach(([identifier, classNames]) => { + const mongoSafeIdentifier = toMongoSafeIdentifier(identifier); + subtypes[mongoSafeSubtype][mongoSafeIdentifier] = classNames; + totalFindings += classNames.length; + }); + + console.log(`Found ${Object.values(resourceChangeListenerCollection.identifiers).flat().length} resource change listener classes`); + } + + // Process event handler findings + const eventHandlerCollection = processEventHandlerFindings(findings); + if (Object.keys(eventHandlerCollection.identifiers).length > 0) { + const mongoSafeSubtype = toMongoSafeFieldName(eventHandlerCollection.subtype); + subtypes[mongoSafeSubtype] = {}; + + Object.entries(eventHandlerCollection.identifiers).forEach(([identifier, classNames]) => { + const mongoSafeIdentifier = toMongoSafeIdentifier(identifier); + subtypes[mongoSafeSubtype][mongoSafeIdentifier] = classNames; + totalFindings += classNames.length; + }); + + console.log(`Found ${Object.values(eventHandlerCollection.identifiers).flat().length} event handler classes`); + } + + // Create unified collection structure with metadata + const subtypeKeys = Object.keys(subtypes); + const unifiedCollection = { + subtypes: subtypes, + meta: { + timestamp: new Date().toISOString(), + source: 'local-bpa-parser', + totalFindings: totalFindings, + subtypeCount: subtypeKeys.length + } + }; + + // Write unified collection file + const unifiedPath = path.join(outputDir, 'unified-collection.json'); + fs.writeFileSync(unifiedPath, JSON.stringify(unifiedCollection, null, 2)); + console.log(`Created unified collection file: ${unifiedPath}`); + + return { + subtypes: subtypeKeys, + totalFindings, + unifiedCollection + }; +} + + +/** + * Main function + */ +function main() { + try { + const { bpaFilePath, outputDir } = parseArgs(); + + console.log('BPA Local Parser'); + console.log('================'); + console.log(`BPA File: ${bpaFilePath}`); + console.log(`Output Directory: ${outputDir}`); + console.log('Format: Unified (cloud-adoption-service compatible)'); + console.log(''); + + // Validate input file + validateBpaFile(bpaFilePath); + + // Parse BPA file + console.log('Parsing BPA CSV file...'); + const bpaData = parseBpaFile(bpaFilePath); + + // Create unified collection + console.log('Creating unified collection...'); + const summary = createUnifiedCollection(bpaData, outputDir); + + console.log(''); + console.log('✅ Successfully created unified code transformer collection'); + console.log(`📁 Output directory: ${outputDir}`); + console.log(`📊 Total subtypes: ${summary?.subtypes?.length || 0}`); + console.log(`🎯 Total findings: ${summary?.totalFindings || 0}`); + + if (summary?.subtypes?.length > 0) { + console.log(''); + console.log('Available subtypes:'); + summary.subtypes.forEach(subtype => { + const subtypeData = summary.unifiedCollection?.subtypes?.[subtype]; + const count = subtypeData ? Object.values(subtypeData).flat().length : 0; + console.log(` - ${subtype}: ${count} classes`); + }); + } + + } catch (error) { + console.error('❌ Error:', error.message); + process.exit(1); + } +} + +// Run the script if called directly +if (require.main === module) { + main(); +} + +module.exports = { + validateBpaFile, + parseBpaFile, + createUnifiedCollection, + extractFindings +}; diff --git a/skills/aem/cloud-service/skills/migration/scripts/fixtures/minimal-scheduler-bpa.csv b/skills/aem/cloud-service/skills/migration/scripts/fixtures/minimal-scheduler-bpa.csv new file mode 100644 index 00000000..7098ab82 --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/scripts/fixtures/minimal-scheduler-bpa.csv @@ -0,0 +1,2 @@ +code,type,subtype,importance,identifier,message,context +BPA-001,issue,sling.commons.scheduler,high,com.example.myapp.jobs.SampleJob,Uses Apache Sling scheduler in SampleJob, diff --git a/skills/aem/cloud-service/skills/migration/scripts/package-lock.json b/skills/aem/cloud-service/skills/migration/scripts/package-lock.json new file mode 100644 index 00000000..72164500 --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/scripts/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "migration-scripts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "migration-scripts", + "version": "1.0.0", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/skills/aem/cloud-service/skills/migration/scripts/package.json b/skills/aem/cloud-service/skills/migration/scripts/package.json new file mode 100644 index 00000000..35a68785 --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/scripts/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-scripts", + "version": "1.0.0", + "description": "BPA findings helper and parser for AEM migration skill", + "scripts": { + "parse-bpa": "node bpa-local-parser.js", + "read-unified": "node unified-collection-reader.js", + "check-sources": "node bpa-findings-helper.js", + "test": "node --test bpa-findings-helper.test.js" + }, + "engines": { + "node": ">=14.0.0" + } +} diff --git a/skills/aem/cloud-service/skills/migration/scripts/unified-collection-reader.js b/skills/aem/cloud-service/skills/migration/scripts/unified-collection-reader.js new file mode 100644 index 00000000..d2a83cb7 --- /dev/null +++ b/skills/aem/cloud-service/skills/migration/scripts/unified-collection-reader.js @@ -0,0 +1,480 @@ +/** + * Unified Collection Reader + * + * Reads unified code transformer collections (cloud-adoption-service format) + * and returns data in the same format as the CAM BPA fetcher MCP tool. + */ + +const fs = require('fs'); +const path = require('path'); + +// Pattern to subtype mapping (matching cam-bpa-fetcher.ts) +const PATTERN_TO_SUBTYPE = { + scheduler: "sling.commons.scheduler", + assetApi: "unsupported.asset.api", + eventListener: "javax.jcr.observation.EventListener", + resourceChangeListener: "org.apache.sling.api.resource.observation.ResourceChangeListener", + eventHandler: "org.osgi.service.event.EventHandler" +}; + +// MongoDB-safe to pattern mapping +const MONGO_SAFE_TO_PATTERN = { + "sling_commons_scheduler": "scheduler", + "unsupported_asset_api": "assetApi", + "javax_jcr_observation_EventListener": "eventListener", + "org_apache_sling_api_resource_observation_ResourceChangeListener": "resourceChangeListener", + "org_osgi_service_event_EventHandler": "eventHandler" +}; + +// Known scheduler identifier +const SCHEDULER_IDENTIFIER = "org.apache.sling.commons.scheduler"; + +/** + * BPA Target type (matching cam-bpa-fetcher.ts) + */ +class BpaTarget { + constructor(pattern, className, identifier, issue, severity = "high") { + this.pattern = pattern; + this.className = className; + this.identifier = identifier; + this.issue = issue; + this.severity = severity; + } +} + +/** + * BPA Result type (matching cam-bpa-fetcher.ts) + */ +class BpaResult { + constructor(success = false) { + this.success = success; + this.environment = 'local'; + this.projectId = 'unified-collection'; + this.targets = []; + this.summary = {}; + this.error = null; + this.errorDetails = null; + this.troubleshooting = []; + this.suggestion = []; + } +} + +/** + * Convert MongoDB-safe field name back to original format + */ +function fromMongoSafeFieldName(mongoSafeFieldName) { + return mongoSafeFieldName ? mongoSafeFieldName.replace(/_/g, '.') : null; +} + +/** + * Check if unified collection exists + */ +function hasUnifiedCollection(collectionsDir = './unified-collections') { + const unifiedPath = path.join(collectionsDir, 'unified-collection.json'); + try { + return fs.existsSync(unifiedPath) && fs.statSync(unifiedPath).isFile(); + } catch (error) { + return false; + } +} + +/** + * Get available patterns in unified collection + */ +function getAvailablePatterns(collectionsDir = './unified-collections') { + if (!hasUnifiedCollection(collectionsDir)) { + return []; + } + + try { + const unifiedCollection = readUnifiedCollection(collectionsDir); + if (!unifiedCollection || !unifiedCollection.subtypes) { + return []; + } + + const patterns = Object.keys(unifiedCollection.subtypes) + .map(mongoSafeSubtype => MONGO_SAFE_TO_PATTERN[mongoSafeSubtype]) + .filter(pattern => pattern); + + return patterns; + } catch (error) { + console.error('Error reading unified collection:', error.message); + return []; + } +} + +/** + * Read unified collection file + */ +function readUnifiedCollection(collectionsDir = './unified-collections') { + const unifiedPath = path.join(collectionsDir, 'unified-collection.json'); + + if (!fs.existsSync(unifiedPath)) { + return null; + } + + try { + const content = fs.readFileSync(unifiedPath, 'utf8'); + return JSON.parse(content); + } catch (error) { + console.error('Error reading unified collection:', error.message); + return null; + } +} + +/** + * Process scheduler data from unified collection + */ +function processSchedulerFromUnified(subtypeData, targets) { + let count = 0; + + for (const mongoSafeIdentifier of Object.keys(subtypeData || {})) { + const classNames = subtypeData[mongoSafeIdentifier] || []; + const identifier = fromMongoSafeFieldName(mongoSafeIdentifier); + + for (const className of classNames) { + count++; + targets.push(new BpaTarget( + "scheduler", + className, + identifier, + "Uses imperative Scheduler API instead of declarative @SlingScheduled annotation", + "high" + )); + } + } + + return count; +} + +/** + * Process asset API data from unified collection + */ +function processAssetApiFromUnified(subtypeData, targets) { + let count = 0; + + for (const mongoSafeIdentifier of Object.keys(subtypeData || {})) { + const classNames = subtypeData[mongoSafeIdentifier] || []; + const identifier = fromMongoSafeFieldName(mongoSafeIdentifier); + + for (const className of classNames) { + count++; + targets.push(new BpaTarget( + "assetApi", + className, + identifier, + `Uses unsupported Asset API: ${identifier}`, + "critical" + )); + } + } + + return count; +} + +/** + * Process event listener data from unified collection + */ +function processEventListenerFromUnified(subtypeData, targets) { + let count = 0; + + for (const mongoSafeIdentifier of Object.keys(subtypeData || {})) { + const classNames = subtypeData[mongoSafeIdentifier] || []; + const identifier = fromMongoSafeFieldName(mongoSafeIdentifier); + + for (const className of classNames) { + count++; + targets.push(new BpaTarget( + "eventListener", + className, + identifier, + `Uses JCR Event Listener: ${identifier}`, + "high" + )); + } + } + + return count; +} + +/** + * Process resource change listener data from unified collection + */ +function processResourceChangeListenerFromUnified(subtypeData, targets) { + let count = 0; + + for (const mongoSafeIdentifier of Object.keys(subtypeData || {})) { + const classNames = subtypeData[mongoSafeIdentifier] || []; + const identifier = fromMongoSafeFieldName(mongoSafeIdentifier); + + for (const className of classNames) { + count++; + targets.push(new BpaTarget( + "resourceChangeListener", + className, + identifier, + `Uses Resource Change Listener: ${identifier}`, + "high" + )); + } + } + + return count; +} + +/** + * Process event handler data from unified collection + */ +function processEventHandlerFromUnified(subtypeData, targets) { + let count = 0; + + for (const mongoSafeIdentifier of Object.keys(subtypeData || {})) { + const classNames = subtypeData[mongoSafeIdentifier] || []; + const identifier = fromMongoSafeFieldName(mongoSafeIdentifier); + + for (const className of classNames) { + count++; + targets.push(new BpaTarget( + "eventHandler", + className, + identifier, + `Uses OSGi Event Handler: ${identifier}`, + "high" + )); + } + } + + return count; +} + +/** + * Fetch findings from unified collection (mimics cam-bpa-fetcher behavior) + */ +function fetchUnifiedBpaFindings(pattern = "all", collectionsDir = './unified-collections') { + const result = new BpaResult(); + + // Check if unified collection exists + if (!hasUnifiedCollection(collectionsDir)) { + result.error = `Unified collection not found: ${collectionsDir}/unified-collection.json`; + result.troubleshooting = [ + "Run bpa-local-parser.js with --unified flag to create unified collection", + "Ensure the collections directory path is correct", + "Check that BPA file has been processed successfully" + ]; + result.suggestion = [ + "Use: node bpa-local-parser.js [output-directory] --unified", + "Verify BPA file contains valid findings data" + ]; + return result; + } + + // Read unified collection + const unifiedCollection = readUnifiedCollection(collectionsDir); + if (!unifiedCollection || !unifiedCollection.subtypes) { + result.error = "Invalid unified collection format"; + result.troubleshooting = [ + "Check that unified-collection.json contains valid JSON", + "Verify the file has 'subtypes' property", + "Re-run bpa-local-parser.js if file is corrupted" + ]; + return result; + } + + // Determine patterns to fetch + const availablePatterns = getAvailablePatterns(collectionsDir); + + if (availablePatterns.length === 0) { + result.error = "No valid pattern collections found in unified collection"; + result.troubleshooting = [ + "Check that unified collection contains supported subtypes", + "Verify pattern files contain valid data", + "Re-run bpa-local-parser.js if data is missing" + ]; + return result; + } + + const patternsToFetch = pattern === "all" + ? availablePatterns + : availablePatterns.filter(p => p === pattern); + + if (patternsToFetch.length === 0) { + result.error = `Pattern '${pattern}' not found in unified collection. Available: ${availablePatterns.join(', ')}`; + result.suggestion = [ + `Use one of: ${availablePatterns.join(', ')}, all`, + "Check that the requested pattern was included in the BPA report" + ]; + return result; + } + + console.log(`[Unified Collection Reader] Reading patterns: ${patternsToFetch.join(', ')}`); + + // Process each pattern + for (const pat of patternsToFetch) { + const mongoSafeSubtype = Object.keys(MONGO_SAFE_TO_PATTERN).find(key => + MONGO_SAFE_TO_PATTERN[key] === pat + ); + + if (!mongoSafeSubtype) { + console.warn(`[Unified Collection Reader] Unknown pattern: ${pat}, skipping`); + continue; + } + + const subtypeData = unifiedCollection.subtypes[mongoSafeSubtype]; + if (!subtypeData) { + console.warn(`[Unified Collection Reader] No data for pattern: ${pat}, skipping`); + continue; + } + + // Process data based on pattern type + let count = 0; + if (pat === "scheduler") { + count = processSchedulerFromUnified(subtypeData, result.targets); + result.summary.schedulerCount = count; + } else if (pat === "assetApi") { + count = processAssetApiFromUnified(subtypeData, result.targets); + result.summary.assetApiCount = count; + } else if (pat === "eventListener") { + count = processEventListenerFromUnified(subtypeData, result.targets); + result.summary.eventListenerCount = count; + } else if (pat === "resourceChangeListener") { + count = processResourceChangeListenerFromUnified(subtypeData, result.targets); + result.summary.resourceChangeListenerCount = count; + } else if (pat === "eventHandler") { + count = processEventHandlerFromUnified(subtypeData, result.targets); + result.summary.eventHandlerCount = count; + } + + console.log(`[Unified Collection Reader] Processed ${count} findings for pattern: ${pat}`); + } + + if (result.targets.length === 0) { + result.error = "No findings found in unified collection"; + result.suggestion = [ + "Verify that BPA file contained relevant findings", + "Check that bpa-local-parser.js processed the file correctly", + "Inspect unified collection file for expected data structure" + ]; + return result; + } + + result.success = true; + console.log(`[Unified Collection Reader] Successfully loaded ${result.targets.length} findings from unified collection`); + + return result; +} + +/** + * Get summary of unified collection (derived from unified-collection.json). + * Returns { timestamp, subtypes, totalFindings } for display purposes. + */ +function getUnifiedCollectionSummary(collectionsDir = './unified-collections') { + const unifiedCollection = readUnifiedCollection(collectionsDir); + if (!unifiedCollection || !unifiedCollection.subtypes) { + return null; + } + + const subtypes = Object.keys(unifiedCollection.subtypes); + let totalFindings = 0; + for (const subtypeKey of subtypes) { + const subtypeData = unifiedCollection.subtypes[subtypeKey] || {}; + for (const classNames of Object.values(subtypeData)) { + totalFindings += (classNames || []).length; + } + } + + return { + timestamp: unifiedCollection.meta?.timestamp || null, + subtypes, + totalFindings: unifiedCollection.meta?.totalFindings ?? totalFindings + }; +} + +/** + * CLI interface for testing + */ +function main() { + const args = process.argv.slice(2); + const pattern = args[0] || 'all'; + const collectionsDir = args[1] || './unified-collections'; + + console.log('Unified Collection Reader'); + console.log('========================'); + console.log(`Pattern: ${pattern}`); + console.log(`Collections Directory: ${collectionsDir}`); + console.log(''); + + // Check if unified collection exists + if (!hasUnifiedCollection(collectionsDir)) { + console.error(`❌ Unified collection not found: ${collectionsDir}/unified-collection.json`); + console.error(''); + console.error('To create unified collection:'); + console.error(' node bpa-local-parser.js [output-directory] --unified'); + process.exit(1); + } + + // Show available patterns + const availablePatterns = getAvailablePatterns(collectionsDir); + console.log(`Available patterns: ${availablePatterns.join(', ')}`); + + // Show summary + const summary = getUnifiedCollectionSummary(collectionsDir); + if (summary) { + console.log(`Total subtypes: ${summary.subtypes?.length || 0}`); + console.log(`Total findings: ${summary.totalFindings || 0}`); + console.log(`Created: ${summary.timestamp || '(unknown)'}`); + } + + console.log(''); + + // Fetch findings + const result = fetchUnifiedBpaFindings(pattern, collectionsDir); + + if (result.success) { + console.log('✅ Successfully loaded findings:'); + console.log(`📊 Total targets: ${result.targets.length}`); + console.log(''); + + // Group by pattern + const byPattern = result.targets.reduce((acc, target) => { + if (!acc[target.pattern]) acc[target.pattern] = []; + acc[target.pattern].push(target); + return acc; + }, {}); + + Object.entries(byPattern).forEach(([pat, targets]) => { + console.log(`${pat.toUpperCase()}:`); + targets.forEach(target => { + console.log(` - ${target.className} (${target.severity}): ${target.issue}`); + }); + console.log(''); + }); + } else { + console.error('❌ Error:', result.error); + if (result.troubleshooting?.length > 0) { + console.error(''); + console.error('Troubleshooting:'); + result.troubleshooting.forEach(tip => console.error(` - ${tip}`)); + } + if (result.suggestion?.length > 0) { + console.error(''); + console.error('Suggestions:'); + result.suggestion.forEach(tip => console.error(` - ${tip}`)); + } + process.exit(1); + } +} + +// Run CLI if called directly +if (require.main === module) { + main(); +} + +module.exports = { + hasUnifiedCollection, + getAvailablePatterns, + fetchUnifiedBpaFindings, + getUnifiedCollectionSummary, + readUnifiedCollection, + BpaTarget, + BpaResult +};