diff --git a/Makefile b/Makefile index 82afcbd..ea26fb7 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ help: ## Show this help message echo 'Usage: make [target]' echo '' echo 'Available targets:' - awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%s\033[0m\t%s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort | column -t -s $$'\t' + awk -f scripts/make-help.awk $(MAKEFILE_LIST) | sort | column -t -s $$'\t' init reconcile: ## Generate or repair declared initialization state sitectl compose reconcile diff --git a/README.md b/README.md index 26de41e..36bcc34 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,12 @@ The ingress component writes `INGRESS_HOSTNAMES` as comma-separated hostnames an See the [Drupal sitectl plugin docs](https://sitectl.libops.io/plugins/drupal) for Drush helpers, development mode, sync operations, login links, and Drupal-specific jobs. +## Versioned runtime programs + +Template v1.2.0 adds the container-side readiness, database-migration, and strict-verification programs under `scripts/`. The `drupal` service mounts each program read-only at a stable path under `/usr/local/lib/sitectl`; `sitectl-drupal` invokes those files instead of injecting PHP or shell source through the container command line. + +Before stopping a running site, `sitectl deploy` checks that every required source is a regular tracked file, that the shell programs remain executable, and that the Compose service declares every mount read-only. It also asks Compose to confirm that the effective service can use each mounted program. A site created from template v1.1.0 or older must first incorporate the v1.2.0 template changes. An incompatible checkout fails before the existing containers are stopped and identifies the required template version; there is no inline fallback with behavior that could differ from the reviewed checkout. + ## Makefile The Makefile is intentionally small. It only keeps Drupal-specific targets that are not core sitectl operations: @@ -99,6 +105,7 @@ Use `sitectl compose ...` and `sitectl set ...` directly for normal stack operat - `mariadb` stores application data. - `solr` provides search. - Secrets are generated into `./secrets/`. +- Rollout and verification programs are versioned with the site and mounted read-only into `drupal`. Drupal code is Composer-managed. Custom modules and themes belong under `web/modules/custom` and `web/themes/custom`. diff --git a/compose.yaml b/compose.yaml index f0ce307..379e06c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -94,6 +94,13 @@ services: - source: CERT_AUTHORITY - source: UID volumes: + - ./scripts/drupal-lint-custom.sh:/usr/local/lib/sitectl/drupal-lint-custom.sh:ro,z + - ./scripts/drupal-rollout-migrate.sh:/usr/local/lib/sitectl/drupal-rollout-migrate.sh:ro,z + - ./scripts/drupal-test-custom.sh:/usr/local/lib/sitectl/drupal-test-custom.sh:ro,z + - ./scripts/drupal-verify-config-drift.php:/usr/local/lib/sitectl/drupal-verify-config-drift.php:ro,z + - ./scripts/drupal-verify-cron-queue.php:/usr/local/lib/sitectl/drupal-verify-cron-queue.php:ro,z + - ./scripts/drupal-verify-solr.php:/usr/local/lib/sitectl/drupal-verify-solr.php:ro,z + - ./scripts/drupal-wait-installed.sh:/usr/local/lib/sitectl/drupal-wait-installed.sh:ro,z - type: volume source: drupal-public-files target: /var/www/drupal/web/sites/default/files diff --git a/scripts/drupal-lint-custom.sh b/scripts/drupal-lint-custom.sh new file mode 100755 index 0000000..216bb03 --- /dev/null +++ b/scripts/drupal-lint-custom.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -euo pipefail + +custom_dir="${CUSTOM_DIR:-web/modules/custom}" + +if [ ! -d "$custom_dir" ]; then + echo "No custom Drupal module directory found at $custom_dir; skipping Drupal code lint." + exit 0 +fi + +if ! find "$custom_dir" -type f \( -name '*.module' -o -name '*.php' -o -name '*.inc' \) | grep -q .; then + echo "No custom Drupal PHP files found under $custom_dir; skipping Drupal code lint." + exit 0 +fi + +cp web/core/phpcs.xml.dist . 2>/dev/null || true + +if [ "${RUN_PHPCBF:-0}" = "1" ]; then + if [ ! -x vendor/bin/phpcbf ]; then + echo 'vendor/bin/phpcbf was not found in the Drupal container' >&2 + exit 1 + fi + + php vendor/bin/phpcbf \ + -n \ + --standard=Drupal,DrupalPractice \ + --extensions=module,php,inc \ + "$custom_dir" || true +fi + +if [ ! -x vendor/bin/phpcs ]; then + echo 'vendor/bin/phpcs was not found in the Drupal container' >&2 + exit 1 +fi + +php vendor/bin/phpcs \ + -n \ + --standard=Drupal,DrupalPractice \ + --extensions=module,php,inc \ + "$custom_dir" + +echo 'PHP codesniff passed' diff --git a/scripts/drupal-rollout-migrate.sh b/scripts/drupal-rollout-migrate.sh new file mode 100755 index 0000000..a2afa40 --- /dev/null +++ b/scripts/drupal-rollout-migrate.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh + +set -eu + +cd /var/www/drupal + +/var/www/drupal/vendor/bin/drush updb -y +/var/www/drupal/vendor/bin/drush cr diff --git a/scripts/drupal-test-custom.sh b/scripts/drupal-test-custom.sh new file mode 100755 index 0000000..957c9fb --- /dev/null +++ b/scripts/drupal-test-custom.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +custom_dir="${CUSTOM_DIR:-web/modules/custom}" + +if [ ! -d "$custom_dir" ]; then + echo "No custom Drupal module directory found at $custom_dir; baseline application assertions passed." + exit 0 +fi + +if ! find "$custom_dir" -type f \( -path '*/tests/src/*' -o -name '*Test.php' \) | grep -q .; then + echo "No custom Drupal tests found under $custom_dir; baseline application assertions passed." + exit 0 +fi + +if [ ! -x vendor/bin/phpunit ]; then + echo 'vendor/bin/phpunit was not found in the Drupal container' >&2 + exit 1 +fi + +s6-setuidgid nginx php vendor/bin/phpunit -c phpunit.unit.xml --debug "$custom_dir" diff --git a/scripts/drupal-verify-config-drift.php b/scripts/drupal-verify-config-drift.php new file mode 100644 index 0000000..cf86df0 --- /dev/null +++ b/scripts/drupal-verify-config-drift.php @@ -0,0 +1,35 @@ +listAll(), $sync->listAll())); + sort($names); + + foreach ($names as $name) { + $activeData = $active->read($name); + $syncData = $sync->read($name); + if (!is_array($activeData) || !is_array($syncData) || $activeData === $syncData) { + continue; + } + + $keys = array_unique(array_merge(array_keys($activeData), array_keys($syncData))); + sort($keys); + foreach ($keys as $key) { + if ( + !array_key_exists($key, $activeData) + || !array_key_exists($key, $syncData) + || $activeData[$key] !== $syncData[$key] + ) { + $result[$name][] = $key; + } + } + } +} + +print json_encode($result, JSON_THROW_ON_ERROR); diff --git a/scripts/drupal-verify-cron-queue.php b/scripts/drupal-verify-cron-queue.php new file mode 100644 index 0000000..b0bd99c --- /dev/null +++ b/scripts/drupal-verify-cron-queue.php @@ -0,0 +1,11 @@ +getDefinitions(); + +print json_encode([ + 'cron' => $cron !== null, + 'queue_workers' => count($workers), +], JSON_THROW_ON_ERROR); diff --git a/scripts/drupal-verify-solr.php b/scripts/drupal-verify-solr.php new file mode 100644 index 0000000..35509c5 --- /dev/null +++ b/scripts/drupal-verify-solr.php @@ -0,0 +1,13 @@ +getStorage('search_api_server') + ->load('default_solr_server'); + +print json_encode([ + 'exists' => $server !== null, + 'enabled' => $server ? (bool) $server->status() : false, + 'available' => $server ? (bool) $server->isAvailable() : false, +], JSON_THROW_ON_ERROR); diff --git a/scripts/drupal-wait-installed.sh b/scripts/drupal-wait-installed.sh new file mode 100755 index 0000000..7cec687 --- /dev/null +++ b/scripts/drupal-wait-installed.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env 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/lint.sh b/scripts/lint.sh index 359e7d3..6a0d171 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -13,44 +13,4 @@ docker compose exec -T \ -e CUSTOM_DIR="${custom_dir}" \ -e RUN_PHPCBF="${RUN_PHPCBF:-0}" \ "${service}" \ - bash -lc ' - set -euo pipefail - - if [ ! -d "${CUSTOM_DIR}" ]; then - echo "No custom Drupal module directory found at ${CUSTOM_DIR}; skipping Drupal code lint." - exit 0 - fi - - if ! find "${CUSTOM_DIR}" -type f \( -name "*.module" -o -name "*.php" -o -name "*.inc" \) | grep -q .; then - echo "No custom Drupal PHP files found under ${CUSTOM_DIR}; skipping Drupal code lint." - exit 0 - fi - - cp web/core/phpcs.xml.dist . 2>/dev/null || true - - if [ "${RUN_PHPCBF}" = "1" ]; then - if [ ! -x vendor/bin/phpcbf ]; then - echo "vendor/bin/phpcbf was not found in the Drupal container" >&2 - exit 1 - fi - - php vendor/bin/phpcbf \ - -n \ - --standard=Drupal,DrupalPractice \ - --extensions=module,php,inc \ - "${CUSTOM_DIR}" || true - fi - - if [ ! -x vendor/bin/phpcs ]; then - echo "vendor/bin/phpcs was not found in the Drupal container" >&2 - exit 1 - fi - - php vendor/bin/phpcs \ - -n \ - --standard=Drupal,DrupalPractice \ - --extensions=module,php,inc \ - "${CUSTOM_DIR}" - - echo "PHP codesniff passed" - ' + /usr/local/lib/sitectl/drupal-lint-custom.sh diff --git a/scripts/make-help.awk b/scripts/make-help.awk new file mode 100644 index 0000000..f684afc --- /dev/null +++ b/scripts/make-help.awk @@ -0,0 +1,7 @@ +BEGIN { + FS = ":.*?## " +} + +/^[a-zA-Z_-]+:.*?## / { + printf " \033[36m%s\033[0m\t%s\n", $1, $2 +} diff --git a/scripts/sitectl-rollout-preflight.sh b/scripts/sitectl-rollout-preflight.sh new file mode 100755 index 0000000..f0420c5 --- /dev/null +++ b/scripts/sitectl-rollout-preflight.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 "This checkout is missing a required Drupal template file (${path}); migrate it to template v1.2.0 or newer before deploying" >&2 + exit 1 + fi +} + +require_executable_file() { + local path="$1" + + require_regular_file "${path}" + if [ ! -x "${path}" ]; then + echo "This checkout has a non-executable Drupal template program (${path}); restore it from template v1.2.0 or newer before deploying" >&2 + exit 1 + fi +} + +require_executable_file "${BASH_SOURCE[0]}" +require_regular_file compose.yaml +require_executable_file scripts/drupal-wait-installed.sh +require_executable_file scripts/drupal-rollout-migrate.sh +require_regular_file scripts/drupal-verify-cron-queue.php +require_regular_file scripts/drupal-verify-solr.php +require_regular_file scripts/drupal-verify-config-drift.php diff --git a/scripts/test.sh b/scripts/test.sh index 28710a8..38f0e8b 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -2,6 +2,7 @@ set -euo pipefail +bash scripts/sitectl-rollout-preflight.sh docker compose run --rm -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" init service="${DRUPAL_SERVICE:-drupal}" @@ -14,23 +15,4 @@ sitectl verify --strict docker compose exec -T \ -e CUSTOM_DIR="${custom_dir}" \ "${service}" \ - bash -lc ' - set -euo pipefail - - if [ ! -d "${CUSTOM_DIR}" ]; then - echo "No custom Drupal module directory found at ${CUSTOM_DIR}; baseline application assertions passed." - exit 0 - fi - - if ! find "${CUSTOM_DIR}" -type f \( -path "*/tests/src/*" -o -name "*Test.php" \) | grep -q .; then - echo "No custom Drupal tests found under ${CUSTOM_DIR}; baseline application assertions passed." - exit 0 - fi - - if [ ! -x vendor/bin/phpunit ]; then - echo "vendor/bin/phpunit was not found in the Drupal container" >&2 - exit 1 - fi - - su nginx -s /bin/bash -c "php vendor/bin/phpunit -c phpunit.unit.xml --debug ${CUSTOM_DIR}" - ' + /usr/local/lib/sitectl/drupal-test-custom.sh