From 5b34c8907eb24922dd7f7222b271ff413405d843 Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Sat, 8 Aug 2026 17:00:25 +0000 Subject: [PATCH 1/4] [minor] Add checked-in lifecycle programs --- README.md | 2 ++ compose.yaml | 7 ++++++- scripts/demo-objects.sh | 2 +- scripts/drupal-wait-installed.sh | 13 +++++++++++++ scripts/initialize-compose.sh | 6 ++++++ scripts/sitectl-build.sh | 10 ++++++++++ scripts/sitectl-init.sh | 24 ++++++++++++++++++++++++ 7 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 scripts/drupal-wait-installed.sh create mode 100644 scripts/initialize-compose.sh create mode 100644 scripts/sitectl-build.sh create mode 100644 scripts/sitectl-init.sh diff --git a/README.md b/README.md index 14cdca0..7027ef4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ The site is served through Traefik at `http://localhost` by default. The `drupal` service builds this checkout on top of the LibOps Islandora base image. The Dockerfile copies Composer lockfiles and assets before local recipes, modules, themes, config, and rootfs additions so Docker can reuse dependency layers when only site customizations change. During `sitectl create`, initialization prepares secrets, certificates, ownership, and rootfs permissions; the normal create build phase then builds the image once. Local builds use the platform selected by the Docker CLI and do not push images. +The lifecycle programs in `scripts/` are part of the versioned template contract with `sitectl-isle`. They keep build, initialization, readiness, and container-side diagnostics reviewable as files and are mounted read-only when a container needs them. Preserve their paths when maintaining an institution-specific fork. + Docker Compose derives the project name from the checkout directory, so independent forks do not share containers, networks, or named volumes by default. Set `COMPOSE_PROJECT_NAME` explicitly when a stable name is required. If an existing checkout previously relied on this template's fixed `isle-site-template` project name, set `COMPOSE_PROJECT_NAME=isle-site-template` before starting it to keep using its existing named volumes, or migrate those volumes deliberately. ## Basic Operations diff --git a/compose.yaml b/compose.yaml index 8365620..ce58767 100644 --- a/compose.yaml +++ b/compose.yaml @@ -141,6 +141,10 @@ services: source: ./scripts/drupal-media-storage-state.php target: /var/www/drupal/drupal-media-storage-state.php type: bind + - read_only: true + source: ./scripts/drupal-wait-installed.sh + target: /usr/local/lib/sitectl/drupal-wait-installed.sh + type: bind - drupal-solr-config:/opt/solr/server/solr/default:z,rw fits: @@ -176,7 +180,7 @@ services: init: entrypoint: /bin/bash - command: ["-euc", "generate-certs.sh && generate-compose-secrets.sh"] + command: ["/usr/local/lib/sitectl/initialize-compose.sh"] image: libops/base:3.2.2.0@sha256:851e17742b5fee57038855f46b1a46d2716c0012c5f3a4788b7e5c7bc12fed5e networks: default: @@ -195,6 +199,7 @@ services: - ./certs:/work/certs:rw,z - ./secrets:/work/secrets:rw,z - ./compose.yaml:/work/compose.yaml:ro,z + - ./scripts/initialize-compose.sh:/usr/local/lib/sitectl/initialize-compose.sh:ro,z database-init: image: libops/base:3.2.2.0@sha256:851e17742b5fee57038855f46b1a46d2716c0012c5f3a4788b7e5c7bc12fed5e diff --git a/scripts/demo-objects.sh b/scripts/demo-objects.sh index 1089cd0..f132156 100755 --- a/scripts/demo-objects.sh +++ b/scripts/demo-objects.sh @@ -74,7 +74,7 @@ docker run \ -v "$(pwd)/islandora_demo_objects":/islandora_demo_objects:z \ --name my-running-workbench \ workbench-docker:latest \ - bash -lc "./workbench --config /islandora_demo_objects/create_islandora_objects.yml" + ./workbench --config /islandora_demo_objects/create_islandora_objects.yml workbench_status=$? set -e diff --git a/scripts/drupal-wait-installed.sh b/scripts/drupal-wait-installed.sh new file mode 100644 index 0000000..303af55 --- /dev/null +++ b/scripts/drupal-wait-installed.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +set -eu + +attempt=0 +until test -f /installed; do + attempt=$((attempt + 1)) + if [ "${attempt}" -ge 150 ]; then + echo "Drupal did not become ready for database migration within 5 minutes" >&2 + exit 1 + fi + sleep 2 +done diff --git a/scripts/initialize-compose.sh b/scripts/initialize-compose.sh new file mode 100644 index 0000000..34aebe1 --- /dev/null +++ b/scripts/initialize-compose.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +generate-certs.sh +generate-compose-secrets.sh diff --git a/scripts/sitectl-build.sh b/scripts/sitectl-build.sh new file mode 100644 index 0000000..3a6ad7a --- /dev/null +++ b/scripts/sitectl-build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ -d drupal/rootfs ]; then + find drupal/rootfs -type d -exec chmod 755 {} + +fi + +docker compose pull --ignore-buildable --ignore-pull-failures +docker compose build diff --git a/scripts/sitectl-init.sh b/scripts/sitectl-init.sh new file mode 100644 index 0000000..3c04827 --- /dev/null +++ b/scripts/sitectl-init.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ ! -f .env ]; then + cp sample.env .env +fi +if ! grep -q '^DRUPAL_HEALTHCHECK_START_PERIOD=' .env; then + printf '\nDRUPAL_HEALTHCHECK_START_PERIOD=5m\n' >>.env +fi + +mkdir -p ./certs ./secrets + +attempt=1 +until docker compose run --rm \ + -e HOST_UID="$(id -u)" \ + -e HOST_GID="$(id -g)" \ + init; do + if [ "${attempt}" -ge 3 ]; then + exit 1 + fi + attempt=$((attempt + 1)) + sleep 5 +done From 64406d3637f2f1724ccb4953a8dc623f975984e6 Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Sat, 8 Aug 2026 17:32:29 +0000 Subject: [PATCH 2/4] [minor] Harden checked-in ISLE lifecycle --- README.md | 6 +- compose.yaml | 11 +-- scripts/ensure-islandora-jwt-keypair.sh | 87 +++++++++++++++++++ scripts/initialize-compose.sh | 1 + scripts/sitectl-init.sh | 24 ----- ...ectl-build.sh => sitectl-prepare-build.sh} | 3 - scripts/sitectl-prepare-init.sh | 26 ++++++ scripts/sitectl-rollout-preflight.sh | 21 +++++ scripts/test-islandora-jwt-reset.sh | 81 +++++++++++++++++ 9 files changed, 224 insertions(+), 36 deletions(-) create mode 100644 scripts/ensure-islandora-jwt-keypair.sh delete mode 100644 scripts/sitectl-init.sh rename scripts/{sitectl-build.sh => sitectl-prepare-build.sh} (59%) create mode 100644 scripts/sitectl-prepare-init.sh create mode 100644 scripts/sitectl-rollout-preflight.sh create mode 100644 scripts/test-islandora-jwt-reset.sh diff --git a/README.md b/README.md index 7027ef4..87a1cd9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Docs: ## Requirements - [sitectl](https://sitectl.libops.io/install) installed on the host that will run the site. -- [`sitectl-isle`](https://github.com/libops/sitectl-isle) installed for ISLE create, validation, healthcheck, and helper commands. +- [`sitectl-isle`](https://github.com/libops/sitectl-isle) 1.5.0 or newer installed for ISLE create, validation, healthcheck, and helper commands. - [`sitectl-drupal`](https://github.com/libops/sitectl-drupal) installed because ISLE includes the Drupal plugin surface. - Docker with the Compose v2 plugin installed on the same host. @@ -35,6 +35,10 @@ The `drupal` service builds this checkout on top of the LibOps Islandora base im The lifecycle programs in `scripts/` are part of the versioned template contract with `sitectl-isle`. They keep build, initialization, readiness, and container-side diagnostics reviewable as files and are mounted read-only when a container needs them. Preserve their paths when maintaining an institution-specific fork. +Initialization preserves a valid existing Islandora RSA signing key, repairs or derives its public key, and replaces malformed legacy JWT material before the generic secret generator runs. Resetting initialization artifacts therefore uses the same keypair contract as a fresh `sitectl create`; it never creates unrelated private and public values. + +Before upgrading an older checkout to `sitectl-isle` 1.5.0, merge the lifecycle programs and read-only mounts from template v1.3.0. The deploy preflight rejects an incomplete legacy checkout before Compose stops a healthy site, then directs the operator to finish that template migration. + Docker Compose derives the project name from the checkout directory, so independent forks do not share containers, networks, or named volumes by default. Set `COMPOSE_PROJECT_NAME` explicitly when a stable name is required. If an existing checkout previously relied on this template's fixed `isle-site-template` project name, set `COMPOSE_PROJECT_NAME=isle-site-template` before starting it to keep using its existing named volumes, or migrate those volumes deliberately. ## Basic Operations diff --git a/compose.yaml b/compose.yaml index ce58767..01cd873 100644 --- a/compose.yaml +++ b/compose.yaml @@ -137,14 +137,8 @@ services: source: drupal-private-files target: /var/www/drupal/private type: volume - - read_only: true - source: ./scripts/drupal-media-storage-state.php - target: /var/www/drupal/drupal-media-storage-state.php - type: bind - - read_only: true - source: ./scripts/drupal-wait-installed.sh - target: /usr/local/lib/sitectl/drupal-wait-installed.sh - type: bind + - ./scripts/drupal-media-storage-state.php:/var/www/drupal/drupal-media-storage-state.php:ro,z + - ./scripts/drupal-wait-installed.sh:/usr/local/lib/sitectl/drupal-wait-installed.sh:ro,z - drupal-solr-config:/opt/solr/server/solr/default:z,rw fits: @@ -199,6 +193,7 @@ services: - ./certs:/work/certs:rw,z - ./secrets:/work/secrets:rw,z - ./compose.yaml:/work/compose.yaml:ro,z + - ./scripts/ensure-islandora-jwt-keypair.sh:/usr/local/lib/sitectl/ensure-islandora-jwt-keypair.sh:ro,z - ./scripts/initialize-compose.sh:/usr/local/lib/sitectl/initialize-compose.sh:ro,z database-init: diff --git a/scripts/ensure-islandora-jwt-keypair.sh b/scripts/ensure-islandora-jwt-keypair.sh new file mode 100644 index 0000000..42fdfae --- /dev/null +++ b/scripts/ensure-islandora-jwt-keypair.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +set -euo pipefail + +secrets_root="${SECRETS_ROOT:-./secrets}" +private_key="${secrets_root%/}/JWT_PRIVATE_KEY" +public_key="${secrets_root%/}/JWT_PUBLIC_KEY" +temporary_private="" +temporary_public="" + +cleanup() { + if [ -n "${temporary_private}" ]; then + rm -f -- "${temporary_private}" + fi + if [ -n "${temporary_public}" ]; then + rm -f -- "${temporary_public}" + fi +} +interrupt() { + exit 130 +} +terminate() { + exit 143 +} +trap cleanup EXIT +trap interrupt INT +trap terminate HUP TERM + +if [ -L "${secrets_root}" ] || [ -L "${private_key}" ] || [ -L "${public_key}" ]; then + echo "Refusing to manage an Islandora JWT key through a symbolic link" >&2 + exit 1 +fi +if { [ -e "${secrets_root}" ] && [ ! -d "${secrets_root}" ]; } || + { [ -e "${private_key}" ] && [ ! -f "${private_key}" ]; } || + { [ -e "${public_key}" ] && [ ! -f "${public_key}" ]; }; then + echo "Islandora JWT key paths must be regular files inside a directory" >&2 + exit 1 +fi + +install -d -m 0700 -- "${secrets_root}" +umask 077 + +valid_private_key() { + local modulus + + [ -s "${private_key}" ] || return 1 + openssl rsa -in "${private_key}" -check -noout >/dev/null 2>&1 || return 1 + modulus="$(openssl rsa -in "${private_key}" -modulus -noout 2>/dev/null)" + modulus="${modulus#Modulus=}" + if [ "${#modulus}" -gt 512 ]; then + return 0 + fi + [ "${#modulus}" -eq 512 ] || return 1 + case "${modulus:0:1}" in + 8|9|a|A|b|B|c|C|d|D|e|E|f|F) return 0 ;; + *) return 1 ;; + esac +} + +if ! valid_private_key; then + temporary_private="$(mktemp "${private_key}.tmp.XXXXXX")" + openssl genpkey \ + -algorithm RSA \ + -pkeyopt rsa_keygen_bits:2048 \ + -out "${temporary_private}" \ + >/dev/null 2>&1 + chmod 0600 "${temporary_private}" + mv -f -- "${temporary_private}" "${private_key}" + temporary_private="" +fi +chmod 0600 "${private_key}" + +temporary_public="$(mktemp "${public_key}.tmp.XXXXXX")" +openssl pkey \ + -in "${private_key}" \ + -pubout \ + -out "${temporary_public}" \ + >/dev/null 2>&1 +chmod 0600 "${temporary_public}" + +if [ -s "${public_key}" ] && cmp -s -- "${temporary_public}" "${public_key}"; then + rm -f -- "${temporary_public}" +else + mv -f -- "${temporary_public}" "${public_key}" +fi +temporary_public="" +chmod 0600 "${public_key}" diff --git a/scripts/initialize-compose.sh b/scripts/initialize-compose.sh index 34aebe1..7ac96db 100644 --- a/scripts/initialize-compose.sh +++ b/scripts/initialize-compose.sh @@ -3,4 +3,5 @@ set -euo pipefail generate-certs.sh +bash /usr/local/lib/sitectl/ensure-islandora-jwt-keypair.sh generate-compose-secrets.sh diff --git a/scripts/sitectl-init.sh b/scripts/sitectl-init.sh deleted file mode 100644 index 3c04827..0000000 --- a/scripts/sitectl-init.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -if [ ! -f .env ]; then - cp sample.env .env -fi -if ! grep -q '^DRUPAL_HEALTHCHECK_START_PERIOD=' .env; then - printf '\nDRUPAL_HEALTHCHECK_START_PERIOD=5m\n' >>.env -fi - -mkdir -p ./certs ./secrets - -attempt=1 -until docker compose run --rm \ - -e HOST_UID="$(id -u)" \ - -e HOST_GID="$(id -g)" \ - init; do - if [ "${attempt}" -ge 3 ]; then - exit 1 - fi - attempt=$((attempt + 1)) - sleep 5 -done diff --git a/scripts/sitectl-build.sh b/scripts/sitectl-prepare-build.sh similarity index 59% rename from scripts/sitectl-build.sh rename to scripts/sitectl-prepare-build.sh index 3a6ad7a..036887d 100644 --- a/scripts/sitectl-build.sh +++ b/scripts/sitectl-prepare-build.sh @@ -5,6 +5,3 @@ set -euo pipefail if [ -d drupal/rootfs ]; then find drupal/rootfs -type d -exec chmod 755 {} + fi - -docker compose pull --ignore-buildable --ignore-pull-failures -docker compose build diff --git a/scripts/sitectl-prepare-init.sh b/scripts/sitectl-prepare-init.sh new file mode 100644 index 0000000..421d020 --- /dev/null +++ b/scripts/sitectl-prepare-init.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +require_regular_file() { + local path="$1" + + if [ ! -f "${path}" ] || [ -L "${path}" ]; then + echo "Required ISLE template file is missing or unsafe: ${path}" >&2 + exit 1 + fi +} + +require_regular_file "${BASH_SOURCE[0]}" +require_regular_file compose.yaml +require_regular_file scripts/ensure-islandora-jwt-keypair.sh +require_regular_file scripts/initialize-compose.sh + +if [ ! -f .env ]; then + cp sample.env .env +fi +if ! grep -q '^DRUPAL_HEALTHCHECK_START_PERIOD=' .env; then + printf '\nDRUPAL_HEALTHCHECK_START_PERIOD=5m\n' >>.env +fi + +mkdir -p ./certs ./secrets diff --git a/scripts/sitectl-rollout-preflight.sh b/scripts/sitectl-rollout-preflight.sh new file mode 100644 index 0000000..34392c0 --- /dev/null +++ b/scripts/sitectl-rollout-preflight.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -euo pipefail + +require_regular_file() { + local path="$1" + + if [ ! -f "${path}" ] || [ -L "${path}" ]; then + echo "This checkout is missing a required ISLE template file (${path}); migrate it to template v1.3.0 or newer before deploying" >&2 + exit 1 + fi +} + +require_regular_file "${BASH_SOURCE[0]}" +require_regular_file compose.yaml +require_regular_file certs/rootCA.pem +require_regular_file conf/triplet/config.yaml +require_regular_file scripts/drupal-media-storage-state.php +require_regular_file scripts/drupal-wait-installed.sh +require_regular_file scripts/ensure-islandora-jwt-keypair.sh +require_regular_file scripts/initialize-compose.sh diff --git a/scripts/test-islandora-jwt-reset.sh b/scripts/test-islandora-jwt-reset.sh new file mode 100644 index 0000000..c61391b --- /dev/null +++ b/scripts/test-islandora-jwt-reset.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +set -euo pipefail + +private_key="secrets/JWT_PRIVATE_KEY" +public_key="secrets/JWT_PUBLIC_KEY" +derived_public="" + +cleanup() { + if [ -n "${derived_public}" ]; then + rm -f -- "${derived_public}" + fi +} +trap cleanup EXIT + +run_init() { + docker compose run --rm \ + -e HOST_UID="$(id -u)" \ + -e HOST_GID="$(id -g)" \ + init +} + +verify_keypair() { + local modulus + + [ "$(stat -c '%a' "${private_key}")" = "600" ] + [ "$(stat -c '%a' "${public_key}")" = "600" ] + openssl rsa -in "${private_key}" -check -noout >/dev/null 2>&1 + modulus="$(openssl rsa -in "${private_key}" -modulus -noout 2>/dev/null)" + modulus="${modulus#Modulus=}" + if [ "${#modulus}" -eq 512 ]; then + case "${modulus:0:1}" in + 8|9|a|A|b|B|c|C|d|D|e|E|f|F) ;; + *) return 1 ;; + esac + else + [ "${#modulus}" -gt 512 ] + fi + + derived_public="$(mktemp)" + openssl pkey -in "${private_key}" -pubout -out "${derived_public}" >/dev/null 2>&1 + cmp -s -- "${derived_public}" "${public_key}" + rm -f -- "${derived_public}" + derived_public="" +} + +run_init +verify_keypair +read -r original_private_checksum _ < <(sha256sum "${private_key}") + +printf 'invalid public key\n' >"${public_key}" +run_init +verify_keypair +read -r repaired_public_private_checksum _ < <(sha256sum "${private_key}") +[ "${repaired_public_private_checksum}" = "${original_private_checksum}" ] + +printf 'invalid private key\n' >"${private_key}" +run_init +verify_keypair +read -r repaired_private_checksum _ < <(sha256sum "${private_key}") +[ "${repaired_private_checksum}" != "${original_private_checksum}" ] + +rm -f -- "${public_key}" +mkdir -- "${public_key}" +if run_init; then + echo "JWT initialization accepted a public-key directory" >&2 + exit 1 +fi +rmdir -- "${public_key}" +run_init +verify_keypair + +rm -f -- "${private_key}" +mkdir -- "${private_key}" +if run_init; then + echo "JWT initialization accepted a private-key directory" >&2 + exit 1 +fi +rmdir -- "${private_key}" +run_init +verify_keypair From 9f4a9e8b8665c610b197d570fe972f8dfdd3b4d7 Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Sat, 8 Aug 2026 17:35:51 +0000 Subject: [PATCH 3/4] [skip-release] Test companion sitectl lifecycle --- .github/workflows/sitectl-create-smoke-test.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sitectl-create-smoke-test.yaml b/.github/workflows/sitectl-create-smoke-test.yaml index 369dbc2..5eb006f 100644 --- a/.github/workflows/sitectl-create-smoke-test.yaml +++ b/.github/workflows/sitectl-create-smoke-test.yaml @@ -12,6 +12,16 @@ concurrency: cancel-in-progress: true jobs: + jwt-keypair-reset: + name: Islandora JWT reset contract + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Verify repeatable JWT initialization + run: bash scripts/test-islandora-jwt-reset.sh + create: name: sitectl create isle ${{ matrix.create_definition }} strategy: @@ -33,7 +43,7 @@ jobs: create-args: ${{ matrix.create_args }} local-plugin-path: sitectl-isle local-plugin-repository: libops/sitectl-isle - local-plugin-ref: c0f3cdf4a9532152af29c3fe032eccd39238d55b + local-plugin-ref: db22ac4cc4d6a39a8602e1499498fba3d75c943f packages: sitectl sitectl-drupal package-versions: sitectl=1.8.1 sitectl-drupal=1.3.0 allow-unversioned-packages: false From c64d28d8384179d51a49b60c67185c9909db76bf Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Sat, 8 Aug 2026 17:42:01 +0000 Subject: [PATCH 4/4] [patch] Initialize the LibOps environment file --- .github/workflows/sitectl-create-smoke-test.yaml | 2 +- scripts/sitectl-prepare-init.sh | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sitectl-create-smoke-test.yaml b/.github/workflows/sitectl-create-smoke-test.yaml index 5eb006f..1788a1b 100644 --- a/.github/workflows/sitectl-create-smoke-test.yaml +++ b/.github/workflows/sitectl-create-smoke-test.yaml @@ -43,7 +43,7 @@ jobs: create-args: ${{ matrix.create_args }} local-plugin-path: sitectl-isle local-plugin-repository: libops/sitectl-isle - local-plugin-ref: db22ac4cc4d6a39a8602e1499498fba3d75c943f + local-plugin-ref: 48759729ab47cbe11be4c363638538e0f919e23a packages: sitectl sitectl-drupal package-versions: sitectl=1.8.1 sitectl-drupal=1.3.0 allow-unversioned-packages: false diff --git a/scripts/sitectl-prepare-init.sh b/scripts/sitectl-prepare-init.sh index 421d020..e67cf5c 100644 --- a/scripts/sitectl-prepare-init.sh +++ b/scripts/sitectl-prepare-init.sh @@ -16,8 +16,12 @@ require_regular_file compose.yaml require_regular_file scripts/ensure-islandora-jwt-keypair.sh require_regular_file scripts/initialize-compose.sh -if [ ! -f .env ]; then - cp sample.env .env +if [ -L .env ] || { [ -e .env ] && [ ! -f .env ]; }; then + echo "ISLE environment path must be a regular file, not a directory or symbolic link: .env" >&2 + exit 1 +fi +if [ ! -e .env ]; then + install -m 0600 /dev/null .env fi if ! grep -q '^DRUPAL_HEALTHCHECK_START_PERIOD=' .env; then printf '\nDRUPAL_HEALTHCHECK_START_PERIOD=5m\n' >>.env