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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 71 additions & 20 deletions extensions/migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
Deployment migration extension for the AEM Groovy Console, replacing the deprecated
[AEM Easy Content Upgrade (AECU)](https://github.com/valtech/aem-easy-content-upgrade) project.

Groovy migration scripts are deployed via content package below `/conf/groovyconsole/scripts/migration` and
executed with **checksum-based run-once semantics**: a script runs when it is new, its content changed or its
last execution was not successful. Scripts execute in deterministic alphanumeric path order with fail-fast
behavior, and a light run history is kept below `/var/groovyconsole/migration`.
Groovy migration scripts are deployed via content package below an immutable `/apps/groovyconsole-migration-scripts`
path and/or the mutable `/conf/groovyconsole/scripts/migration` path, and executed with **checksum-based run-once
semantics**: a script runs when it is new, its content changed or its last execution was not successful. Scripts
execute in deterministic alphanumeric path order with fail-fast behavior, and a light run history is kept below
`/var/groovyconsole/migration`.

Works on **AEM 6.5 on-premises, AMS and AEM as a Cloud Service** as well as plain Apache Sling — see
[Cloud vs on-premises](#cloud-vs-on-premises) for how automatic execution differs per environment.

## Installation (opt-in)

Expand All @@ -16,13 +20,23 @@ service user (`aem-groovy-console-migration-service`) and OSGi configuration.

## Writing migration scripts

Deploy `.groovy` files (typically via a content package) below `/conf/groovyconsole/scripts/migration`.
Scripts are regular Groovy Console scripts with all console bindings (`resourceResolver`, `session`, etc.)
available, executed by the extension's service user. They are discovered recursively and executed in
alphanumeric path order, so a numeric prefix convention keeps the order explicit:
Deploy `.groovy` files (typically via a content package) below one of the configured scripts base paths. Two
paths are searched by default (both optional, searched in order, missing paths skipped):

- **`/apps/groovyconsole-migration-scripts`** — *immutable*, recommended on AEM as a Cloud Service. Scripts
ship inside the code image (present on both author and publish, read-only at runtime) and are picked up
automatically by the [cloud startup hook](#cloud-vs-on-premises). Deploy them here from your project's own
content package.
- **`/conf/groovyconsole/scripts/migration`** — *mutable*, suited to authored or ad-hoc scripts.

Configure a different set (or a single path) via `scriptsBasePaths` on the `MigrationService` OSGi
configuration. Scripts are regular Groovy Console scripts with all console bindings (`resourceResolver`,
`session`, etc.) available, executed by the extension's service user. They are discovered recursively and
executed in alphanumeric path order across all base paths (`/apps` sorts before `/conf`), so a numeric prefix
convention keeps the order explicit:

```
/conf/groovyconsole/scripts/migration/
/apps/groovyconsole-migration-scripts/
2025/
001-activate-new-templates.groovy
002-cleanup-legacy-paths.author.groovy
Expand All @@ -46,13 +60,19 @@ script already committed itself (e.g. an explicit `session.save()` halfway) are

## Triggering

- **Startup hook** (automatic, cloud by default): on bundle activation the extension detects an AEM as a Cloud
Service instance (composite node store) and enqueues a run of the pending scripts once the instance is ready.
Controlled by `autoRunOnStartup` (`cloudOnly` default / `always` / `never`) on the `MigrationStartupHook` OSGi
configuration — see [Cloud vs on-premises](#cloud-vs-on-premises).
- **HTTP API** (CI/CD): `POST /bin/groovyconsole/migration` — synchronous by default, `async=true` returns a
`runId` for polling via `GET ?runId=...`, `dryRun=true` previews without executing. `path=...` scopes the run
to a single script or folder instead of the configured scripts base path; `data=...` (JSON or plain string) is
made available to every script in the run as the `data` binding variable. `GET ?registry=true` / `?pending=true`
expose the per-script state. Returns `409 Conflict` while a run is in progress.
- **Resource listener** (opt-in, disabled by default): enqueues a run automatically when migration scripts
are added/changed, debounced. Enable via the `MigrationScriptListener` OSGi configuration.
are added/changed under either default base path, debounced. Enable via the `MigrationScriptListener` OSGi
configuration. Note that immutable `/apps` changes on AEMaaCS are applied by a container swap and do not fire
resource events — the startup hook covers auto-run there instead.
- **JMX** (e.g. JConsole, or a scripted JMX client): `be.orbinson.aem.groovyconsole:type=Migration` exposes
`run()`, `run(path)` and `run(path, data)` (synchronous, same semantics as the HTTP API above), plus
`isRunning()`, `getPendingScripts()` and `getRuns(count)`. Mirrors `AecuServiceMBean`, adapted to this
Expand Down Expand Up @@ -107,14 +127,22 @@ Two Apache Felix Health Checks are registered under the `migration` tag (mirrori

`be.orbinson.aem.groovy.console.migration.impl.DefaultMigrationService`:

| Property | Default | Description |
|--------------------------|----------------------------------------|--------------------------------------------------------------------|
| `scriptsBasePath` | `/conf/groovyconsole/scripts/migration`| JCR path containing the migration scripts |
| `allowedMigrationGroups` | *(empty)* | Groups allowed to trigger runs (admin always allowed) |
| `staleLockMillis` | `1800000` | Time after which an in-progress run lock is considered stale |
| `maxRunHistory` | `50` | Number of runs kept in the history; older runs are pruned |
| `maxOutputChars` | `4096` | Script output characters stored per result (full output in audit) |
| `runModeFilterEnabled` | `true` | Honor `author`/`publish` file name tokens |
| Property | Default | Description |
|--------------------------|---------------------------------------------|--------------------------------------------------------------------|
| `scriptsBasePaths` | `/apps/groovyconsole-migration-scripts`, `/conf/groovyconsole/scripts/migration` | JCR paths containing the migration scripts, searched in order; missing paths skipped |
| `allowedMigrationGroups` | *(empty)* | Groups allowed to trigger runs (admin always allowed) |
| `staleLockMillis` | `1800000` | Time after which an in-progress run lock is considered stale |
| `maxRunHistory` | `50` | Number of runs kept in the history; older runs are pruned |
| `maxOutputChars` | `4096` | Script output characters stored per result (full output in audit) |
| `runModeFilterEnabled` | `true` | Honor `author`/`publish` file name tokens |

`be.orbinson.aem.groovy.console.migration.impl.MigrationStartupHook`:

| Property | Default | Description |
|-------------------------|-------------|----------------------------------------------------------------------------------------------|
| `autoRunOnStartup` | `cloudOnly` | When to auto-run on activation: `cloudOnly` (AEMaaCS composite node store), `always`, `never` |
| `bootDelayMillis` | `10000` | Delay after activation before the startup run, letting the instance settle |
| `readinessTimeoutMillis`| `300000` | Max time to wait for the repository/service user to become available |

`be.orbinson.aem.groovy.console.migration.impl.MigrationScriptListener`:

Expand All @@ -123,9 +151,30 @@ Two Apache Felix Health Checks are registered under the `migration` tag (mirrori
| `enabled` | `false` | Automatically enqueue a run when migration scripts change |
| `debounceMillis` | `3000` | Coalesce bursts of change events into a single run |

When overriding `scriptsBasePath`, the listener's `resource.paths` component property must be overridden
When overriding `scriptsBasePaths`, the listener's `resource.paths` component property must be overridden
accordingly via OSGi configuration.

## Cloud vs on-premises

Migrations can run automatically on deployment in every supported environment; the available trigger differs,
so pick the row that matches yours:

| Environment | Recommended scripts path | How migrations run on deploy |
|-----------------------------------|-----------------------------------------|-----------------------------------------------------------------------------------------------|
| **AEM as a Cloud Service** | `/apps/groovyconsole-migration-scripts` | **Startup hook** enqueues pending scripts automatically when the container starts. No pipeline step required. |
| **AEM 6.5 on-premises / AMS** | either path (via content package) | Enable the **resource listener** to run pending scripts when the package installs, or trigger the **HTTP API** / **JMX** from your deployment tooling. The startup hook stays inactive by default (`cloudOnly`). |
| **Apache Sling / local dev** | either path | HTTP API, JMX, resource listener, or set `autoRunOnStartup=always` to run on every startup. |

Scripts under `/apps` ship inside the immutable code image (present on author and publish, read-only at
runtime); scripts under `/conf` are mutable content, deployed to author only. Execution state always lives
under mutable `/var/groovyconsole/migration`. Set `autoRunOnStartup=always` to also auto-run on premises, or
`never` to rely solely on the listener / HTTP API / JMX.

On a horizontally scaled publish tier each pod runs the migrations against its own repository (each pod's
`/var` state is independent); the per-instance run lock in `/var/groovyconsole/migration` prevents concurrent
runs within an instance. Use the [health checks](#health-checks) (tag `migration`) to gate a cloud pipeline on
migration success.

## Persistence model

- `/var/groovyconsole/migration/registry/*` — one entry per known script (checksum, last status, last run date).
Expand All @@ -139,4 +188,6 @@ Follows the reports extension methodology: `api` (exported interfaces), `bundle`
`ui.frontend` (Lit + Spectrum Web Components UI built with Vite into `ui.apps` under the migration-owned
`/apps/groovyconsole-migration/spa` path, sharing console infrastructure via the `@console` alias), `ui.apps`
(SPA assets + AEM Tools navigation overlay), `ui.config` (service user, repoinit, ordered job queue),
`ui.content` (the `/conf/groovyconsole/scripts/migration` folder) and `all` (the container package).
`ui.content` (the mutable `/conf/groovyconsole/scripts/migration` folder) and `all` (the container package).
Migration scripts destined for the immutable `/apps/groovyconsole-migration-scripts` path are deployed by the
customer's own content package, not by this extension.
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,25 @@ class MigrationConstants {

public static final String PATH_MIGRATION_RUNS = "$PATH_MIGRATION_ROOT/runs"

/** Immutable scripts path: ships with the code image (reaches publish, tamper-proof) -- preferred on AEMaaCS. */
public static final String DEFAULT_SCRIPTS_BASE_PATH_APPS = "/apps/groovyconsole-migration-scripts"

/** Mutable scripts path: authored/ad-hoc scripts, deployed as mutable content (author only on AEMaaCS). */
public static final String DEFAULT_SCRIPTS_BASE_PATH = "/conf/groovyconsole/scripts/migration"

/**
* Default scripts base paths, searched in order. Missing paths are skipped, so a deployment can use the
* immutable {@code /apps} path, the mutable {@code /conf} path, or both.
*/
public static final List<String> DEFAULT_SCRIPTS_BASE_PATHS =
[DEFAULT_SCRIPTS_BASE_PATH_APPS, DEFAULT_SCRIPTS_BASE_PATH].asImmutable()

public static final String MIGRATION_JOB_TOPIC = "groovyconsole/migration"

// startup / trigger

public static final String TRIGGER_STARTUP = "STARTUP"

// request parameters

public static final String RUN_ID = "runId"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class DefaultMigrationService implements MigrationService {
@Reference
private SlingSettingsService slingSettingsService

private String scriptsBasePath
private List<String> scriptsBasePaths

private Set<String> allowedMigrationGroups

Expand All @@ -75,7 +75,7 @@ class DefaultMigrationService implements MigrationService {
@Activate
@Synchronized
void activate(MigrationServiceProperties properties) {
scriptsBasePath = properties.scriptsBasePath()
scriptsBasePaths = ((properties.scriptsBasePaths() ?: []).findAll() ?: DEFAULT_SCRIPTS_BASE_PATHS) as List
allowedMigrationGroups = (properties.allowedMigrationGroups() ?: []).findAll() as Set
staleLockMillis = properties.staleLockMillis()
maxRunHistory = properties.maxRunHistory()
Expand All @@ -96,8 +96,8 @@ class DefaultMigrationService implements MigrationService {
isPendingScript(resourceResolver, script)
}

LOG.info("found {} pending migration script(s) below path : {}", pendingScripts.size(),
options.path ?: scriptsBasePath)
LOG.info("found {} pending migration script(s) below path(s) : {}", pendingScripts.size(),
options.path ?: scriptsBasePaths.join(", "))

def results
def status
Expand Down Expand Up @@ -287,15 +287,19 @@ class DefaultMigrationService implements MigrationService {
private List<Map> findScripts(ResourceResolver resourceResolver, String overridePath = null) {
def scripts = []

def rootPath = overridePath ?: scriptsBasePath
def rootResource = resourceResolver.getResource(rootPath)
def rootPaths = overridePath ? [overridePath] : scriptsBasePaths

if (rootResource) {
collectScripts(rootResource, scripts)
} else {
LOG.debug("migration scripts path not found : {}", rootPath)
rootPaths.each { rootPath ->
def rootResource = resourceResolver.getResource(rootPath)

if (rootResource) {
collectScripts(rootResource, scripts)
} else {
LOG.debug("migration scripts path not found : {}", rootPath)
}
}

// deterministic alphanumeric path order across all base paths (/apps sorts before /conf)
scripts.sort { script -> script.path }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@ import static be.orbinson.aem.groovy.console.migration.MigrationConstants.TRIGGE
* Optionally enqueues a migration run when migration scripts are added or changed, e.g. by a content package
* installation. Bursts of change events are debounced into a single asynchronous run. Disabled by default.
*
* <p>Note: when overriding the migration scripts base path, the <code>resource.paths</code> property of this
* component must be overridden accordingly via OSGi configuration.</p>
* <p>Watches both the immutable <code>/apps</code> and mutable <code>/conf</code> default script paths. On AEM as a
* Cloud Service immutable <code>/apps</code> changes are applied by a container swap rather than at runtime, so they
* do not fire resource events -- the {@link MigrationStartupHook} covers auto-run there instead. When overriding the
* migration scripts base paths, the <code>resource.paths</code> property of this component must be overridden
* accordingly via OSGi configuration.</p>
*/
@Component(property = [
"resource.paths=glob:/apps/groovyconsole-migration-scripts/**",
"resource.paths=glob:/conf/groovyconsole/scripts/migration/**",
"resource.change.types=ADDED",
"resource.change.types=CHANGED"
Expand Down
Loading
Loading