diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d24ca9a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test +on: + push: + branches: [main] + pull_request: +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install tools + run: sudo apt-get update -q && sudo apt-get install -y -q bats shellcheck php-cli + - name: Shellcheck + run: shellcheck scripts/*.sh */scan.sh tests/*/stubs/* + - name: Tests + run: bats tests/*/*.bats diff --git a/README.md b/README.md new file mode 100644 index 0000000..0554b90 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Sentinel actions + +CI actions and templates for [Sentinel](https://sentinelconnector.io), for +GitHub Actions and GitLab CI. Each runs in your own pipeline and your own site +image, and sends Sentinel only what that check needs. + +| Action | What it does | GitHub Actions | GitLab CI | +|---|---|---|---| +| [Upgrade scan](upgrade-scan) | Drupal upgrade readiness from Upgrade Status, without installing it on production. | `truecms/sentinel-actions/upgrade-scan@v1.0.0` | `upgrade-scan/gitlab.yml` | + +Each action folder has: + +- the GitHub action (`action.yml`) and the GitLab template (`gitlab.yml`), +- `examples/` with a complete GitHub workflow and GitLab job, +- `prompts/` with an AI prompt you paste into your coding assistant, opened in + your repository, to add the job for you. + +## Pin a release + +Releases cover the whole repository: `v1.0.0`, `v1.1.0` and so on. Use a tag +you have reviewed, or a full commit SHA: + +```yaml +- uses: truecms/sentinel-actions/upgrade-scan@ +``` + +GitLab templates download their script from the same tag and check its +SHA-256 before they run it. + +## Secrets + +Every credential goes into a CI secret (GitHub repository secret, or a masked +and protected GitLab CI/CD variable). Never write one into a file, a commit or +an AI chat. The actions never print them. + +## Development + +```bash +shellcheck scripts/*.sh */scan.sh tests/*/stubs/* +bats tests/*/*.bats +``` + +To release: run `scripts/pin-release.sh vX.Y.Z`, commit, then tag `vX.Y.Z`. + +## Licence + +MIT. See [LICENSE](LICENSE). Report security problems as described in +[SECURITY.md](SECURITY.md). diff --git a/scripts/pin-release.sh b/scripts/pin-release.sh new file mode 100755 index 0000000..1157beb --- /dev/null +++ b/scripts/pin-release.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Writes the release tag and each script's SHA-256 into every GitLab template. +# Run it in the release commit, before tagging: scripts/pin-release.sh v1.2.0 +set -euo pipefail + +tag="${1:-}" +[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'Usage: scripts/pin-release.sh vX.Y.Z' >&2; exit 1; } + +root="$(cd "$(dirname "$0")/.." && pwd)" +sha256() { + if command -v sha256sum > /dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi +} + +for template in "$root"/*/gitlab.yml; do + dir="$(dirname "$template")" + # Each template runs the scan.sh beside it. + sum="$(sha256 "$dir/scan.sh")" + sed -i.bak \ + -e "s/^\( SENTINEL_ACTIONS_REF:\).*/\1 $tag/" \ + -e "s/^\( SENTINEL_SCRIPT_SHA256:\).*/\1 $sum/" \ + "$template" + rm -f "$template.bak" + echo "$(basename "$dir")/gitlab.yml now runs scan.sh $tag ($sum)." +done diff --git a/tests/upgrade-scan/scan.bats b/tests/upgrade-scan/scan.bats new file mode 100644 index 0000000..c752ad7 --- /dev/null +++ b/tests/upgrade-scan/scan.bats @@ -0,0 +1,406 @@ +#!/usr/bin/env bats +# Tests for scan.sh with stand-in drush, composer and curl (tests/stubs). + +KEY="sk_scan_$(printf 'a%.0s' {1..64})" +UUID='0f8fad5b-d9cb-469f-a165-70867728950e' + +setup() { + ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + PROJECT="$BATS_TEST_TMPDIR/site" + mkdir -p "$PROJECT/web" + echo '{"original": true}' > "$PROJECT/composer.json" + echo '{"original": true}' > "$PROJECT/composer.lock" + export STUB_LOG="$BATS_TEST_TMPDIR/calls.log" + : > "$STUB_LOG" + export PATH="$ROOT/tests/upgrade-scan/stubs:$PATH" + export SENTINEL_UPGRADE_SCAN=1 + export SENTINEL_SITE_UUID="$UUID" + export SENTINEL_SCAN_KEY="$KEY" + export SENTINEL_API_URL='https://sentinel.example' + export SENTINEL_PROJECT_DIR="$PROJECT" + export SENTINEL_DRUSH="$ROOT/tests/upgrade-scan/stubs/drush" + unset SENTINEL_CONNECTOR_API_BASE_URL +} + +scan() { run bash "$ROOT/upgrade-scan/scan.sh"; } + +@test 'does nothing without SENTINEL_UPGRADE_SCAN' { + unset SENTINEL_UPGRADE_SCAN + scan + [ "$status" -eq 0 ] + [[ "$output" == *'is not set'* ]] + [ ! -s "$STUB_LOG" ] +} + +@test 'does nothing when SENTINEL_UPGRADE_SCAN is 0' { + SENTINEL_UPGRADE_SCAN=0 scan + [ "$status" -eq 0 ] + [ ! -s "$STUB_LOG" ] +} + +@test 'refuses a malformed key without calling anything or printing it' { + SENTINEL_SCAN_KEY='sk_scan_short' scan + [ "$status" -eq 1 ] + [[ "$output" != *sk_scan_short* ]] + [ ! -s "$STUB_LOG" ] +} + +@test 'refuses a missing or malformed site UUID' { + SENTINEL_SITE_UUID='not-a-uuid' scan + [ "$status" -eq 1 ] + [ ! -s "$STUB_LOG" ] +} + +@test 'refuses an API URL with a path' { + SENTINEL_API_URL='https://sentinel.example/api/v1' scan + [ "$status" -eq 1 ] +} + +@test 'no database: tells Sentinel no_database and passes' { + STUB_BOOTSTRAP='' scan + [ "$status" -eq 0 ] + grep -q '"reason":"no_database"' "$STUB_LOG" + grep -q "curl url https://sentinel.example/api/v1/sites/$UUID/upgrade-readiness/scan" "$STUB_LOG" + grep -q "curl header-file X-Scan-Key: $KEY" "$STUB_LOG" + ! grep -q 'analyze' "$STUB_LOG" +} + +@test 'no database fails when Sentinel did not hear about it' { + STUB_BOOTSTRAP='' STUB_HTTP=500 scan + [ "$status" -eq 1 ] +} + +@test 'uses the site Composer root even from a checkout with its own composer.json' { + mkdir -p "$BATS_TEST_TMPDIR/checkout" + echo '{"checkout": true}' > "$BATS_TEST_TMPDIR/checkout/composer.json" + SENTINEL_PROJECT_DIR="$BATS_TEST_TMPDIR/checkout" STUB_ROOT="$PROJECT/web" STUB_US_MISSING=1 scan + [ "$status" -eq 0 ] + grep -q '"checkout": true' "$BATS_TEST_TMPDIR/checkout/composer.json" + grep -q '"original": true' "$PROJECT/composer.json" +} + +@test 'no database and a closed route still passes' { + STUB_BOOTSTRAP='' STUB_HTTP=404 scan + [ "$status" -eq 0 ] + [[ "$output" == *'scan route is closed'* ]] +} + +@test 'old connector: tells Sentinel connector_outdated and fails' { + STUB_CONNECTOR='1.5.0' scan + [ "$status" -eq 1 ] + grep -q '"reason":"connector_outdated"' "$STUB_LOG" + ! grep -q 'analyze' "$STUB_LOG" +} + +@test 'connector not enabled: connector_outdated' { + STUB_CONNECTOR='' scan + [ "$status" -eq 1 ] + grep -q '"reason":"connector_outdated"' "$STUB_LOG" +} + +@test 'Upgrade Status present: scans and reports without Composer' { + scan + [ "$status" -eq 0 ] + ! grep -q '^composer' "$STUB_LOG" + grep -q 'drush pm:enable upgrade_status --yes' "$STUB_LOG" + grep -q 'drush upgrade_status:analyze --all --ignore-contrib' "$STUB_LOG" + grep -q "drush sentinel_connector:upgrade-status --scan-report --scan-started=[0-9]*$" "$STUB_LOG" + grep -q "report env key=set uuid=$UUID api=https://sentinel.example" "$STUB_LOG" + ! grep -q '^curl' "$STUB_LOG" +} + +@test 'scan exit code 3 (problems found) is a good scan' { + STUB_ANALYZE_RC=3 scan + [ "$status" -eq 0 ] + grep -q 'sentinel_connector:upgrade-status --scan-report' "$STUB_LOG" +} + +@test 'Upgrade Status missing: adds it, then restores composer files' { + STUB_US_MISSING=1 scan + [ "$status" -eq 0 ] + grep -q 'composer require --dev --no-install --no-interaction --no-progress drupal/upgrade_status$' "$STUB_LOG" + grep -q '^composer install --no-interaction --no-progress$' "$STUB_LOG" + grep -q '"original": true' "$PROJECT/composer.json" + grep -q '"original": true' "$PROJECT/composer.lock" +} + +@test 'Upgrade Status missing: falls back to core-dev at the site core version' { + STUB_US_MISSING=1 STUB_COMPOSER_FAIL_FIRST=1 scan + [ "$status" -eq 0 ] + grep -q 'drupal/core-dev:11.2.5' "$STUB_LOG" + grep -q '"original": true' "$PROJECT/composer.json" +} + +@test 'install fails: tells Sentinel install_failed, fails, restores files' { + STUB_US_MISSING=1 STUB_COMPOSER_FAIL_ALL=1 scan + [ "$status" -eq 1 ] + grep -q '"reason":"install_failed"' "$STUB_LOG" + grep -q '"original": true' "$PROJECT/composer.json" + grep -q '"original": true' "$PROJECT/composer.lock" + ! grep -q 'analyze' "$STUB_LOG" +} + +@test 'enable fails: install_failed' { + STUB_ENABLE_RC=1 scan + [ "$status" -eq 1 ] + grep -q '"reason":"install_failed"' "$STUB_LOG" +} + +@test 'scan fails: tells Sentinel scan_failed and fails' { + STUB_ANALYZE_RC=1 scan + [ "$status" -eq 1 ] + grep -q '"reason":"scan_failed"' "$STUB_LOG" + ! grep -q 'sentinel_connector:upgrade-status' "$STUB_LOG" +} + +@test 'the connector exit code is the result' { + STUB_REPORT_RC=1 scan + [ "$status" -eq 1 ] +} + +@test 'a refused key is explained' { + STUB_ENABLE_RC=1 STUB_HTTP=401 scan + [ "$status" -eq 1 ] + [[ "$output" == *'refused the scan key'* ]] +} + +@test 'the key never reaches the output' { + STUB_ANALYZE_RC=1 STUB_HTTP=500 scan + [[ "$output" != *"$KEY"* ]] + STUB_BOOTSTRAP='' scan + [[ "$output" != *"$KEY"* ]] +} + +@test 'the connector API URL is used when no URL is given' { + unset SENTINEL_API_URL + SENTINEL_CONNECTOR_API_BASE_URL='https://uat.example/' scan + [ "$status" -eq 0 ] + grep -q 'api=https://uat.example$' "$STUB_LOG" +} + +@test 'production is the default API URL' { + unset SENTINEL_API_URL + STUB_BOOTSTRAP='' scan + grep -q "curl url https://ingest.sentinelconnector.io/api/v1/sites/$UUID/upgrade-readiness/scan" "$STUB_LOG" +} + +@test 'Drush that does not run fails without reporting' { + STUB_DRUSH_BROKEN=1 scan + [ "$status" -eq 1 ] + [[ "$output" == *'Drush does not run here'* ]] + ! grep -q '^curl' "$STUB_LOG" +} + +@test 'refuses an API URL with a query, fragment or credentials' { + for url in 'https://sentinel.example?foo=bar' 'https://sentinel.example#x' 'https://user:pass@sentinel.example'; do + SENTINEL_API_URL="$url" scan + [ "$status" -eq 1 ] + done + [ ! -s "$STUB_LOG" ] +} + +@test 'refuses a remote http:// API URL' { + SENTINEL_API_URL='http://sentinel.example' scan + [ "$status" -eq 1 ] + [ ! -s "$STUB_LOG" ] +} + +@test 'allows http:// for localhost' { + SENTINEL_API_URL='http://localhost:8000' STUB_BOOTSTRAP='' scan + [ "$status" -eq 0 ] + grep -q 'curl url http://localhost:8000/api/v1/' "$STUB_LOG" +} + +@test 'problem reports follow the connector URL when none is given' { + unset SENTINEL_API_URL + STUB_CONNECTOR_URL='https://uat-ingest.example' STUB_ANALYZE_RC=1 scan + [ "$status" -eq 1 ] + grep -q 'curl url https://uat-ingest.example/api/v1/' "$STUB_LOG" +} + +@test 'a given URL that settings.php overrides stops before anything is sent' { + STUB_CONNECTOR_URL='https://uat-ingest.example' scan + [ "$status" -eq 1 ] + [[ "$output" == *'not to https://sentinel.example'* ]] + ! grep -q '^curl' "$STUB_LOG" + ! grep -q 'analyze' "$STUB_LOG" +} + +@test 'uninstalls the modules this run enabled' { + scan + [ "$status" -eq 0 ] + grep -q 'drush pm:uninstall upgrade_status --yes' "$STUB_LOG" +} + +@test 'leaves modules that were already enabled' { + STUB_US_ENABLED=1 scan + [ "$status" -eq 0 ] + ! grep -q 'pm:uninstall' "$STUB_LOG" +} + +@test 'uninstalls the added modules after a failed scan too' { + STUB_ANALYZE_RC=1 scan + [ "$status" -eq 1 ] + grep -q 'drush pm:uninstall upgrade_status --yes' "$STUB_LOG" +} + +@test 'finds the Composer root from Drupal when run elsewhere' { + mkdir -p "$PROJECT/web" "$BATS_TEST_TMPDIR/elsewhere" + SENTINEL_PROJECT_DIR="$BATS_TEST_TMPDIR/elsewhere" STUB_ROOT="$PROJECT/web" STUB_US_MISSING=1 scan + [ "$status" -eq 0 ] + grep -q '"original": true' "$PROJECT/composer.json" + [ ! -f "$BATS_TEST_TMPDIR/elsewhere/composer.json" ] +} + +@test 'Drush that cannot find the site fails without reporting' { + STUB_ROOT='' scan + [ "$status" -eq 1 ] + [[ "$output" == *'cannot find the Drupal site'* ]] + ! grep -q '^curl' "$STUB_LOG" +} + +@test 'an invalid connector URL stops before anything is sent' { + unset SENTINEL_API_URL + STUB_CONNECTOR_URL='INVALID' scan + [ "$status" -eq 1 ] + [[ "$output" == *'API URL is not valid'* ]] + ! grep -q '^curl' "$STUB_LOG" + ! grep -q 'analyze' "$STUB_LOG" +} + +@test 'an outdated connector is reported to the connector URL' { + unset SENTINEL_API_URL + STUB_CONNECTOR_URL='https://uat-ingest.example' STUB_CONNECTOR='1.5.0' scan + [ "$status" -eq 1 ] + grep -q 'curl url https://uat-ingest.example/api/v1/' "$STUB_LOG" +} + +@test 'without a given URL the connector keeps its own setting' { + unset SENTINEL_API_URL + STUB_CONNECTOR_URL='https://uat-ingest.example' scan + [ "$status" -eq 0 ] + grep -q 'report env key=set uuid=.* api=unset$' "$STUB_LOG" +} + +@test 'a locked Upgrade Status is installed from the lock file' { + STUB_US_MISSING=1 STUB_LOCKED=1 scan + [ "$status" -eq 0 ] + grep -q '^composer install --no-interaction --no-progress$' "$STUB_LOG" + ! grep -q '^composer require' "$STUB_LOG" +} + +@test 'a database that connects but a site that does not start fails' { + STUB_BOOTSTRAP='' STUB_DB='Connected' scan + [ "$status" -eq 1 ] + grep -q '"reason":"scan_failed"' "$STUB_LOG" +} + +@test 'refuses an install that changes a locked package' { + echo '{"packages":[{"name":"drupal/core","version":"11.2.5"}]}' > "$PROJECT/composer.lock" + echo '{"packages":[{"name":"drupal/core","version":"11.2.6"}],"packages-dev":[{"name":"drupal/upgrade_status","version":"4.3.10"}]}' > "$BATS_TEST_TMPDIR/after.lock" + STUB_US_MISSING=1 STUB_LOCK_AFTER="$BATS_TEST_TMPDIR/after.lock" scan + [ "$status" -eq 1 ] + grep -q '"reason":"install_failed"' "$STUB_LOG" + [[ "$output" == *'would change drupal/core'* ]] + grep -q '11.2.5' "$PROJECT/composer.lock" + ! grep -q '^composer install' "$STUB_LOG" +} + +@test 'accepts an install that only adds packages' { + echo '{"packages":[{"name":"drupal/core","version":"11.2.5"}]}' > "$PROJECT/composer.lock" + echo '{"packages":[{"name":"drupal/core","version":"11.2.5"}],"packages-dev":[{"name":"drupal/upgrade_status","version":"4.3.10"}]}' > "$BATS_TEST_TMPDIR/after.lock" + STUB_US_MISSING=1 STUB_LOCK_AFTER="$BATS_TEST_TMPDIR/after.lock" scan + [ "$status" -eq 0 ] +} + +@test 'a given URL in upper case matches the connector URL' { + SENTINEL_API_URL='https://Sentinel.Example/' STUB_CONNECTOR_URL='https://sentinel.example' scan + [ "$status" -eq 0 ] +} + +@test 'a failed connector URL lookup stops before anything is sent' { + STUB_RESOLVE_FAIL=1 scan + [ "$status" -eq 1 ] + ! grep -q '^curl' "$STUB_LOG" +} + +@test 'refuses an install that removes a locked package' { + echo '{"packages":[{"name":"drupal/core","version":"11.2.5"},{"name":"drupal/token","version":"1.15.0"}]}' > "$PROJECT/composer.lock" + echo '{"packages":[{"name":"drupal/core","version":"11.2.5"}],"packages-dev":[{"name":"drupal/upgrade_status","version":"4.3.10"}]}' > "$BATS_TEST_TMPDIR/after.lock" + STUB_US_MISSING=1 STUB_LOCK_AFTER="$BATS_TEST_TMPDIR/after.lock" scan + [ "$status" -eq 1 ] + [[ "$output" == *'would change drupal/token'* ]] +} + +@test 'installs development packages even when the image turns them off' { + COMPOSER_NO_DEV=1 STUB_US_MISSING=1 scan + [ "$status" -eq 0 ] + ! grep -q 'composer no-dev=1' "$STUB_LOG" + grep -q 'composer no-dev=0' "$STUB_LOG" +} + +@test 'removes a lock file made only for the scan' { + rm "$PROJECT/composer.lock" + STUB_US_MISSING=1 scan + [ "$status" -eq 0 ] + [ ! -f "$PROJECT/composer.lock" ] +} + +@test 'reinstalls the packages from the restored lock file after adding Upgrade Status' { + mkdir -p "$PROJECT/vendor/composer" + echo '{"packages":[],"dev":false}' > "$PROJECT/vendor/composer/installed.json" + echo '{"packages":[{"name":"drupal/core","version":"11.2.5"}]}' > "$PROJECT/composer.lock" + echo '{"packages":[{"name":"drupal/core","version":"11.2.5"}],"packages-dev":[{"name":"drupal/upgrade_status","version":"4.3.10"}]}' > "$BATS_TEST_TMPDIR/after.lock" + STUB_LOCK_AFTER="$BATS_TEST_TMPDIR/after.lock" STUB_US_MISSING=1 scan + [ "$status" -eq 0 ] + [ "$(grep -c '^composer install' "$STUB_LOG")" -eq 2 ] + [[ "$output" == *'Reinstalled the packages from the restored lock file.'* ]] + ! grep -q 'upgrade_status' "$PROJECT/composer.lock" + [ "$(grep '^composer no-dev=' "$STUB_LOG" | tail -n 1)" = 'composer no-dev=1' ] +} + +@test 'does not reinstall when the dev package state is unknown' { + echo '{"packages":[{"name":"drupal/core","version":"11.2.5"}]}' > "$PROJECT/composer.lock" + echo '{"packages":[{"name":"drupal/core","version":"11.2.5"}],"packages-dev":[{"name":"drupal/upgrade_status","version":"4.3.10"}]}' > "$BATS_TEST_TMPDIR/after.lock" + STUB_LOCK_AFTER="$BATS_TEST_TMPDIR/after.lock" STUB_US_MISSING=1 scan + [ "$status" -eq 0 ] + [ "$(grep -c '^composer install' "$STUB_LOG")" -eq 1 ] + [[ "$output" == *'Do not reuse this folder'* ]] +} + +@test 'an upper-case site UUID is sent in lower case' { + SENTINEL_SITE_UUID="$(printf '%s' "$UUID" | tr '[:lower:]' '[:upper:]')" STUB_BOOTSTRAP='' scan + grep -q "/api/v1/sites/$UUID/upgrade-readiness/scan" "$STUB_LOG" +} + +@test 'warns when the packages cannot be reinstalled' { + echo '{"packages":[{"name":"drupal/core","version":"11.2.5"}]}' > "$PROJECT/composer.lock" + echo '{"packages":[{"name":"drupal/core","version":"11.2.5"}],"packages-dev":[{"name":"drupal/upgrade_status","version":"4.3.10"}]}' > "$BATS_TEST_TMPDIR/after.lock" + STUB_LOCK_AFTER="$BATS_TEST_TMPDIR/after.lock" STUB_US_MISSING=1 STUB_REINSTALL_FAIL=1 scan + [ "$status" -eq 0 ] + [[ "$output" == *'Do not reuse this folder'* ]] +} + +@test 'a failed module list stops before enabling anything' { + STUB_LIST_FAIL=1 scan + [ "$status" -eq 1 ] + grep -q '"reason":"install_failed"' "$STUB_LOG" + ! grep -q 'pm:enable' "$STUB_LOG" + ! grep -q 'pm:uninstall' "$STUB_LOG" +} + +@test 'a failed second module list turns off only Upgrade Status' { + STUB_LIST_FAIL_AFTER=1 scan + [ "$status" -eq 0 ] + grep -q '^drush pm:uninstall upgrade_status --yes$' "$STUB_LOG" + ! grep -q 'pm:uninstall.*node' "$STUB_LOG" +} + +@test 'upgrade-scan/gitlab.yml pins the SHA-256 of scan.sh' { + if command -v sha256sum > /dev/null; then + sum="$(sha256sum "$ROOT/upgrade-scan/scan.sh" | cut -d' ' -f1)" + else + sum="$(shasum -a 256 "$ROOT/upgrade-scan/scan.sh" | cut -d' ' -f1)" + fi + grep -q "SENTINEL_SCRIPT_SHA256: $sum" "$ROOT/upgrade-scan/gitlab.yml" +} diff --git a/tests/upgrade-scan/stubs/composer b/tests/upgrade-scan/stubs/composer new file mode 100755 index 0000000..9c940de --- /dev/null +++ b/tests/upgrade-scan/stubs/composer @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +echo "composer $*" >> "$STUB_LOG" +echo "composer no-dev=${COMPOSER_NO_DEV-unset}" >> "$STUB_LOG" +if [[ "$1" == config ]]; then echo vendor; exit 0; fi +if [[ "$1" == show ]]; then [[ -n "${STUB_LOCKED:-}" ]]; exit; fi +# A require changes both files; an install only writes a missing lock file. +if [[ "$1" == require ]]; then + echo '{"changed": true}' > composer.json + if [[ -n "${STUB_LOCK_AFTER:-}" ]]; then cp "$STUB_LOCK_AFTER" composer.lock; else echo '{"changed": true}' > composer.lock; fi +elif [[ "$1" == install && ! -f composer.lock ]]; then + echo '{"changed": true}' > composer.lock +fi +if [[ "$1" == install && -n "${STUB_REINSTALL_FAIL:-}" && -f "$STUB_LOG.installed" ]]; then exit 1; fi +[[ "$1" == install ]] && touch "$STUB_LOG.installed" +if [[ -n "${STUB_COMPOSER_FAIL_ALL:-}" ]]; then exit 1; fi +if [[ -n "${STUB_COMPOSER_FAIL_FIRST:-}" && "$1" == require && "$*" != *core-dev* ]]; then exit 1; fi +exit 0 diff --git a/tests/upgrade-scan/stubs/curl b/tests/upgrade-scan/stubs/curl new file mode 100755 index 0000000..b1ee8d3 --- /dev/null +++ b/tests/upgrade-scan/stubs/curl @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Stand-in for curl: records the request, prints STUB_HTTP as the status. +args=("$@"); url="${args[-1]}" +for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[i]}" in + -H) h="${args[i+1]}"; if [[ "$h" == @* ]]; then echo "curl header-file $(cat "${h#@}")" >> "$STUB_LOG"; fi ;; + --data-binary) echo "curl body $(cat "${args[i+1]#@}")" >> "$STUB_LOG" ;; + esac +done +echo "curl url $url" >> "$STUB_LOG" +printf '%s' "${STUB_HTTP:-200}" diff --git a/tests/upgrade-scan/stubs/drush b/tests/upgrade-scan/stubs/drush new file mode 100755 index 0000000..a3e7caf --- /dev/null +++ b/tests/upgrade-scan/stubs/drush @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Stand-in for Drush. Behaviour comes from STUB_* variables. +echo "drush $*" >> "$STUB_LOG" +state="$STUB_LOG.enabled" +case "$1" in + version) exit "${STUB_DRUSH_BROKEN:-0}" ;; + status) + case "$*" in + *drupal-version*) echo "${STUB_CORE:-11.2.5}" ;; + *root*) echo "${STUB_ROOT-$PWD/web}" ;; + *db-status*) echo "${STUB_DB-}" ;; + *) echo "${STUB_BOOTSTRAP-Successful}" ;; + esac ;; + php:eval) + if [[ "$2" == *CONNECTOR_VERSION* ]]; then echo "${STUB_CONNECTOR-1.6.0}"; + elif [[ "$2" == *ApiBaseUrlResolver* ]]; then [[ -n "${STUB_RESOLVE_FAIL:-}" ]] && exit 1; echo "${STUB_CONNECTOR_URL-${SENTINEL_CONNECTOR_API_BASE_URL-}}"; + elif [[ -n "${STUB_US_MISSING:-}" ]]; then echo no; else echo yes; fi ;; + pm:list) + [[ -n "${STUB_LIST_FAIL:-}" ]] && exit 1 + if [[ -n "${STUB_LIST_FAIL_AFTER:-}" && -f "$state" ]]; then exit 1; fi + printf 'node\nsentinel_connector\n' + if [[ -n "${STUB_US_ENABLED:-}" || -f "$state" ]]; then printf 'upgrade_status\n'; fi ;; + pm:enable) + [[ "${STUB_ENABLE_RC:-0}" -eq 0 ]] && touch "$state" + exit "${STUB_ENABLE_RC:-0}" ;; + upgrade_status:analyze) echo 'analysis output'; exit "${STUB_ANALYZE_RC:-0}" ;; + sentinel_connector:upgrade-status) + if [[ "${SENTINEL_SCAN_KEY:-}" == sk_scan_* ]]; then echo "report env key=set uuid=$SENTINEL_CONNECTOR_SITE_UUID api=${SENTINEL_CONNECTOR_API_BASE_URL-unset}" >> "$STUB_LOG"; fi + exit "${STUB_REPORT_RC:-0}" ;; +esac diff --git a/upgrade-scan/README.md b/upgrade-scan/README.md new file mode 100644 index 0000000..bd6071b --- /dev/null +++ b/upgrade-scan/README.md @@ -0,0 +1,123 @@ +# Upgrade scan + +Runs the Drupal [Upgrade Status](https://www.drupal.org/project/upgrade_status) +scan in your own CI pipeline and sends the result to +[Sentinel](https://sentinelconnector.io). Upgrade Status never needs to be on +production. + +- Runs inside your own site image, beside a copy of your database. +- Adds Upgrade Status for that run only, never changes your locked package + versions, and puts everything back at the end. Nothing is committed. +- Sends counts only: never project names, file paths, messages or database + content. +- Does nothing unless `SENTINEL_UPGRADE_SCAN` is set. +- With no database it stops without failing your pipeline, and Sentinel shows + why. + +Files: [`action.yml`](action.yml) (GitHub Actions), [`gitlab.yml`](gitlab.yml) +(GitLab CI template), [`scan.sh`](scan.sh) (the script both run), +[`examples/`](examples) and an [AI prompt](prompts/add-upgrade-scan.md) that adds +the job to your repository. + +## What you need + +- The [Sentinel Connector](https://www.drupal.org/project/sentinel_connector) + module, version **1.6.0 or later**, enabled on the site. +- An image of your site with PHP, Composer, Drush, `bash` and `curl`. +- A copy of your database the job can reach. Use a sanitised copy that is + thrown away after the job. +- The site's UUID and a scan key, from the site page in Sentinel. The key is + shown once. Store it as a secret. + +The scan key can only send scan results for its own site. It cannot read +anything, and it cannot change the site's modules in Sentinel. + +## GitHub Actions + +See [`examples/github-workflow.yml`](examples/github-workflow.yml): + +```yaml +- uses: truecms/sentinel-actions/upgrade-scan@v1.0.0 + with: + site-uuid: 00000000-0000-0000-0000-000000000000 + scan-key: ${{ secrets.SENTINEL_SCAN_KEY }} +``` + +| Input | Required | Meaning | +|---|---|---| +| `site-uuid` | yes | The site's UUID in Sentinel. | +| `scan-key` | yes | The scan key, from a secret. | +| `api-url` | no | Sentinel API base URL, `https://` (plain `http://` only for localhost). Empty: the connector's setting, else production. | +| `working-directory` | no | Where to start. Default: `.` If Drush is not found there, the `drush` on the image's PATH is used, and the Composer root is found from Drupal. | +| `drush` | no | The Drush command. Default: `vendor/bin/drush`, else `drush`. | + +If your image keeps the site somewhere else (for example `/app`), set +`working-directory: /app`. The checkout step is only needed when your image +does not already hold the site. + +GitHub turns off scheduled workflows in public repositories after 60 days with +no activity. Sentinel marks the result out of date when that happens. + +## GitLab CI + +See [`examples/gitlab-ci.yml`](examples/gitlab-ci.yml): + +```yaml +include: + - remote: https://raw.githubusercontent.com/truecms/sentinel-actions/v1.0.0/upgrade-scan/gitlab.yml +``` + +Then override the `sentinel-upgrade-scan` job with your image, database +service and variables. The template downloads `scan.sh` from the same release +and checks its SHA-256 before it runs. + +## Settings + +The script reads these environment variables. The GitHub action sets them from +its inputs. + +| Variable | Meaning | +|---|---| +| `SENTINEL_UPGRADE_SCAN` | Must be set, and not `0` or `false`, or nothing runs. | +| `SENTINEL_SITE_UUID` | The site's UUID in Sentinel. | +| `SENTINEL_SCAN_KEY` | The scan key. Never printed. | +| `SENTINEL_API_URL` | Optional API base URL, with no path. | +| `SENTINEL_PROJECT_DIR` | Optional start folder. | +| `SENTINEL_DRUSH` | Optional Drush command. | + +Without `SENTINEL_API_URL`, results and problems go to the API URL the +connector is set to use. If that setting is not valid, the job fails and sends +nothing. With `SENTINEL_API_URL`, the connector must send to the same URL, or +the job fails. With no database the connector cannot be asked, so a missing +database is reported to `SENTINEL_API_URL`, else production: set it if your +site uses another Sentinel. If your `settings.php` sets the connector's Site +UUID, keep it the same as `SENTINEL_SITE_UUID`. + +## What it does + +1. `SENTINEL_UPGRADE_SCAN` is not set: it does nothing and exits 0. +2. Drush does not run, or cannot find the site: it fails, because the job is + set up wrong. Drupal has no working database: it tells Sentinel + `no_database` and exits 0 (or fails if Sentinel could not be told). +3. The connector is missing, disabled or older than 1.6.0: it tells Sentinel + `connector_outdated` and fails. +4. Upgrade Status is missing: it adds it with Composer for this run, then + enables it. It never changes the versions your lock file holds: if Upgrade + Status cannot be added without that, it tells Sentinel `install_failed` and + fails. +5. It runs `drush upgrade_status:analyze --all --ignore-contrib`: only your + custom code is scanned, because contributed projects are judged from their + releases. Exit code 3 means the scan + found problems, which is a good scan. Any other failure tells Sentinel + `scan_failed` and fails. +6. It runs `drush sentinel_connector:upgrade-status --scan-report`, which sends + the counts. A custom project with no good scan from this run tells Sentinel + `scan_failed`. +7. It uninstalls the modules it turned on, puts `composer.json` and + `composer.lock` back as they were, and reinstalls the packages from the + restored lock file if it had added any. If it cannot list the enabled + modules before starting, it turns nothing on and fails. + +If the scan route is closed on Sentinel, it logs one line and exits 0. + +Network use: Composer sources, Drupal.org release data and the Sentinel API. diff --git a/upgrade-scan/action.yml b/upgrade-scan/action.yml new file mode 100644 index 0000000..40df8b4 --- /dev/null +++ b/upgrade-scan/action.yml @@ -0,0 +1,39 @@ +name: Sentinel upgrade scan +description: Run the Drupal Upgrade Status scan in your own site image and send the counts to Sentinel. +author: TrueCMS +branding: + icon: shield + color: blue + +inputs: + site-uuid: + description: The site's UUID in Sentinel, from the site page. + required: true + scan-key: + description: The site's scan key. Pass it from a secret, never as plain text. + required: true + api-url: + description: Sentinel API base URL. Leave empty to use the connector's setting or production. + required: false + default: '' + working-directory: + description: The Composer project root of the site. + required: false + default: '.' + drush: + description: The Drush command. Leave empty for vendor/bin/drush. + required: false + default: '' + +runs: + using: composite + steps: + - name: Scan and report + shell: bash + env: + SENTINEL_SITE_UUID: ${{ inputs.site-uuid }} + SENTINEL_SCAN_KEY: ${{ inputs.scan-key }} + SENTINEL_API_URL: ${{ inputs.api-url }} + SENTINEL_PROJECT_DIR: ${{ inputs.working-directory }} + SENTINEL_DRUSH: ${{ inputs.drush }} + run: bash "$GITHUB_ACTION_PATH/scan.sh" diff --git a/upgrade-scan/examples/github-workflow.yml b/upgrade-scan/examples/github-workflow.yml new file mode 100644 index 0000000..b3657b5 --- /dev/null +++ b/upgrade-scan/examples/github-workflow.yml @@ -0,0 +1,28 @@ +# .github/workflows/sentinel-upgrade-scan.yml +# Runs the Sentinel upgrade scan every Monday and when you start it by hand. +name: Sentinel upgrade scan +on: + workflow_dispatch: + schedule: + - cron: '0 3 * * 1' +permissions: + contents: read +jobs: + scan: + runs-on: ubuntu-latest + # Your own site image, and a sanitised copy of your database. + container: ghcr.io/your-org/your-site:latest + services: + database: + image: ghcr.io/your-org/your-site-db:latest + env: + SENTINEL_UPGRADE_SCAN: '1' + steps: + # Only needed when the image does not already hold the site. + - uses: actions/checkout@v5 + - uses: truecms/sentinel-actions/upgrade-scan@v1.0.0 + with: + site-uuid: 00000000-0000-0000-0000-000000000000 + scan-key: ${{ secrets.SENTINEL_SCAN_KEY }} + # If the image keeps the site elsewhere, for example /app: + # working-directory: /app diff --git a/upgrade-scan/examples/gitlab-ci.yml b/upgrade-scan/examples/gitlab-ci.yml new file mode 100644 index 0000000..fb16dad --- /dev/null +++ b/upgrade-scan/examples/gitlab-ci.yml @@ -0,0 +1,20 @@ +# Add to .gitlab-ci.yml. Runs from a pipeline schedule (Build > Pipeline +# schedules, for example every Monday: 0 3 * * 1) or a pipeline you start by hand. +include: + - remote: https://raw.githubusercontent.com/truecms/sentinel-actions/v1.0.0/upgrade-scan/gitlab.yml + +sentinel-upgrade-scan: + # Your own site image, and a sanitised copy of your database. + image: registry.example.com/your-site:latest + services: + - name: registry.example.com/your-site-db:latest + alias: database + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" + - if: $CI_PIPELINE_SOURCE == "web" + variables: + SENTINEL_UPGRADE_SCAN: '1' + SENTINEL_SITE_UUID: 00000000-0000-0000-0000-000000000000 + # If the image keeps the site elsewhere, for example /app: + # SENTINEL_PROJECT_DIR: /app + # Set SENTINEL_SCAN_KEY as a masked, protected CI/CD variable. diff --git a/upgrade-scan/gitlab.yml b/upgrade-scan/gitlab.yml new file mode 100644 index 0000000..69daf18 --- /dev/null +++ b/upgrade-scan/gitlab.yml @@ -0,0 +1,21 @@ +# Sentinel upgrade scan for GitLab CI (truecms/sentinel-actions). +# +# Include this file at a release tag and override the job with your own site +# image, database service and SENTINEL_* variables. See the README. +# +# The job downloads scan.sh from the same release and checks its SHA-256 +# before running it. scripts/pin-release.sh writes both values below. + +sentinel-upgrade-scan: + variables: + SENTINEL_ACTIONS_REF: v1.0.0 + SENTINEL_SCRIPT_SHA256: ba5ee7db4cd76d0ef2f1ec34f42417a71b0b12b9fbb3585ea6ed0423ed9bdda7 + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" + - if: $CI_PIPELINE_SOURCE == "web" + script: + - | + curl -fsSL --proto '=https' -o /tmp/sentinel-upgrade-scan.sh \ + "https://raw.githubusercontent.com/truecms/sentinel-actions/${SENTINEL_ACTIONS_REF}/upgrade-scan/scan.sh" + echo "${SENTINEL_SCRIPT_SHA256} /tmp/sentinel-upgrade-scan.sh" | sha256sum -c - + bash /tmp/sentinel-upgrade-scan.sh diff --git a/upgrade-scan/prompts/add-upgrade-scan.md b/upgrade-scan/prompts/add-upgrade-scan.md new file mode 100644 index 0000000..1df03d0 --- /dev/null +++ b/upgrade-scan/prompts/add-upgrade-scan.md @@ -0,0 +1,49 @@ +# AI prompt: add the Sentinel upgrade scan to this repository + +Copy everything below the line into your AI coding assistant (Claude Code, +Codex, Copilot or similar), opened in your Drupal site's repository. Replace +the Site UUID first. Never paste your scan key into the chat. + +--- + +Add the Sentinel upgrade scan to this Drupal repository's CI. It runs the +Drupal Upgrade Status scan in our own CI and sends only counts to Sentinel. +The instructions and examples are in +https://github.com/truecms/sentinel-actions/tree/v1.0.0/upgrade-scan +(read the README there first). + +Site UUID: REPLACE_WITH_SITE_UUID + +Do this: + +1. Work out where this repository runs CI: GitHub Actions (`.github/workflows/`) + or GitLab CI (`.gitlab-ci.yml`). If both or neither, ask me. +2. Find how CI already builds and runs this site: the image it uses, where the + site lives inside it (for example `/app`), and how tests get a database. Reuse + that. The scan needs our own site image with PHP, Composer, Drush, bash and + curl, and a database copy that is sanitised and thrown away after the job. + If CI has no database for the site, tell me. Do not invent one or point at + any real database. +3. Check `composer.lock` has `drupal/sentinel_connector` at 1.6.0 or later. If + it is older, tell me; do not upgrade it as part of this change. +4. Add one new job, copied from the matching example in that folder: + - GitHub: a new workflow file, using + `truecms/sentinel-actions/upgrade-scan@v1.0.0`, `site-uuid` set to the UUID + above and `scan-key: ${{ secrets.SENTINEL_SCAN_KEY }}`. + - GitLab: include `upgrade-scan/gitlab.yml` at `v1.0.0` and a + `sentinel-upgrade-scan` job with our image, the database service, + `SENTINEL_UPGRADE_SCAN: '1'` and `SENTINEL_SITE_UUID`. + - Run it on a weekly schedule and by hand only, never on every push. + - Set the working directory if the site is not at the job's start folder. + - Set `SENTINEL_UPGRADE_SCAN` only in this job, never globally. +5. Never write the scan key into any file, commit, log or message. It goes + only into a CI secret named `SENTINEL_SCAN_KEY` (GitHub: repository secret; + GitLab: masked and protected CI/CD variable), which I will add myself. +6. Do not change other jobs, `composer.json`, `composer.lock` or Drupal + configuration. +7. Open the change as a branch and pull or merge request. In its description, + list what I must do: add the `SENTINEL_SCAN_KEY` secret (create the key on + the site page in Sentinel), add a GitLab pipeline schedule if on GitLab, and + run the job once by hand to check it. + +When you finish, tell me what you added and anything you could not settle. diff --git a/upgrade-scan/scan.sh b/upgrade-scan/scan.sh new file mode 100755 index 0000000..6148a02 --- /dev/null +++ b/upgrade-scan/scan.sh @@ -0,0 +1,338 @@ +#!/usr/bin/env bash +# Sentinel upgrade scan. +# +# Runs the Drupal Upgrade Status scan inside the site's own image, beside a +# copy of its database, and sends the result to Sentinel. Only counts and a +# reason from a fixed list leave the runner. The scan key is never printed. +# +# Settings (environment): +# SENTINEL_UPGRADE_SCAN Must be set (and not 0 or false), or nothing runs. +# SENTINEL_SITE_UUID The site's UUID in Sentinel. +# SENTINEL_SCAN_KEY The site's scan key (sk_scan_...). Keep it secret. +# SENTINEL_API_URL Optional. Sentinel API base URL. Default: the +# connector's own setting, else +# https://ingest.sentinelconnector.io. +# SENTINEL_PROJECT_DIR Optional. The Composer project root. Default: the +# current directory. +# SENTINEL_DRUSH Optional. The Drush command. Default: +# vendor/bin/drush, else drush on the PATH. +# +# Exit codes: 0 done, or skipped on purpose (switch off, no database, scan +# route closed). 1 a real fault, already reported to Sentinel where possible. + +set -uo pipefail + +readonly MIN_CONNECTOR_VERSION='1.6.0' +readonly DEFAULT_API_URL='https://ingest.sentinelconnector.io' + +log() { printf 'Sentinel upgrade scan: %s\n' "$*"; } +fail() { log "$*" >&2; exit 1; } + +case "${SENTINEL_UPGRADE_SCAN:-}" in + ''|0|false|FALSE|no|off) + log 'SENTINEL_UPGRADE_SCAN is not set. Nothing to do.' + exit 0 + ;; +esac + +uuid="${SENTINEL_SITE_UUID:-}" +key="${SENTINEL_SCAN_KEY:-}" +uuid_re='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' +[[ "$uuid" =~ $uuid_re ]] || fail 'SENTINEL_SITE_UUID is missing or is not a UUID. Copy it from the site page in Sentinel.' +# Sentinel's routes use the lower-case form. +uuid="$(printf '%s' "$uuid" | tr '[:upper:]' '[:lower:]')" +[[ "$key" =~ ^sk_scan_[0-9a-f]{64}$ ]] || fail 'SENTINEL_SCAN_KEY is missing or is not a scan key. Create one on the site page in Sentinel and store it as a secret.' + +# Plain http:// only for localhost, as in the connector: the key must not +# cross a network in the clear. +valid_url() { + # Scheme, host and port only: no path, query, fragment or credentials. + [[ "$1" =~ ^https://[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?$ || "$1" =~ ^http://(localhost|[a-z0-9.-]+\.localhost)(:[0-9]{1,5})?$ ]] +} +api_given="${SENTINEL_API_URL:-${SENTINEL_CONNECTOR_API_BASE_URL:-}}" +# Lower case, as the connector stores it, so the two can be compared. +api_given="$(printf '%s' "$api_given" | tr '[:upper:]' '[:lower:]')" +api="${api_given:-$DEFAULT_API_URL}" +api="${api%/}" +api_given="${api_given%/}" +valid_url "$api" || fail 'SENTINEL_API_URL must be a base URL with no path, such as https://ingest.sentinelconnector.io. Plain http:// is allowed only for localhost.' + +cd "${SENTINEL_PROJECT_DIR:-.}" || fail 'SENTINEL_PROJECT_DIR does not exist.' + +if [[ -n "${SENTINEL_DRUSH:-}" ]]; then + read -r -a drush <<< "$SENTINEL_DRUSH" +elif [[ -x vendor/bin/drush ]]; then + drush=(vendor/bin/drush) +else + drush=(drush) +fi +# An absolute path, so Drush still runs after moving to the Composer root. +if [[ "${drush[0]}" == */* && -e "${drush[0]}" ]]; then + drush[0]="$(cd "$(dirname "${drush[0]}")" && pwd)/$(basename "${drush[0]}")" +fi +"${drush[@]}" version > /dev/null 2>&1 || fail "Drush does not run here (${drush[0]}). Set working-directory (SENTINEL_PROJECT_DIR) to your site's project folder in the image, or set drush (SENTINEL_DRUSH)." + +started="$(date +%s)" +started_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +work="$(mktemp -d)" +chmod 700 "$work" + +# The key goes to curl in a header file, never on the command line. +printf 'X-Scan-Key: %s\n' "$key" > "$work/header" +chmod 600 "$work/header" + +restore() { + if [[ -f "$work/modules_before" && ! -f "$work/added_modules" ]]; then + if enabled_modules > "$work/modules_after" && [[ -s "$work/modules_after" ]]; then + comm -13 "$work/modules_before" "$work/modules_after" > "$work/added_modules" + elif ! grep -qx upgrade_status "$work/modules_before"; then + echo upgrade_status > "$work/added_modules" + fi + fi + if [[ -s "$work/added_modules" ]]; then + # shellcheck disable=SC2046 # One module name per line. + if "${drush[@]}" pm:uninstall $(cat "$work/added_modules") --yes > /dev/null 2>&1; then + log 'Uninstalled the modules added for the scan.' + else + log 'Could not uninstall the modules added for the scan. Do not reuse this database copy.' + fi + fi + if [[ -f "$work/composer.json" ]]; then + cp "$work/composer.json" composer.json + if [[ -f "$work/composer.lock" ]]; then + cp "$work/composer.lock" composer.lock + else + # The site had no lock file: drop the one made for the scan. + rm -f composer.lock + fi + log 'Restored composer.json and composer.lock. Nothing is committed.' + if [[ -f "$work/installed" ]]; then + # Put the installed packages back to what the restored lock file holds, + # so later steps in this job see the site as it was. + # Composer records whether dev packages were installed; put that back. + if [[ -f composer.lock && -s "$work/no_dev" ]] && COMPOSER_NO_DEV="$(cat "$work/no_dev")" composer install --no-interaction --no-progress > /dev/null 2>&1; then + log 'Reinstalled the packages from the restored lock file.' + else + log 'The installed packages still include Upgrade Status. Do not reuse this folder in later steps of this job.' + fi + fi + fi + rm -rf "$work" +} +trap restore EXIT + +# Tells Sentinel why there is no result. Prints what Sentinel answered. +# Returns 0 when Sentinel stored it or the scan route is closed. +report_problem() { + local reason="$1" status + if ! command -v curl > /dev/null 2>&1; then + log "curl is not in the image, so Sentinel was not told: $reason." + return 1 + fi + printf '{"problem":{"reason":"%s","generated_at":"%s"}}' "$reason" "$started_iso" > "$work/body" + status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 --proto '=https,http' \ + -X POST -H 'Content-Type: application/json' -H "@$work/header" \ + --data-binary "@$work/body" \ + "$api/api/v1/sites/$uuid/upgrade-readiness/scan" 2> /dev/null)" || status='000' + case "$status" in + 2??) log "Sentinel shows this on the site: $reason."; return 0 ;; + 404) log 'The scan route is closed on this Sentinel. Nothing was sent.'; return 0 ;; + 401) log 'Sentinel refused the scan key. Create a new key on the site page.' ;; + 409) log 'Sentinel has no report from this site yet. Connect the site first.' ;; + 429) log 'Sentinel is limiting scan reports for this site. Try again later.' ;; + 000) log "Could not reach $api." ;; + *) log "Sentinel answered $status." ;; + esac + return 1 +} + +trim() { sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; } + +# Drush must find the site, or the job is set up wrong. This needs no +# database. +root="$("${drush[@]}" status --field=root 2> /dev/null | trim)" || root='' +[[ -n "$root" && -d "$root" ]] || fail "Drush cannot find the Drupal site from here. Set working-directory (SENTINEL_PROJECT_DIR) to your site's project folder in the image." + +# Composer work happens in the site's own project root, found from Drupal's +# root, never in a checkout that only looks like one. +dir="$root" +while [[ "$dir" != / && ! -f "$dir/composer.json" ]]; do + dir="$(dirname "$dir")" +done +if [[ -f "$dir/composer.json" ]]; then + cd "$dir" || fail "Cannot open $dir." +fi + +# 1. A working Drupal with its database. +bootstrap="$("${drush[@]}" status --field=bootstrap 2> /dev/null | trim)" || bootstrap='' +if [[ "$bootstrap" != 'Successful' ]]; then + db="$("${drush[@]}" status --field=db-status 2> /dev/null | trim)" || db='' + if [[ "$db" == 'Connected' ]]; then + log 'Drupal reaches its database but does not start. Check the site image.' + report_problem scan_failed || true + exit 1 + fi + log 'Drupal has no working database here, so there is nothing to scan.' + # Passes only when Sentinel heard about it (or the route is closed). + report_problem no_database || exit 1 + exit 0 +fi + +# Results and problems must go to the same Sentinel. Ask the connector where +# it sends: with a given URL it must agree (settings.php wins over the +# environment in the connector); without one, problems follow it. A connector +# URL that is not valid stops here, so nothing goes to production by mistake. +url_env=() +if [[ -n "$api_given" ]]; then + url_env=(SENTINEL_CONNECTOR_API_BASE_URL="$api") +fi +# shellcheck disable=SC2016 # PHP code, expanded by PHP. +resolved="$(env ${url_env[@]+"${url_env[@]}"} "${drush[@]}" php:eval ' + $class = "Drupal\\sentinel_connector\\ApiBaseUrlResolver"; + if (\Drupal::moduleHandler()->moduleExists("sentinel_connector")) { + if (class_exists($class)) { + $url = \Drupal::service($class)->getUrl(); + echo $url ?? "INVALID"; + } + else { + // Connector 1.3 and older kept the URL in configuration. + echo strtolower(rtrim((string) \Drupal::config("sentinel_connector.settings")->get("api_base_url"), "/")); + } + }' 2> /dev/null | trim)" || fail 'Could not ask the connector which Sentinel it sends to. Nothing was sent.' +resolved="${resolved%/}" +if [[ "$resolved" == INVALID ]] || { [[ -n "$resolved" ]] && ! valid_url "$resolved"; }; then + fail "The connector's Sentinel API URL is not valid. Fix it in settings.php or set api-url (SENTINEL_API_URL). Nothing was sent." +fi +if [[ -n "$resolved" ]]; then + if [[ -n "$api_given" && "$resolved" != "$api" ]]; then + fail "The connector sends to $resolved (set in settings.php), not to $api. Remove api-url (SENTINEL_API_URL) or change the setting. Nothing was sent." + fi + api="$resolved" +fi + +# 2. The connector, new enough to send a scan report. +connector="$("${drush[@]}" php:eval ' + if (\Drupal::moduleHandler()->moduleExists("sentinel_connector") && class_exists("Drupal\\sentinel_connector\\PayloadBuilder")) { + echo \Drupal\sentinel_connector\PayloadBuilder::CONNECTOR_VERSION; + }' 2> /dev/null | trim)" || connector='' +# shellcheck disable=SC2016 # PHP code, expanded by PHP. +if [[ -z "$connector" ]] || ! php -r 'exit(version_compare($argv[1], $argv[2], ">=") ? 0 : 1);' "$connector" "$MIN_CONNECTOR_VERSION"; then + log "The Sentinel Connector module must be enabled at version $MIN_CONNECTOR_VERSION or later (found: ${connector:-none})." + report_problem connector_outdated || true + exit 1 +fi + +# Every package the site's lock file held is still at the same version. +locked_versions_kept() { + [[ -f "$work/composer.lock" ]] || return 0 + # shellcheck disable=SC2016 # PHP code, expanded by PHP. + php -r ' + $versions = function (string $file): array { + $lock = json_decode((string) file_get_contents($file), TRUE) ?: []; + $out = []; + foreach (array_merge($lock["packages"] ?? [], $lock["packages-dev"] ?? []) as $package) { + $out[$package["name"]] = ($package["version"] ?? "") . "@" . ($package["source"]["reference"] ?? $package["dist"]["reference"] ?? ""); + } + return $out; + }; + $before = $versions($argv[1]); + $after = $versions($argv[2]); + foreach ($before as $name => $version) { + if (($after[$name] ?? NULL) !== $version) { + fwrite(STDERR, "Adding Upgrade Status would change $name.\n"); + exit(1); + } + }' "$work/composer.lock" composer.lock +} + +# 3. Upgrade Status, added for this run only, never changing locked versions. +present="$("${drush[@]}" php:eval 'echo \Drupal::service("extension.list.module")->exists("upgrade_status") ? "yes" : "no";' 2> /dev/null | trim)" || present='' +if [[ "$present" != 'yes' ]]; then + log 'Adding Upgrade Status for this run.' + cp composer.json "$work/composer.json" || fail 'There is no composer.json here. Set SENTINEL_PROJECT_DIR.' + if [[ -f composer.lock ]]; then + cp composer.lock "$work/composer.lock" + fi + if ! command -v composer > /dev/null 2>&1; then + log 'Composer is not in the image.' + report_problem install_failed || true + exit 1 + fi + # Whether the image was built with dev packages, to restore it the same way. + vendor="$(composer config vendor-dir 2> /dev/null | trim)" || vendor='' + if [[ -n "$vendor" && -f "$vendor/composer/installed.json" ]] && command -v php > /dev/null 2>&1; then + # shellcheck disable=SC2016 # PHP code, expanded by PHP. + php -r '$d = json_decode(file_get_contents($argv[1]), TRUE); if (is_array($d) && isset($d["dev"])) { echo $d["dev"] ? "0" : "1"; }' "$vendor/composer/installed.json" > "$work/no_dev" 2> /dev/null || : > "$work/no_dev" + fi + if composer show --locked drupal/upgrade_status > /dev/null 2>&1; then + # Already in the lock file, as a dev package left out of the image: + # install exactly what the lock file holds. + touch "$work/installed" + COMPOSER_NO_DEV=0 composer install --no-interaction --no-progress > "$work/composer.log" 2>&1 + else + # Change the lock file only, check it, and only then install from it, so + # a refused change never touches the installed packages. + COMPOSER_NO_DEV=0 composer require --dev --no-install --no-interaction --no-progress drupal/upgrade_status > "$work/composer.log" 2>&1 || { + core="$("${drush[@]}" status --field=drupal-version 2> /dev/null | trim)" + [[ -n "$core" ]] && COMPOSER_NO_DEV=0 composer require --dev --no-install --no-interaction --no-progress drupal/upgrade_status "drupal/core-dev:$core" > "$work/composer.log" 2>&1 + } && locked_versions_kept && touch "$work/installed" && COMPOSER_NO_DEV=0 composer install --no-interaction --no-progress >> "$work/composer.log" 2>&1 + fi + # shellcheck disable=SC2181 # The status of whichever branch ran. + if [[ $? -ne 0 ]]; then + tail -n 20 "$work/composer.log" >&2 + log "Composer could not add Upgrade Status without changing the site's locked versions." + report_problem install_failed || true + exit 1 + fi +fi +enabled_modules() { + "${drush[@]}" pm:list --type=module --status=enabled --field=name 2> /dev/null | tr -d ' ' | sort +} +# Without a full list of the modules on before, nothing is turned on, so the +# clean-up can never turn off a module the site already had. +if ! enabled_modules > "$work/modules_before.tmp" || [[ ! -s "$work/modules_before.tmp" ]]; then + log 'Could not list the enabled modules.' + report_problem install_failed || true + exit 1 +fi +mv "$work/modules_before.tmp" "$work/modules_before" +if ! "${drush[@]}" pm:enable upgrade_status --yes > "$work/enable.log" 2>&1; then + tail -n 20 "$work/enable.log" >&2 + log 'Upgrade Status could not be enabled.' + report_problem install_failed || true + exit 1 +fi + +# The modules this run turned on, to turn off again at the end. If the list +# fails, only Upgrade Status itself is turned off. +if enabled_modules > "$work/modules_after" && [[ -s "$work/modules_after" ]]; then + comm -13 "$work/modules_before" "$work/modules_after" > "$work/added_modules" +else + grep -qx upgrade_status "$work/modules_before" || echo upgrade_status > "$work/added_modules" + touch "$work/added_modules" +fi + +# 4. The scan. Exit code 3 means it found problems, which is a good scan. +# Contributed projects are judged from their releases, so only custom code is +# scanned, as the connector's own scan does. +log 'Scanning. This can take several minutes.' +"${drush[@]}" upgrade_status:analyze --all --ignore-contrib > "$work/analyze.log" 2>&1 +code=$? +if [[ $code -ne 0 && $code -ne 3 ]]; then + tail -n 20 "$work/analyze.log" >&2 + log "The scan failed (exit code $code)." + report_problem scan_failed || true + exit 1 +fi + +# 5. Send the stored result. The connector reports scan_failed itself for an +# incomplete scan, and exits 0 when the scan route is closed. +# Without a given URL the connector uses its own setting. The key is exported +# in a subshell, never put on a command line where process lists show it. +( + export SENTINEL_SCAN_KEY="$key" SENTINEL_CONNECTOR_SITE_UUID="$uuid" + if [[ -n "$api_given" ]]; then + export SENTINEL_CONNECTOR_API_BASE_URL="$api" + fi + "${drush[@]}" sentinel_connector:upgrade-status --scan-report --scan-started="$started" +)