diff --git a/.github/workflows/sitectl-create-smoke-test.yaml b/.github/workflows/sitectl-create-smoke-test.yaml index 369dbc2..1788a1b 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: 48759729ab47cbe11be4c363638538e0f919e23a packages: sitectl sitectl-drupal package-versions: sitectl=1.8.1 sitectl-drupal=1.3.0 allow-unversioned-packages: false diff --git a/README.md b/README.md index 14cdca0..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. @@ -33,6 +33,12 @@ 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. + +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 8365620..01cd873 100644 --- a/compose.yaml +++ b/compose.yaml @@ -137,10 +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 + - ./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: @@ -176,7 +174,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 +193,8 @@ 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: 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/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 new file mode 100644 index 0000000..7ac96db --- /dev/null +++ b/scripts/initialize-compose.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +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-prepare-build.sh b/scripts/sitectl-prepare-build.sh new file mode 100644 index 0000000..036887d --- /dev/null +++ b/scripts/sitectl-prepare-build.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ -d drupal/rootfs ]; then + find drupal/rootfs -type d -exec chmod 755 {} + +fi diff --git a/scripts/sitectl-prepare-init.sh b/scripts/sitectl-prepare-init.sh new file mode 100644 index 0000000..e67cf5c --- /dev/null +++ b/scripts/sitectl-prepare-init.sh @@ -0,0 +1,30 @@ +#!/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 [ -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 +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