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
29 changes: 29 additions & 0 deletions .devcontainer/devcontainer-lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:3": {
"version": "3.0.1",
"resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:ca2508495b01ba29eba93e8153772a2daa65eaa86471cc6863fe2a3d21933df9",
"integrity": "sha256:ca2508495b01ba29eba93e8153772a2daa65eaa86471cc6863fe2a3d21933df9"
},
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "1.1.0",
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
},
"ghcr.io/devcontainers/features/java:1": {
"version": "1.8.0",
"resolved": "ghcr.io/devcontainers/features/java@sha256:9663ce0219ff85786e87901ce5f0a59f488edd5f99b46015192cda48468b233a",
"integrity": "sha256:9663ce0219ff85786e87901ce5f0a59f488edd5f99b46015192cda48468b233a"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "1.7.1",
"resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6",
"integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6"
},
"ghcr.io/phil-bell/devcontainer-features/antigen:1": {
"version": "1.0.3",
"resolved": "ghcr.io/phil-bell/devcontainer-features/antigen@sha256:75204591baac950dc64975ecf42a3a5908335348ed9a6260e0dc3d46d1cf9148",
"integrity": "sha256:75204591baac950dc64975ecf42a3a5908335348ed9a6260e0dc3d46d1cf9148"
}
}
}
20 changes: 16 additions & 4 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,32 @@
]
},
"extensions": [
"mads-hartmann.bash-ide-vscode"
"mads-hartmann.bash-ide-vscode",
"ms-vscode.vscode-chat-customizations-evaluations",
"GitHub.vscode-github-actions"
]
}
},
"features": {
"ghcr.io/phil-bell/devcontainer-features/antigen:1": {
"theme": "spaceship-prompt/spaceship-prompt",
"bundles": [
"zsh-users/zsh-syntax-highlighting",
"zsh-users/zsh-autosuggestions"
]
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
"version": "22"
},
"ghcr.io/devcontainers/features/docker-in-docker:3": {
"moby": false
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/java:1": {
"version": "21",
"jdkDistro": "oracle",
"installMaven": true
}
},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"updateContentCommand": "npm install -g @devcontainers/cli",
"postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}"
Expand Down
103 changes: 103 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# GitHub Copilot Instructions - devcontainer-features

## Repository role
You are working in a Dev Container Features repository that packages Adobe Experience Manager (AEM) tooling for local development. The repository is centered on feature definitions under src/<feature-id>/, test fixtures under test/<feature-id>/, and CI workflows under .github/workflows/.

## Repository-specific conventions
- Feature IDs and folder names match exactly:
- aem-sdk
- aem-lts
- aem-repo-tool
- aem-universal-editor-service
- Each feature owns its own install.sh, devcontainer-feature.json, and supporting scripts under src/<feature-id>/bin/.
- Test directories mirror the feature IDs under test/<feature-id>/ and contain at least test.sh plus scenario definitions when applicable.
- Generated documentation for each feature is stored in src/<feature-id>/README.md and is derived from devcontainer-feature.json. Do not manually diverge from the JSON unless you intentionally want to change the generated output.

## Feature implementation conventions
### devcontainer-feature.json
- Keep the JSON compliant with Dev Container Features Spec v1.
- Preserve existing option names and defaults when making incremental changes.
- Any new option must be documented in the options block and reflected in install.sh and, when relevant, in tests and scenarios.
- Preserve the existing containerEnv PATH entries and feature directories:
- aem-sdk: PATH includes /aem-sdk/bin:/aem-sdk/dispatcher/bin:${PATH}
- aem-lts: PATH includes /aem-lts/bin:/aem-lts/dispatcher/bin:${PATH}
- aem-repo-tool: PATH includes /aem-repo-tool:${PATH}
- aem-universal-editor-service: PATH includes /aem-ues/bin:${PATH}
- Preserve existing mounts where applicable. For example, aem-sdk and aem-lts mount persistent author/publish volumes under their feature directories.
- Keep dependsOn and installsAfter entries when a feature relies on Java, Docker-in-Docker, or Node.

### install.sh
- Use /usr/bin/env bash as the shebang.
- Create the feature directory with mkdir -p and write options.sh in the feature root.
- Persist feature configuration by exporting values into options.sh and sourcing it immediately after creation.
- Copy the feature-specific bin scripts into the feature directory with cp -r.
- Use idempotent logic and avoid re-downloading or re-extracting unless necessary.
- Prefer explicit validation for required inputs and fail fast when required artifacts are missing.
- Clean up temporary archives when you create them during the same layer execution.

### bin scripts
- Source the feature options file from the feature directory.
- Use helper functions for repeated logic instead of duplicating path and lookup code.
- Follow the current naming and behavior patterns already used in this repository:
- aem-sdk: get_runmode_port, get_runmode_jar, get_aem_sdk_zip, aem_sdk_not_found
- aem-lts: get_runmode_port, get_action, get_runmode, aem_lts_err
- aem-universal-editor-service: get_ues_zip, ues_zip_not_found
- Keep command entrypoints consistent with the current user-facing commands:
- start-aem author|publish|dispatcher
- start-ues
- aem-lts start|stop author|publish [-i]

## Feature-specific behavior to preserve
### aem-sdk
- The feature installs SDK artifacts into /aem-sdk and exposes the start-aem command.
- start-aem must auto-install the requested service jar or dispatcher tools on first use.
- The feature expects the AEM SDK archive in a local directory, usually .devcontainer, and supports automatic version selection via sdkVersion=automatic.
- Persisted state must live in the mounted volume paths under /aem-sdk/author/crx-quickstart and /aem-sdk/publish/crx-quickstart.

### aem-lts
- The feature copies the provided quickstart jar into the feature directory and generates license.properties from the configured license values.
- The aem-lts script should support start and stop actions for author and publish.
- Interactive mode must run in the foreground, while non-interactive mode should daemonize and write logs to /var/log/aem-<runmode>.log.
- Keep the current defaults for authorPort and publishPort.

### aem-repo-tool
- The repo tool is fetched from the Adobe-Marketing-Cloud tools release endpoint.
- The feature installs the binary at /aem-repo-tool/repo and makes it executable.
- Do not introduce extra packaging or download logic unless the upstream release path changes.

### aem-universal-editor-service
- The feature expects one or more Universal Editor Service zip files in a local directory, usually .devcontainer.
- start-ues must source NVM, install the configured Node version, install required global packages, and then launch the service plus local SSL proxies.
- The feature also provisions the content package into the AEM author install directory when aem-sdk is present.

## Testing and validation workflow
- Use the same commands already encoded in the GitHub Actions workflows under .github/workflows/.
- Validate feature JSON files with the devcontainers action:
- devcontainers/action@v1 with validate-only: true and base-path-to-features: ./src
- Run generated tests for a specific feature against a base image:
- devcontainer features test --skip-scenarios -f <feature-id> -i <base-image> .
- Run scenario-based tests for a feature:
- devcontainer features test -f <feature-id> --skip-autogenerated --skip-duplicated .
- When changing a feature, update or add test.sh and scenario definitions under test/<feature-id>/ if the change affects behavior or options.

## Documentation and examples
- Update feature README.md and NOTES.md when user-facing behavior changes.
- Use the existing documentation patterns in the repository:
- Example usage with ghcr.io/juan-ayala/devcontainer-features/<feature-id>:1
- A .devcontainer/devcontainer.json snippet that points the feature to ${containerWorkspaceFolder}/.devcontainer
- Run instructions showing the exact command the user should execute inside the devcontainer
- Keep examples consistent with the current feature IDs and option names.
- Treat README.md as generated output when feasible; prefer changing devcontainer-feature.json and letting the existing release workflow regenerate it.

## General implementation guidance
- Prefer portable Bash and avoid shell-specific behavior that is not already used in the repository.
- Preserve the current project style: minimal scripts, direct environment usage, and explicit path handling.
- Make every change idempotent and safe to rerun.
- When adding new logic, prefer the smallest change that matches the existing patterns in neighboring feature scripts.
- If a change affects how users configure or run a feature, update both the feature JSON and the user-facing documentation.

## What to check before you claim completion
- Verify the relevant feature JSON remains valid.
- Run the appropriate test command for the changed feature.
- If the change affects user-facing commands or options, confirm the test or scenario coverage includes that behavior.
- If the change touches shared conventions, re-check the affected feature directories and any generated README output.
4 changes: 4 additions & 0 deletions src/aem-lts/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ In VSCode, open the terminal window. This is a terminal inside the docker contai

There will be a script named `aem-lts`. Use this to start the author, publish or dispatcher.
* Start author: `aem-lts start author`
* Start author in interactive mode: `aem-lts start author -i`
* Stop author: `aem-lts stop author`
* Start publish: `aem-lts start publish`
* Start publish in interactive mode: `aem-lts start publish -i`
* Stop publish: `aem-lts stop publish`

The `-i` flag runs the Java process in the foreground so logs stream to the terminal. Use `CTRL+C` to stop the interactive session.

The feature also sets up volume mounts for the author and publish services. This is where the services will persist the repository. So that if the container gets deleted and/or rebuilt, the repository will persist.

## References
Expand Down
62 changes: 50 additions & 12 deletions src/aem-lts/bin/aem-lts
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
#!/usr/bin/env bash
#
# aem-lts - Manage AEM LTS instances in a dev container
#
# Usage:
# aem-lts start [author|publish] [-i] Start an AEM instance
# aem-lts stop [author|publish] Stop a running AEM instance
#
# Options:
# -i Interactive mode (start only): runs the Java process in the
# foreground so logs stream to the terminal. Use CTRL+C to stop.
#
# When started without -i, the process runs in the background as a daemon
# and logs are written to /var/log/aem-<runmode>.log.

source "$(dirname $0)/_globals.sh"
action=$(get_action "${1}")
service=$(get_runmode "${2}")

# parse -i flag from any position in the arguments
INTERACTIVE=false
ARGS=()
for arg in "$@"; do
[[ "${arg}" == "-i" ]] && INTERACTIVE=true || ARGS+=("${arg}")
done

action=$(get_action "${ARGS[0]}")
service=$(get_runmode "${ARGS[1]}")

echo "${action^}ing AEM ${service}..."

Expand All @@ -26,7 +47,7 @@ if [[ ! -f "${licenceFile}" ]]; then
aem_lts_err "License customer name not set"
[[ -z "${AEM_LTS_LICENSE_DOWNLOAD_ID}" ]] && \
aem_lts_err "License download ID not set"

cat <<EOF | sudo tee "${licenceFile}" > /dev/null
license.customer.name=${AEM_LTS_LICENSE_CUSTOMER_NAME}
license.downloadID=${AEM_LTS_LICENSE_DOWNLOAD_ID}
Expand All @@ -47,28 +68,45 @@ licenseFileLink="${runmodedir}/license.properties"
[[ ! -L "${licenseFileLink}" ]] && sudo ln -s "${licenceFile}" "${licenseFileLink}"

# make user owner of crx-quickstart (it is a volume mount)
sudo chown ${USER} "${runmodedir}/crx-quickstart"
[[ -d "${runmodedir}/crx-quickstart" ]] && sudo chown ${USER} "${runmodedir}/crx-quickstart"

PID_FILE="/tmp/aem-${service}.pid"

# check if pid file exists and process is running
IS_RUNNING=false
[[ -f "${PID_FILE}" ]] && sudo kill -0 "$(cat "${PID_FILE}")" 2>/dev/null && IS_RUNNING=true

case "${action}" in
start)

[[ -f "/tmp/aem-${service}.pid" ]] && aem_lts_err "AEM ${service} already running"
${IS_RUNNING} && aem_lts_err "AEM ${service} already running"
[[ -f "${PID_FILE}" ]] && sudo rm -f "${PID_FILE}" # remove stale pid file if present

port=$(get_runmode_port ${service})
jvm_opts="-agentlib:jdwp=transport=dt_socket,address=*:3${port},server=y,suspend=n"
cq_opts="-nofork -nobrowser -nointeractive -r ${service} -p ${port}"

sudo start-stop-daemon --start --quiet --background --chuid root \
--make-pidfile --pidfile "/tmp/aem-${service}.pid" \
--name ${service} \
--chdir "$(dirname "${jarFileLink}")" \
--startas /bin/bash -- -c "exec ${JAVA_HOME}/bin/java ${jvm_opts} -jar "${jarFileLink}" ${cq_opts} \
> /var/log/aem-${service}.log 2>&1"
if ${INTERACTIVE}; then
# run in foreground; CTRL+C to stop
echo "Running in interactive mode, press CTRL+C to stop..."
${JAVA_HOME}/bin/java ${jvm_opts} -jar "${jarFileLink}" ${cq_opts}
else
sudo start-stop-daemon --start --quiet --background --chuid root \
--make-pidfile --pidfile "${PID_FILE}" \
--name ${service} \
--chdir "$(dirname "${jarFileLink}")" \
--startas /bin/bash -- -c "exec ${JAVA_HOME}/bin/java ${jvm_opts} -jar "${jarFileLink}" ${cq_opts} \
> /var/log/aem-${service}.log 2>&1"
fi
;;
stop)

${IS_RUNNING} || aem_lts_err "AEM ${service} is not running"

# give it 2 minutes to shut down gracefully, force kill after 10 more seconds
sudo start-stop-daemon --stop --verbose \
--remove-pidfile --pidfile /tmp/aem-${service}.pid
--remove-pidfile --pidfile "${PID_FILE}" \
--retry TERM/120/KILL/10
;;
*)
aem_lts_err "Unknown action '${action}'"
Expand Down
67 changes: 36 additions & 31 deletions test/aem-lts/defaults-with-quickstart.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,40 +16,45 @@ check "author port default" \
[ "${AEM_LTS_AUTHOR_PORT}" = "4502" ]
check "publish port default" \
[ "${AEM_LTS_PUBLISH_PORT}" = "4503" ]

# Check aem-lts in PATH is executable
check "aem-lts is +x" \
stat -c '%A' $(which aem-lts) | grep 'x.*x.*x'

# Check that author installs, starts & stops correctly
check "author: install & start" \
aem-lts start author
sleep 3 # give it time to start
check "author: check log for start message" \
grep 'Server started on port 4502' /var/log/aem-author.log
check "author: compare pid file to java process" \
[ $(cat /tmp/aem-author.pid) -eq $(pgrep -x java) ]
check "author: stop" \
aem-lts stop author
sleep 3 # give it time to stop
check "author: pid file should be removed" \
[ ! -f /tmp/aem-author.pid ]
check "author: no java process should be running" \
[ -z $(pgrep -x java) ]

# Check that publish installs, starts & stops correctly
check "publish: install & start" \
aem-lts start publish
sleep 3 # give it time to start
check "publish: check log for start message" \
grep 'Server started on port 4503' /var/log/aem-publish.log
check "publish: compare pid file to java process" \
[ $(cat /tmp/aem-publish.pid) -eq $(pgrep -x java) ]
check "publish: stop" \
aem-lts stop publish
sleep 3 # give it time to stop
check "publish: pid file should be removed" \
[ ! -f /tmp/aem-publish.pid ]
check "publish: no java process should be running" \
[ -z $(pgrep -x java) ]
test_runmode() {
local service=$1 port=$2

# Simulate a stale pid file (process exited but file was left behind)
echo "99999" | sudo tee /tmp/aem-${service}.pid > /dev/null

check "${service}: install & start" \
aem-lts start ${service}
check "${service}: wait for server started on port ${port}" \
timeout 10 bash -c "until grep -q 'Server started on port ${port}' /var/log/aem-${service}.log 2>/dev/null; do sleep 0.5; done"
check "${service}: compare pid file to java process" \
[ $(cat /tmp/aem-${service}.pid) -eq $(pgrep -x java) ]
check "${service}: stop" \
aem-lts stop ${service}
check "${service}: pid file should be removed" \
[ ! -f /tmp/aem-${service}.pid ]
check "${service}: no java process should be running" \
[ -z $(pgrep -x java) ]

# Test interactive mode: start in background, capture stdout, verify process runs, then kill it
INTERACTIVE_LOG=$(mktemp)
aem-lts start ${service} -i > ${INTERACTIVE_LOG} &
INTERACTIVE_PID=$!
check "${service}: interactive mode wait for server started on port ${port}" \
timeout 10 bash -c "until grep -q 'Server started on port ${port}' ${INTERACTIVE_LOG} 2>/dev/null; do sleep 0.5; done"
check "${service}: interactive mode pid matches java process" \
[ $(pgrep -P ${INTERACTIVE_PID} -x java) -eq $(pgrep -x java) ]
kill $(pgrep -P ${INTERACTIVE_PID} -x java) 2>/dev/null
check "${service}: interactive mode java process stopped after kill" \
timeout 10 bash -c "until ! pgrep -x java > /dev/null; do sleep 0.5; done"
rm -f ${INTERACTIVE_LOG}
}

test_runmode author 4502
test_runmode publish 4503

reportResults
Loading