diff --git a/.github/workflows/sitectl-create-smoke-test.yaml b/.github/workflows/sitectl-create-smoke-test.yaml index d9a4bc7..d3645f7 100644 --- a/.github/workflows/sitectl-create-smoke-test.yaml +++ b/.github/workflows/sitectl-create-smoke-test.yaml @@ -26,11 +26,14 @@ jobs: --iiif triplet --iiif-topology disabled --bot-mitigation off - uses: libops/.github/.github/workflows/sitectl-create-smoke-test.yaml@55489923a34bd3714d2a1b9c5f8a6c678ea1cbfe # main + uses: libops/.github/.github/workflows/sitectl-create-smoke-test.yaml@92a70c027cfe2553a8bc4adaba67a3e620ff6958 # main with: plugin: isle create-definition: ${{ matrix.create_definition }} create-args: ${{ matrix.create_args }} - packages: sitectl sitectl-drupal sitectl-isle - package-versions: sitectl=1.0.0 sitectl-drupal=1.0.0 sitectl-isle=1.0.0 + packages: sitectl sitectl-drupal + package-versions: sitectl=1.0.0 sitectl-drupal=1.0.0 allow-unversioned-packages: false + local-plugin-repository: libops/sitectl-isle + local-plugin-ref: canonical-compose-filename + local-plugin-path: sitectl-isle-source diff --git a/Makefile b/Makefile index 95e2b0f..192f809 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL := /bin/bash -.PHONY: help clean demo-objects sync-solr-conf create-starter-site-pr +.PHONY: help init up clean reconcile healthcheck test demo-objects sync-solr-conf create-starter-site-pr .SILENT: -include custom.Makefile @@ -12,7 +12,19 @@ help: ## Show this help message awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%s\033[0m\t%s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort | column -t -s $$'\t' clean: ## Delete stateful local data generated by the template - ./scripts/clean.sh + sitectl compose clean + +init reconcile: ## Generate or repair declared initialization state + sitectl compose reconcile + +up: ## Start the complete site and wait for health + sitectl compose up --wait + +healthcheck: ## Check Compose and application health + sitectl healthcheck + +test: ## Run the full ISLE template smoke test + ./scripts/test.sh demo-objects: ## Add demo objects from https://github.com/Islandora-Devops/islandora_demo_objects ./scripts/demo-objects.sh diff --git a/docker-compose.yml b/compose.yaml similarity index 88% rename from docker-compose.yml rename to compose.yaml index 98e5f26..5a1db96 100644 --- a/docker-compose.yml +++ b/compose.yaml @@ -165,18 +165,26 @@ services: - source: JWT_PUBLIC_KEY init: - entrypoint: /scripts/init-entrypoint.sh - image: libops/base:3.2.2.0@sha256:0c6bab5444d4758bb977a42c1e5199cba733c7776e1aa006f8021108a785c547 + entrypoint: /bin/bash + command: ["-euc", "generate-certs.sh && generate-compose-secrets.sh"] + image: libops/base:3.2.2.0@sha256:851e17742b5fee57038855f46b1a46d2716c0012c5f3a4788b7e5c7bc12fed5e networks: default: profiles: - none restart: "no" + working_dir: /work + environment: + CA_SUBJECT: /CN=LibOps ISLE Local Development CA + CERT_DIR: /work/certs + COMPOSE_FILE: /work/compose.yaml + SECRETS_ROOT: /work/secrets + SECRET_FORMAT_DRUPAL_DEFAULT_SALT: salt74 + SUBJECT_ALT_NAMES: DNS:*.islandora.dev,DNS:islandora.dev,DNS:localhost,IP:127.0.0.1,IP:::1 volumes: - - ./certs:/certs:rw,z - - ./secrets:/secrets:rw,z - - ./scripts:/scripts:ro,z - - ./docker-compose.yml:/docker-compose.yml:ro,z + - ./certs:/work/certs:rw,z + - ./secrets:/work/secrets:rw,z + - ./compose.yaml:/work/compose.yaml:ro,z mariadb: <<: *common @@ -200,7 +208,7 @@ services: depends_on: drupal: condition: service_healthy - image: libops/solr:9@sha256:267504e413f0d2226a734ad3031487dfd3f9ca75c0e3284c7d924eae6388e6b5 + image: libops/solr:9@sha256:d4f5d06c5cba47677ba96580c1a8b524f26f93c0d05392dc287b34da9ca07d86 volumes: - solr-data:/data:rw - read_only: false @@ -228,7 +236,7 @@ services: healthcheck: start_period: 10s test: traefik healthcheck --ping - image: traefik:v3.7.5@sha256:e4d98158c01ad752fc1071d4e9573788747230d902cdde00a772516e692d07c9 + image: traefik:v3.7.9@sha256:652929a140a32d7cafafb13c6cdfab5376cfeff800f51397b87b524501ed02a8 networks: default: aliases: diff --git a/scripts/clean.sh b/scripts/clean.sh deleted file mode 100755 index 0bfa11a..0000000 --- a/scripts/clean.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -set -eou pipefail - -echo "This will delete all your data." -read -p "Do you want to continue? (y/N): " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Operation cancelled" - exit 1 -fi - -docker compose down -v -rm -f ./certs/* ./secrets/* diff --git a/scripts/generate-certs.sh b/scripts/generate-certs.sh deleted file mode 100755 index 810f0d6..0000000 --- a/scripts/generate-certs.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# shellcheck shell=bash - -set -euo pipefail - -PROGDIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd)" -readonly PROGDIR - -CERT_DIR="${PROGDIR}/certs" -CA_KEY="${CERT_DIR}/rootCA-key.pem" -CA_CERT="${CERT_DIR}/rootCA.pem" -LEAF_KEY="${CERT_DIR}/privkey.pem" -LEAF_CERT="${CERT_DIR}/cert.pem" -readonly CERT_DIR CA_KEY CA_CERT LEAF_KEY LEAF_CERT - -readonly CA_SUBJECT='/CN=LibOps ISLE Local Development CA' -readonly LEAF_SUBJECT='/CN=localhost' -readonly SUBJECT_ALT_NAMES='DNS:*.islandora.io,DNS:islandora.io,DNS:*.islandora.info,DNS:islandora.info,DNS:localhost,IP:127.0.0.1,IP:::1' - -install -d -m 0700 "${CERT_DIR}" - -if [ -s "${CA_CERT}" ] && [ ! -s "${CA_KEY}" ]; then - echo "Certificate authority key is missing for existing ${CA_CERT}" >&2 - exit 1 -fi - -if [ ! -s "${CA_KEY}" ]; then - echo "Creating: ${CA_KEY}" >&2 - umask 077 - openssl genrsa -out "${CA_KEY}" 4096 -fi -chmod 0600 "${CA_KEY}" - -if [ ! -s "${CA_CERT}" ]; then - echo "Creating: ${CA_CERT}" >&2 - openssl req -x509 -new -sha256 \ - -key "${CA_KEY}" \ - -out "${CA_CERT}" \ - -days 3650 \ - -subj "${CA_SUBJECT}" \ - -addext 'subjectKeyIdentifier=hash' \ - -addext 'authorityKeyIdentifier=keyid:always,issuer' \ - -addext 'basicConstraints=critical,CA:TRUE' \ - -addext 'keyUsage=critical,keyCertSign,cRLSign' -fi -chmod 0644 "${CA_CERT}" - -if [ -s "${LEAF_CERT}" ] && [ ! -s "${LEAF_KEY}" ]; then - echo "Private key is missing for existing ${LEAF_CERT}" >&2 - exit 1 -fi - -if [ ! -s "${LEAF_KEY}" ]; then - echo "Creating: ${LEAF_KEY}" >&2 - umask 077 - openssl genrsa -out "${LEAF_KEY}" 2048 -fi -chmod 0600 "${LEAF_KEY}" - -if [ ! -s "${LEAF_CERT}" ]; then - echo "Creating: ${LEAF_CERT}" >&2 - workdir="$(mktemp -d)" - trap 'rm -rf "${workdir}"' EXIT - - openssl req -new -sha256 \ - -key "${LEAF_KEY}" \ - -out "${workdir}/leaf.csr" \ - -subj "${LEAF_SUBJECT}" - - cat >"${workdir}/leaf.ext" <&2 - (grep -ao '[A-Za-z0-9_-]' "${SALT_FILE}" -fi -chmod 0600 "${SALT_FILE}" - -# Use openssl to generate certificates. -PRIVATE_KEY_FILE="${PROGDIR}/secrets/JWT_PRIVATE_KEY" -readonly PRIVATE_KEY_FILE -if [ ! -s "${PRIVATE_KEY_FILE}" ]; then - echo "Creating: ${PRIVATE_KEY_FILE}" >&2 - openssl genrsa 2048 >"${PRIVATE_KEY_FILE}" 2>/dev/null -fi - -# Public key is derived from the private key. -PUBLIC_KEY_FILE="${PROGDIR}/secrets/JWT_PUBLIC_KEY" -readonly PUBLIC_KEY_FILE -if [ ! -s "${PUBLIC_KEY_FILE}" ]; then - echo "Creating: ${PUBLIC_KEY_FILE}" >&2 - openssl rsa -pubout <"${PRIVATE_KEY_FILE}" >"${PUBLIC_KEY_FILE}" 2>/dev/null -fi - -# The snippet below list all the secret files referenced by the docker-compose.yml file. -# For each it will generate a random password. -readonly CHARACTERS='[A-Za-z0-9]' -readonly LENGTH=32 - -declare -a SECRETS -while IFS= read -r line; do - SECRETS+=("$line") -done < \ - <( - yq -r '.secrets[].file' "${PROGDIR}/docker-compose.yml" | uniq - ) - -for secret in "${SECRETS[@]}"; do - mode=0600 - case "${secret}" in - ./*) secret="${PROGDIR}/${secret#./}" ;; - esac - case "${secret}" in - "${PROGDIR}"/certs/*) continue ;; - esac - if [ ! -s "${secret}" ]; then - echo "Creating: ${secret}" >&2 - install -d -m 0700 "$(dirname -- "${secret}")" - (grep -ao "${CHARACTERS}" "${secret}" - fi - if [ "${secret}" = "${PUBLIC_KEY_FILE}" ]; then - mode=0644 - fi - chmod "${mode}" "${secret}" -done diff --git a/scripts/init-database.sh b/scripts/init-database.sh deleted file mode 100755 index d89dce7..0000000 --- a/scripts/init-database.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -DB_HOST="${DB_HOST:-mariadb}" -DB_PORT="${DB_PORT:-3306}" -DB_ROOT_USER="${DB_ROOT_USER:-root}" -DB_CHARACTER_SET="${DB_CHARACTER_SET:-utf8mb4}" -DB_COLLATION="${DB_COLLATION:-utf8mb4_unicode_ci}" -DB_ROOT_PASSWORD_FILE="${DB_ROOT_PASSWORD_FILE:-/run/secrets/DB_ROOT_PASSWORD}" -DB_PASSWORD_FILE="${DB_PASSWORD_FILE:-/run/secrets/DB_PASSWORD}" -readonly DB_HOST DB_PORT DB_ROOT_USER DB_CHARACTER_SET DB_COLLATION -readonly DB_ROOT_PASSWORD_FILE DB_PASSWORD_FILE - -: "${DB_NAME:?DB_NAME is required}" -: "${DB_USER:?DB_USER is required}" - -validate_identifier() { - local name="$1" - local value="$2" - if [[ ! "${value}" =~ ^[A-Za-z0-9_]+$ ]]; then - echo "${name} must contain only letters, numbers, and underscores" >&2 - exit 1 - fi -} - -read_secret() { - local name="$1" - local path="$2" - local value - if [ ! -s "${path}" ]; then - echo "${name} secret is missing or empty at ${path}" >&2 - exit 1 - fi - value="$(cat -- "${path}")" - if [ -z "${value}" ] || [[ "${value}" == *$'\n'* ]] || [[ "${value}" == *$'\r'* ]]; then - echo "${name} must be a non-empty single-line secret" >&2 - exit 1 - fi - printf '%s' "${value}" -} - -escape_option_value() { - local value="$1" - value="${value//\\/\\\\}" - value="${value//\"/\\\"}" - printf '%s' "${value}" -} - -escape_sql_literal() { - local value="$1" - value="${value//\\/\\\\}" - value="${value//\'/\'\'}" - printf '%s' "${value}" -} - -validate_identifier DB_ROOT_USER "${DB_ROOT_USER}" -validate_identifier DB_NAME "${DB_NAME}" -validate_identifier DB_USER "${DB_USER}" -validate_identifier DB_CHARACTER_SET "${DB_CHARACTER_SET}" -validate_identifier DB_COLLATION "${DB_COLLATION}" -if [[ ! "${DB_HOST}" =~ ^[A-Za-z0-9._:-]+$ ]]; then - echo "DB_HOST contains unsupported characters" >&2 - exit 1 -fi -if [[ ! "${DB_PORT}" =~ ^[0-9]+$ ]] || [ "${DB_PORT}" -lt 1 ] || [ "${DB_PORT}" -gt 65535 ]; then - echo "DB_PORT must be an integer from 1 through 65535" >&2 - exit 1 -fi - -root_password="$(read_secret DB_ROOT_PASSWORD "${DB_ROOT_PASSWORD_FILE}")" -db_password="$(read_secret DB_PASSWORD "${DB_PASSWORD_FILE}")" -root_password_option="$(escape_option_value "${root_password}")" -db_user_sql="$(escape_sql_literal "${DB_USER}")" -db_password_sql="$(escape_sql_literal "${db_password}")" -readonly root_password db_password root_password_option db_user_sql db_password_sql - -credentials_dir="$(mktemp -d)" -credentials_file="${credentials_dir}/client.cnf" -cleanup() { - rm -rf "${credentials_dir}" -} -trap cleanup EXIT -umask 077 -cat >"${credentials_file}" </dev/null 2>&1; then - database_ready=true - break - fi - sleep 2 -done -if [ "${database_ready}" != true ]; then - echo "MariaDB root access was not ready after 120 seconds" >&2 - exit 1 -fi - -mariadb --defaults-extra-file="${credentials_file}" <&2 -fi diff --git a/scripts/init.sh b/scripts/init.sh deleted file mode 100755 index ef27d6c..0000000 --- a/scripts/init.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash - -set -eou pipefail - -# shellcheck disable=SC1091 -source "$(dirname "${BASH_SOURCE[0]}")/profile.sh" - -if is_dev_mode && is_docker_rootless; then - echo "Development mode is not supported on rootless docker." - echo "Set DEVELOPMENT_ENVIRONMENT=false in the compose service environment." - exit 1 -fi - -host_uid="$(id -u)" -host_gid="$(id -g)" - -docker compose run --rm \ - -e HOST_UID="${host_uid}" \ - -e HOST_GID="${host_gid}" \ - init - -if [ "${host_uid}" -eq 0 ]; then - chown -R "${host_uid}:${host_gid}" ./certs ./secrets -else - unowned_path="$(find ./certs ./secrets ! -user "${host_uid}" -print -quit)" - if [ -n "${unowned_path}" ]; then - if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then - sudo chown -R "${host_uid}:${host_gid}" ./certs ./secrets - else - echo "Could not change certs/secrets ownership without sudo; continuing after container-side ownership fix." >&2 - fi - fi -fi - -mkdir -p ./certs -id -u > ./certs/UID -if [ -d drupal/rootfs ]; then - find drupal/rootfs -type d -exec chmod 755 {} \; -fi diff --git a/scripts/ping.sh b/scripts/ping.sh deleted file mode 100755 index 674ff6d..0000000 --- a/scripts/ping.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -set -eou pipefail - -# shellcheck disable=SC1091 -source "$(dirname "${BASH_SOURCE[0]}")/profile.sh" - -MAX_RETRIES=${MAX_RETRIES:-10} -SLEEP_INCREMENT=5 -RETRIES=0 -URL="$(site_url)" -while true; do - timeout 5 curl -fs "${URL%/}/" | grep Islandora && break || exit_code=$? - - RETRIES=$((RETRIES + 1)) - if [ "$RETRIES" -ge "$MAX_RETRIES" ]; then - echo "Site failed to come online after $MAX_RETRIES attempts (Last exit code: $exit_code)." >&2 - exit 1 - fi - - SLEEP=$(( SLEEP_INCREMENT * RETRIES )) - echo "Site is not live yet. Retrying in $SLEEP seconds... (Attempt $RETRIES/$MAX_RETRIES)" >&2 - sleep "$SLEEP" -done diff --git a/scripts/profile.sh b/scripts/profile.sh deleted file mode 100755 index ae1359c..0000000 --- a/scripts/profile.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash - -set -euf -o pipefail - -RESET="" -RED="" -GREEN="" -BLUE="" -YELLOW="" -if command -v tput >/dev/null 2>&1 && [ -n "${TERM:-}" ]; then - RESET="$(tput sgr0 2>/dev/null || true)" - RED="$(tput setaf 9 2>/dev/null || true)" - GREEN="$(tput setaf 2 2>/dev/null || true)" - BLUE="$(tput setaf 6 2>/dev/null || true)" - YELLOW="$(tput setaf 3 2>/dev/null || true)" -fi -readonly RESET RED GREEN BLUE YELLOW -# Export color codes for use by sourcing scripts -export RESET RED GREEN BLUE YELLOW - -# Alias for echo -e to avoid shellcheck warnings about printf format strings -# shellcheck disable=SC2039,SC3044 -echo_e() { - echo -e "$@" -} - -is_wsl() { - grep -qi microsoft /proc/version 2>/dev/null || grep -qi wsl /proc/version 2>/dev/null || false -} - -DEVELOPMENT_ENVIRONMENT="${DEVELOPMENT_ENVIRONMENT:-false}" -export DEVELOPMENT_ENVIRONMENT - -status_dev() { - [ "${STATUS_DEV:-false}" = "true" ] -} - -is_docker_rootless() { - status_dev || docker info -f "{{println .SecurityOptions}}" | grep -qi rootless -} - -is_dev_mode() { - status_dev || [ "${DEVELOPMENT_ENVIRONMENT:-}" = "true" ] -} - -site_url() { - sitectl stats --path . --format json | jq -er '.ingress.public_url' -} - -fcrepo_enabled() { - docker compose config --services 2>/dev/null | grep -qx 'fcrepo' -} - -container_url_for_url() { - local url - url="$1" - if fcrepo_enabled && [[ "${url}" =~ ^(https?)://(localhost|127\.0\.0\.1)(:[0-9]+)?(/.*)?$ ]]; then - printf '%s://drupal.internal%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[4]}" - return - fi - printf '%s\n' "${url}" -} - -container_network_for_url() { - local url - local compose_project_name - local traefik_container - url="${1:?url is required}" - if [[ "${url}" =~ ^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?(/.*)?$ ]]; then - traefik_container="$(docker compose ps -q traefik)" - if [ -n "${traefik_container}" ]; then - printf 'container:%s\n' "${traefik_container}" - return - fi - fi - compose_project_name="$(docker compose config --format json | jq -er '.name')" - printf '%s_default\n' "${compose_project_name}" -} diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..5d1e78e --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -euo pipefail + +docker compose config --quiet +docker compose pull --ignore-buildable --ignore-pull-failures +docker compose build --pull drupal +docker compose run --rm -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" init +docker compose up --remove-orphans --wait --wait-timeout "${COMPOSE_WAIT_TIMEOUT:-900}" -d +sitectl healthcheck --persist --timeout "${SITECTL_HEALTHCHECK_TIMEOUT:-10m}" diff --git a/scripts/up.sh b/scripts/up.sh deleted file mode 100755 index cd7f7ff..0000000 --- a/scripts/up.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash - -set -eou pipefail - -# shellcheck disable=SC1091 -source "$(dirname "${BASH_SOURCE[0]}")/profile.sh" - -docker compose up --remove-orphans --wait --wait-timeout "${COMPOSE_WAIT_TIMEOUT:-900}" - -URL="$(site_url)" - -MAX_RETRIES="${POST_INSTALL_MAX_RETRIES:-12}" ./scripts/ping.sh > /dev/null 2>&1 - -echo "---------------------------------------------------" -echo "🚀 Site available at: $URL" -echo "---------------------------------------------------" - -# don't open the URL if we're in GHA -if [ "${GITHUB_ACTIONS:-}" != "" ]; then - exit 0 -fi - -# don't open the URL if we're in an SSH session -if [ -n "${SSH_CONNECTION:-}" ] || [ -n "${SSH_CLIENT:-}" ] || [ -n "${SSH_TTY:-}" ]; then - exit 0 -fi - -# 6. Open in Browser (Cross-Platform) -case "$(uname -s)" in - Darwin*) open "$URL" ;; - Linux*) if grep -qi microsoft /proc/version; then - powershell.exe Start-Process "$URL" # WSL - else - xdg-open "$URL" # Standard Linux - fi ;; - CYGWIN*|MINGW*|MSYS*) start "$URL" ;; # Windows Native - *) echo "You can open $URL in your browser." ;; -esac