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
12 changes: 11 additions & 1 deletion .github/workflows/sitectl-create-smoke-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/demo-objects.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions scripts/drupal-wait-installed.sh
Original file line number Diff line number Diff line change
@@ -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
87 changes: 87 additions & 0 deletions scripts/ensure-islandora-jwt-keypair.sh
Original file line number Diff line number Diff line change
@@ -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}"
7 changes: 7 additions & 0 deletions scripts/initialize-compose.sh
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions scripts/sitectl-prepare-build.sh
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions scripts/sitectl-prepare-init.sh
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions scripts/sitectl-rollout-preflight.sh
Original file line number Diff line number Diff line change
@@ -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
81 changes: 81 additions & 0 deletions scripts/test-islandora-jwt-reset.sh
Original file line number Diff line number Diff line change
@@ -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