diff --git a/.github/behat/pebble-setup.sh b/.github/behat/pebble-setup.sh new file mode 100755 index 00000000..33664693 --- /dev/null +++ b/.github/behat/pebble-setup.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash +# Redirects EasyEngine's hardcoded Let's Encrypt endpoint to a local Pebble on a throwaway CI runner, so the @le Behat +# scenarios run without public DNS or real certificates. Test-only: no product code changes. +# +# pebble-setup.sh setup start Pebble + DNS, redirect and trust, block real LE, then run `verify` +# pebble-setup.sh block lanes without Pebble: point the LE host at a blackhole and block real LE +# pebble-setup.sh verify prove EE's HTTP stack reaches Pebble (and cannot reach real LE) +# pebble-setup.sh reach after the EE proxy is up: prove Pebble's VA can fetch http:/// (HTTP-01 path) +# pebble-setup.sh logs print the Pebble, proxy and DNS logs +# pebble-setup.sh teardown undo everything setup or block did +# +# Needs root, docker, openssl, curl, php, iptables and systemd-resolved (GitHub's ubuntu runners have all of them). +# Pebble's root is trusted system-wide while it runs, so never leave it set up on a machine used for anything else. +set -euo pipefail + +PEBBLE_DIR=${PEBBLE_DIR:-/tmp/ee-pebble} +# 2.10+ also offers the token-less draft dns-persist-01 challenge, which EE's acmephp 1.3 client can't parse. +PEBBLE_VERSION=${PEBBLE_VERSION:-2.9.0} +PEBBLE_NET=${PEBBLE_NET:-ee-pebble} +PEBBLE_BRIDGE=${PEBBLE_BRIDGE:-ee-pebble0} +PEBBLE_SUBNET=${PEBBLE_SUBNET:-172.30.0.0/24} +PEBBLE_GATEWAY=${PEBBLE_GATEWAY:-172.30.0.1} +# EE's host resolves to the front proxy: Pebble serves its directory at /dir, EE requests /directory. +PEBBLE_IP=${PEBBLE_IP:-172.30.0.10} +PEBBLE_ACME_IP=${PEBBLE_ACME_IP:-172.30.0.12} +PEBBLE_PROXY_IMAGE=${PEBBLE_PROXY_IMAGE:-nginx:1.29-alpine} +PEBBLE_DNS_IP=${PEBBLE_DNS_IP:-172.30.0.11} +PEBBLE_DNS_IMAGE=${PEBBLE_DNS_IMAGE:-alpine:3.22} +# Every name under this domain resolves to PEBBLE_TARGET_IP, on the runner and for Pebble's VA. +PEBBLE_DOMAIN=${PEBBLE_DOMAIN:-ee4-test.rt.gw} +# The gateway is a host address, so both the runner and the VA reach nginx-proxy's published :80 through it. +PEBBLE_TARGET_IP=${PEBBLE_TARGET_IP:-$PEBBLE_GATEWAY} +ACME_HOST=acme-v02.api.letsencrypt.org +TOS_MARKER='Do%20what%20thou%20wilt' +# TEST-NET-1: never routed, and rejected locally so a request fails at once instead of timing out. +BLACKHOLE_IP=${BLACKHOLE_IP:-192.0.2.1} +PUBLIC_PROBE=api.wordpress.org +MARK='# ee-pebble' +RESOLVED_DROPIN=/etc/systemd/resolved.conf.d/ee-pebble.conf +CA_LINK=/usr/local/share/ca-certificates/ee-pebble-ca.crt +ROOT_LINK=/usr/local/share/ca-certificates/ee-pebble-root.crt +STATE="$PEBBLE_DIR/state" + +log() { printf '[pebble] %s\n' "$*" >&2; } +die() { log "ERROR: $*"; exit 1; } + +need_root() { [[ $EUID -eq 0 ]] || die 'run as root'; } + +# Records an undo command (one per line) for teardown. +remember() { printf '%s\n' "$*" >>"$STATE"; } + +public_ips() { + # $1 = ahostsv4|ahostsv6, $2 = name; resolved before any override is installed. + getent "$1" "$2" 2>/dev/null | awk '{print $1}' | sort -u || true +} + +block_ip() { + local ip=$1 tool=iptables + [[ $ip == *:* ]] && tool=ip6tables + command -v "$tool" >/dev/null || return 0 + "$tool" -I OUTPUT -d "$ip" -j REJECT + remember "$tool -D OUTPUT -d $ip -j REJECT" + if "$tool" -n -L DOCKER-USER >/dev/null 2>&1; then + "$tool" -I DOCKER-USER -d "$ip" -j REJECT + remember "$tool -D DOCKER-USER -d $ip -j REJECT" + fi + log "blocked egress to $ip" +} + +make_certs() { + local d=$PEBBLE_DIR + openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj '/CN=EE Behat Pebble listener CA' \ + -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=critical,keyCertSign,cRLSign' \ + -keyout "$d/ca.key" -out "$d/ca.pem" 2>/dev/null + openssl req -newkey rsa:2048 -nodes -subj "/CN=$ACME_HOST" -keyout "$d/key.pem" -out "$d/cert.csr" 2>/dev/null + printf 'subjectAltName=DNS:%s,IP:%s\nextendedKeyUsage=serverAuth\nbasicConstraints=critical,CA:FALSE\n' \ + "$ACME_HOST" "$PEBBLE_IP,IP:$PEBBLE_ACME_IP" >"$d/cert.ext" + openssl x509 -req -in "$d/cert.csr" -CA "$d/ca.pem" -CAkey "$d/ca.key" -CAcreateserial -days 2 \ + -extfile "$d/cert.ext" -out "$d/cert.pem" 2>/dev/null + rm -f "$d/ca.key" "$d/cert.csr" "$d/ca.srl" + # Pebble runs as root in a scratch image; the key only has to be readable by it. + chmod 644 "$d/key.pem" +} + +write_config() { + # The blocklist mirrors LE refusing reserved TLDs. + cat >"$PEBBLE_DIR/pebble-config.json" <<-EOF + { + "pebble": { + "listenAddress": "0.0.0.0:14000", + "managementListenAddress": "0.0.0.0:15000", + "certificate": "/pebble/cert.pem", + "privateKey": "/pebble/key.pem", + "httpPort": 80, + "tlsPort": 443, + "ocspResponderURL": "", + "externalAccountBindingRequired": false, + "domainBlocklist": ["invalid", "test", "example", "localhost", "local"], + "retryAfter": { "authz": 3, "order": 5 }, + "keyAlgorithm": "ecdsa", + "profiles": { + "default": { "description": "LE classic: 90 days, first name as CN", "validityPeriod": 7776000, "promoteCommonName": true } + } + } + } + EOF +} + +write_front_config() { + # Pebble builds its URLs from Host and X-Forwarded-Proto, so they come back as https://$ACME_HOST/... + cat >"$PEBBLE_DIR/front.conf" <<-EOF + server { + listen 443 ssl; + ssl_certificate /pebble/cert.pem; + ssl_certificate_key /pebble/key.pem; + proxy_set_header Host \$host; + proxy_set_header X-Forwarded-Proto https; + proxy_redirect off; + proxy_ssl_server_name on; + proxy_ssl_name $ACME_HOST; + proxy_ssl_verify on; + proxy_ssl_trusted_certificate /pebble/ca.pem; + location = /directory { proxy_pass https://$PEBBLE_ACME_IP:14000/dir; } + # Boulder puts a blank line between chain PEMs and acmephp splits on it; Pebble puts none. + location /certZ/ { + proxy_pass https://$PEBBLE_ACME_IP:14000; + # A location-level proxy_set_header drops the inherited ones; Pebble checks the kid against Host. + proxy_set_header Host \$host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Accept-Encoding ""; + sub_filter_types application/pem-certificate-chain; + sub_filter_once off; + sub_filter "-----END CERTIFICATE----- + -----BEGIN CERTIFICATE-----" "-----END CERTIFICATE----- + + -----BEGIN CERTIFICATE-----"; + } + location / { proxy_pass https://$PEBBLE_ACME_IP:14000; } + } + EOF +} + +wait_for() { + # $1 = description, rest = command; up to 60 s. + local what=$1 i + shift + for ((i = 0; i < 60; i++)); do + "$@" >/dev/null 2>&1 && return 0 + sleep 1 + done + die "timed out waiting for $what" +} + +start_containers() { + docker network create --driver bridge --subnet "$PEBBLE_SUBNET" --gateway "$PEBBLE_GATEWAY" \ + -o "com.docker.network.bridge.name=$PEBBLE_BRIDGE" "$PEBBLE_NET" >/dev/null + remember "docker network rm $PEBBLE_NET" + # dnsmasq, not challtestsrv: it answers only the test domain and forwards everything else, because resolved's + # global DNS also ends up in the containers' resolv.conf (challtestsrv answers every name, even wordpress.org). + local upstream + upstream=$(awk '$1 == "nameserver" && $2 !~ /^127\./ {print $2; exit}' /run/systemd/resolve/resolv.conf 2>/dev/null) + [[ -n $upstream ]] || die 'no upstream DNS server found in /run/systemd/resolve/resolv.conf' + docker run -d --name ee-pebble-dns --network "$PEBBLE_NET" --ip "$PEBBLE_DNS_IP" "$PEBBLE_DNS_IMAGE" \ + sh -c "apk add -q --no-cache dnsmasq && exec dnsmasq -k --no-hosts --no-resolv --log-queries --log-facility=- \ + --server=$upstream --address=/$PEBBLE_DOMAIN/$PEBBLE_TARGET_IP" >/dev/null + remember 'docker rm -f ee-pebble-dns' + docker run -d --name ee-pebble --network "$PEBBLE_NET" --ip "$PEBBLE_ACME_IP" \ + -e PEBBLE_VA_NOSLEEP=1 -e PEBBLE_WFE_NONCEREJECT=0 -e PEBBLE_AUTHZREUSE=100 \ + -v "$PEBBLE_DIR:/pebble:ro" "ghcr.io/letsencrypt/pebble:$PEBBLE_VERSION" \ + -config /pebble/pebble-config.json -dnsserver "$PEBBLE_DNS_IP:53" >/dev/null + remember 'docker rm -f ee-pebble' + docker run -d --name ee-pebble-front --network "$PEBBLE_NET" --ip "$PEBBLE_IP" \ + -v "$PEBBLE_DIR:/pebble:ro" -v "$PEBBLE_DIR/front.conf:/etc/nginx/conf.d/default.conf:ro" \ + "$PEBBLE_PROXY_IMAGE" >/dev/null + remember 'docker rm -f ee-pebble-front' +} + +allow_va_forwarding() { + # Docker's inter-network isolation may drop the VA's DNATed request to nginx-proxy's bridge; DOCKER-USER runs first. + iptables -n -L DOCKER-USER >/dev/null 2>&1 || return 0 + iptables -I DOCKER-USER -o "$PEBBLE_BRIDGE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + remember "iptables -D DOCKER-USER -o $PEBBLE_BRIDGE -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" + iptables -I DOCKER-USER -i "$PEBBLE_BRIDGE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + remember "iptables -D DOCKER-USER -i $PEBBLE_BRIDGE -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" + iptables -I DOCKER-USER -i "$PEBBLE_BRIDGE" -p tcp --dport 80 -j ACCEPT + remember "iptables -D DOCKER-USER -i $PEBBLE_BRIDGE -p tcp --dport 80 -j ACCEPT" + # Site containers get the resolver from resolved's resolv.conf, which now lists the Pebble DNS. + local proto + for proto in udp tcp; do + iptables -I DOCKER-USER -o "$PEBBLE_BRIDGE" -d "$PEBBLE_DNS_IP" -p "$proto" --dport 53 -j ACCEPT + remember "iptables -D DOCKER-USER -o $PEBBLE_BRIDGE -d $PEBBLE_DNS_IP -p $proto --dport 53 -j ACCEPT" + done +} + +redirect_acme_host() { + cp /etc/hosts "$PEBBLE_DIR/hosts.bak" + printf '%s\t%s %s\n' "$PEBBLE_IP" "$ACME_HOST" "$MARK" >>/etc/hosts + remember "sed -i '/ $MARK\$/d' /etc/hosts" +} + +route_test_domain() { + systemctl is-active --quiet systemd-resolved || die 'systemd-resolved is not active; cannot route *.'"$PEBBLE_DOMAIN" + mkdir -p "$(dirname "$RESOLVED_DROPIN")" + printf '[Resolve]\nDNS=%s\nDomains=~%s\n' "$PEBBLE_DNS_IP" "$PEBBLE_DOMAIN" >"$RESOLVED_DROPIN" + remember "rm -f $RESOLVED_DROPIN; systemctl restart systemd-resolved" + systemctl restart systemd-resolved + resolvectl flush-caches 2>/dev/null || true +} + +php_cafiles() { + # CA files PHP is configured to use instead of the system bundle, if any. + php <<-'PHP' | sort -u + /dev/null" + update-ca-certificates >/dev/null + # Guzzle (verify=true) and PHP streams fall back to these when set, so they must trust the CAs too. + while IFS= read -r f; do + [[ -f $f ]] || continue + [[ $(readlink -f "$f") == "$(readlink -f /etc/ssl/certs/ca-certificates.crt)" ]] && continue + cp "$f" "$f.ee-pebble.bak" + remember "mv -f '$f.ee-pebble.bak' '$f'" + for pem in "$PEBBLE_DIR/ca.pem" "$PEBBLE_DIR/pebble-root.pem"; do cat "$pem" >>"$f"; done + log "appended the CAs to PHP's $f" + done < <(php_cafiles) +} + +write_env() { + local issuer + issuer=$(openssl x509 -in "$PEBBLE_DIR/pebble-intermediate.pem" -noout -subject -nameopt multiline | + sed -n 's/^ *commonName *= *//p') + [[ -n $issuer ]] || die 'could not read the Pebble intermediate CN' + printf 'EE_BEHAT_LE_ISSUER=%s\nEE_BEHAT_LE_DNS_SERVER=127.0.0.53\nEE_BEHAT_PEBBLE_ROOT=%s\n' \ + "$issuer" "$PEBBLE_DIR/pebble-root.pem" >"$PEBBLE_DIR/env" + if [[ -n ${GITHUB_ENV:-} ]]; then + cat "$PEBBLE_DIR/env" >>"$GITHUB_ENV" + fi + log "issuer for the scenarios: $issuer" +} + +cmd_setup() { + need_root + [[ ! -e $STATE ]] || die "$STATE exists; run teardown first" + mkdir -p "$PEBBLE_DIR" + : >"$STATE" + + local ip + # Resolve before the overrides so the real addresses get blocked. + for ip in $(public_ips ahostsv4 "$ACME_HOST") $(public_ips ahostsv6 "$ACME_HOST") \ + $(public_ips ahostsv4 "$PEBBLE_DOMAIN") $(public_ips ahostsv6 "$PEBBLE_DOMAIN"); do + block_ip "$ip" + done + + make_certs + write_config + write_front_config + start_containers + allow_va_forwarding + redirect_acme_host + + wait_for 'the Pebble directory' curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$ACME_HOST/directory" + curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$PEBBLE_ACME_IP:15000/roots/0" -o "$PEBBLE_DIR/pebble-root.pem" + curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$PEBBLE_ACME_IP:15000/intermediates/0" -o "$PEBBLE_DIR/pebble-intermediate.pem" + + trust_cas + wait_for 'the Pebble DNS' docker exec ee-pebble-dns pidof dnsmasq + route_test_domain + write_env + cmd_verify + log 'setup done' +} + +cmd_verify() { + local probe resolved ip body + resolved=$(getent ahostsv4 "$ACME_HOST" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_IP" ]] || die "$ACME_HOST resolves to '$resolved', not Pebble ($PEBBLE_IP)" + + body=$(curl -fsS "https://$ACME_HOST/directory") || die 'curl with the system trust store cannot reach Pebble' + [[ $body == *"$TOS_MARKER"* ]] || die "the directory at $ACME_HOST is not Pebble's: $body" + [[ $body == *"https://$ACME_HOST/"* ]] || die "Pebble's directory does not use $ACME_HOST in its URLs: $body" + + # The same two paths EE's ACME client can take: Guzzle over ext-curl (verify=true, no CA option) and PHP streams. + # Pebble rejects requests without a User-Agent (Guzzle always sends one). + ACME_HOST=$ACME_HOST TOS_MARKER=$TOS_MARKER php <<-'PHP' >&2 || die 'PHP does not reach Pebble with default TLS verification' + true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_USERAGENT => 'ee-pebble-verify']); + $a = curl_exec($c); + if (false === $a || false === strpos($a, $m)) { + fwrite(STDERR, 'php curl: ' . json_encode(['error' => curl_error($c), 'url' => $u, 'marker' => $m, 'code' => curl_getinfo($c, CURLINFO_RESPONSE_CODE), 'ip' => curl_getinfo($c, CURLINFO_PRIMARY_IP), 'body' => substr((string) $a, 0, 300)]) . "\n"); + exit(1); + } + $b = @file_get_contents($u, false, stream_context_create(['http' => ['user_agent' => 'ee-pebble-verify']])); + if (false === $b || false === strpos($b, $m)) { fwrite(STDERR, 'php stream: ' . json_encode([error_get_last(), substr((string) $b, 0, 300)]) . "\n"); exit(1); } + echo 'php curl + stream reach Pebble; ', json_encode(['curl.cainfo' => ini_get('curl.cainfo'), 'openssl' => openssl_get_cert_locations()['default_cert_file'], 'ssl' => curl_version()['ssl_version']]), "\n"; + PHP + + probe="probe-$RANDOM.$PEBBLE_DOMAIN" + resolved=$(getent ahostsv4 "$probe" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_TARGET_IP" ]] || die "$probe resolves to '$resolved', not $PEBBLE_TARGET_IP" + resolved=$(getent ahostsv4 "www.$probe" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_TARGET_IP" ]] || die "www.$probe resolves to '$resolved', not $PEBBLE_TARGET_IP" + + # Everything else must still resolve normally, on the host and in containers on other networks (like EE's sites). + resolved=$(getent ahostsv4 "$PUBLIC_PROBE" | awk 'NR==1{print $1}') + [[ -n $resolved && $resolved != "$PEBBLE_TARGET_IP" ]] || die "$PUBLIC_PROBE resolves to '$resolved' on the host" + log "resolved upstreams for containers: $(grep '^nameserver' /run/systemd/resolve/resolv.conf | tr '\n' ' ')" + docker network create ee-pebble-probe >/dev/null + body=$(docker run --rm --network ee-pebble-probe "$PEBBLE_DNS_IMAGE" sh -c \ + "nslookup $probe | tail -n +3; wget -q -O /dev/null https://$PUBLIC_PROBE/ && echo public-https-ok" 2>&1) || true + docker network rm ee-pebble-probe >/dev/null + log "container view of $probe: $(tr '\n' ' ' <<<"$body")" + [[ $body == *public-https-ok* ]] || die "containers can no longer reach https://$PUBLIC_PROBE/: $body" + + while read -r _ ip; do + [[ $ip == *:* ]] && ip="[$ip]" + if curl -sS --connect-timeout 5 --resolve "$ACME_HOST:443:$ip" -o /dev/null "https://$ACME_HOST/directory" 2>/dev/null; then + die "real Let's Encrypt at $ip is still reachable" + fi + done < <(grep -E '^(ip6?tables) -D OUTPUT' "$STATE" | awk '{print $1, $5}') + log "verified: $ACME_HOST -> Pebble $PEBBLE_IP (system trust), *.$PEBBLE_DOMAIN -> $PEBBLE_TARGET_IP, real LE blocked" +} + +cmd_block() { + need_root + [[ ! -e $STATE ]] || die "$STATE exists; run teardown first" + mkdir -p "$PEBBLE_DIR" + : >"$STATE" + + local ip resolved + for ip in $(public_ips ahostsv4 "$ACME_HOST") $(public_ips ahostsv6 "$ACME_HOST") "$BLACKHOLE_IP"; do + block_ip "$ip" + done + printf '%s\t%s %s\n' "$BLACKHOLE_IP" "$ACME_HOST" "$MARK" >>/etc/hosts + remember "sed -i '/ $MARK\$/d' /etc/hosts" + + resolved=$(getent ahosts "$ACME_HOST" | awk 'NR==1{print $1}') + [[ $resolved == "$BLACKHOLE_IP" ]] || die "$ACME_HOST resolves to '$resolved', not the blackhole $BLACKHOLE_IP" + if curl -sS --connect-timeout 5 -o /dev/null "https://$ACME_HOST/directory" 2>/dev/null; then + die "$ACME_HOST is still reachable" + fi + while read -r _ ip; do + [[ $ip == "$BLACKHOLE_IP" ]] && continue + [[ $ip == *:* ]] && ip="[$ip]" + if curl -sS --connect-timeout 5 --resolve "$ACME_HOST:443:$ip" -o /dev/null "https://$ACME_HOST/directory" 2>/dev/null; then + die "real Let's Encrypt at $ip is still reachable" + fi + done < <(grep -E '^(ip6?tables) -D OUTPUT' "$STATE" | awk '{print $1, $5}') + log "verified: $ACME_HOST -> $BLACKHOLE_IP (rejected), real LE blocked" +} + +cmd_reach() { + local code + code=$(docker run --rm --network "$PEBBLE_NET" "curlimages/curl:8.11.1" -sS -o /dev/null -w '%{http_code}' \ + --connect-timeout 10 "http://$PEBBLE_TARGET_IP/.well-known/acme-challenge/ee-pebble-probe") || + die "a container on $PEBBLE_NET cannot reach http://$PEBBLE_TARGET_IP/ (HTTP-01 would fail)" + log "container on $PEBBLE_NET -> http://$PEBBLE_TARGET_IP/ answered HTTP $code" +} + +cmd_logs() { + local c + for c in ee-pebble ee-pebble-front ee-pebble-dns; do + echo "===== $c" + docker logs "$c" 2>&1 || true + done +} + +cmd_teardown() { + need_root + [[ -f $STATE ]] || { log 'nothing to tear down'; return 0; } + local line + # Undo in reverse order; keep going on errors so a partial setup is still cleaned up. + while IFS= read -r line; do + bash -c "$line" >/dev/null 2>&1 || log "undo failed (ignored): $line" + done < <(tac "$STATE") + resolvectl flush-caches 2>/dev/null || true + rm -rf "$PEBBLE_DIR" + log 'teardown done' +} + +case ${1:-} in + setup) cmd_setup ;; + block) cmd_block ;; + verify) cmd_verify ;; + reach) cmd_reach ;; + logs) cmd_logs ;; + teardown) cmd_teardown ;; + *) + sed -n '2,13p' "$0" >&2 + exit 2 + ;; +esac diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml new file mode 100644 index 00000000..c89b0cd0 --- /dev/null +++ b/.github/workflows/behat.yml @@ -0,0 +1,220 @@ +# Behat suite of an EasyEngine package, run inside a fresh core checkout on a throwaway runner. +# The main lane's job is named "Behat Tests - PHP " to match the required checks; update those with the matrix. +name: Behat + +on: + pull_request: + push: + branches: + - develop + - master + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: behat-${{ github.ref }} + cancel-in-progress: true + +env: + CORE_REF: develop + COMPOSE_VERSION: v5.5.1 + # Not RCLONE_*: rclone reads those variables as its own flags. + RCLONE_RELEASE: v1.75.1 + # No public DNS (Let's Encrypt) or real backup storage on hosted runners. + EXCLUDE_TAGS: '~@le&&~@remote-real' + # le* lanes run @le against a local Pebble instead (.github/behat/pebble-setup.sh); @le-real needs the real service. + LE_TAGS: '@le&&~@le-real&&~@remote-real' + # Local rclone alias written to EE's rclone-path, for suites with @remote scenarios ('eetest:ee-backups'); empty = no rclone. + EE_RCLONE_PATH: 'eetest:ee-backups' + COMPOSE_INTERACTIVE_NO_CLI: 1 + RCLONE_CONFIG: /etc/rclone/rclone.conf + EE_BEHAT_DESTRUCTIVE: 1 + +jobs: + behat: + name: ${{ matrix.lane == 'main' && format('Behat Tests - PHP {0}', matrix.php) || format('Behat {0} (PHP {1})', matrix.lane, matrix.php) }} + runs-on: ubuntu-latest + timeout-minutes: 150 + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + lane: [main, wp, le] + include: + - lane: main + tags: '~@known-bug&&~@wp' + - lane: wp + tags: '@wp&&~@known-bug' + # @le against Pebble (.github/behat/pebble-setup.sh). + - lane: le + tags: '~@known-bug' + steps: + - name: Check out the package + uses: actions/checkout@v7 + with: + path: pkg + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer + extensions: pcntl, curl, sqlite3, zip, dom, mbstring, json + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config -g cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Set up Composer caching + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-php${{ matrix.php }}-${{ github.sha }} + restore-keys: ${{ runner.os }}-composer-php${{ matrix.php }}- + + - name: Prepare the host + run: | + # The runner image ships web and database servers; EE needs 80/443. + sudo systemctl stop nginx apache2 mysql 2>/dev/null || true + sudo ss -ltnp | grep -E ':(80|443|3306) ' || true + sudo curl -fsSL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker version --format 'docker {{.Server.Version}}' + docker-compose version + openssl version + echo "umask $(umask), under sudo $(sudo sh -c umask)" + # Suites that request the host's public IP use the first of these; EE picks a frontend subnet outside eth0's. + echo "hostname -I: $(hostname -I)" + ip -4 route + if [ -n "$EE_RCLONE_PATH" ]; then + curl -fsSL "https://github.com/rclone/rclone/releases/download/${RCLONE_RELEASE}/rclone-${RCLONE_RELEASE}-linux-amd64.zip" -o "$RUNNER_TEMP/rclone.zip" + unzip -q -j -o "$RUNNER_TEMP/rclone.zip" '*/rclone' -d "$RUNNER_TEMP" + sudo install -m 0755 "$RUNNER_TEMP/rclone" /usr/local/bin/rclone + sudo mkdir -p "$(dirname "$RCLONE_CONFIG")" /var/tmp/ee-rclone + printf '[%s]\ntype = alias\nremote = /var/tmp/ee-rclone\n' "${EE_RCLONE_PATH%%:*}" | sudo tee "$RCLONE_CONFIG" >/dev/null + sudo -E rclone listremotes + fi + + # Every lane without Pebble points EE's hardcoded Let's Encrypt host at a blackhole, so no scenario can reach the real service. + - name: Block the real Let's Encrypt + if: ${{ !startsWith(matrix.lane, 'le') }} + run: sudo -E pkg/.github/behat/pebble-setup.sh block + + - name: Set up EasyEngine core + run: | + git clone -q --depth 1 -b "$CORE_REF" https://github.com/EasyEngine/easyengine.git easyengine + cd easyengine + # All easyengine/* packages on dev-develop (as core CI does), except the one under test: that's this checkout. + php -r ' + $pkg = json_decode(file_get_contents("../pkg/composer.json"), true)["name"]; + $j = json_decode(file_get_contents("composer.json"), true); + foreach ($j["require"] as $k => $v) if (strpos($k, "easyengine/") === 0) $j["require"][$k] = "dev-develop"; + $repos = isset($j["repositories"]) ? $j["repositories"] : []; + array_unshift($repos, ["type" => "path", "url" => "../pkg", "options" => ["symlink" => false, "versions" => [$pkg => "dev-develop"]]]); + $j["repositories"] = $repos; + file_put_contents("composer.json", json_encode($j, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + file_put_contents(getenv("GITHUB_ENV"), "PKG=$pkg\n", FILE_APPEND);' + composer update --prefer-dist --no-progress --no-interaction + composer show "$(jq -r .name ../pkg/composer.json)" | grep -E '^(versions|source|dist) ' || true + rm -rf features + cp -R ../pkg/features features + # Some commands (clone, sync) shell out to `ee` from PATH. + sudo ln -sf "$PWD/bin/ee" /usr/local/bin/ee + + - name: Initialise EasyEngine + working-directory: easyengine + run: | + sudo -E bin/ee cli info + # The first non-cli command runs the initial setup (DB, config, image pulls). + sudo -E bin/ee site list || true + # Non-LE commands such as ssl-renew still prompt for le-mail when it's missing. + sudo -E bin/ee config set le-mail behat-ci@example.org + if [ -n "$EE_RCLONE_PATH" ]; then + sudo -E bin/ee config set rclone-path "$EE_RCLONE_PATH" + sudo -E bin/ee config set no-of-backups 3 + fi + # EasyEngine's first run replaces the installed docker-compose with 2.27.0, so that's what the suite runs on, as on a fresh install. + # That download doesn't use curl --fail, so a GitHub error page can replace the binary; fetch 2.27.0 again then. + if ! docker-compose version; then + sudo curl -fsSL --retry 5 "https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose version + fi + + # The suites run bin/ee; this keeps the release build (no dev autoloading) covered. + - name: Build the phar + if: matrix.lane == 'main' + working-directory: easyengine + run: | + composer dump-autoload --no-dev -q + php -dphar.readonly=0 utils/make-phar.php easyengine.phar --quiet + composer dump-autoload -q + sudo php easyengine.phar cli version + + - name: Start Pebble and redirect Let's Encrypt to it + if: startsWith(matrix.lane, 'le') + run: | + sudo -E pkg/.github/behat/pebble-setup.sh setup + # Diagnostic only: nginx-proxy may not be up before the first site exists. + sudo docker ps --format '{{.Names}} {{.Ports}}' + sudo pkg/.github/behat/pebble-setup.sh reach || true + + - name: Run Behat + working-directory: easyengine + shell: 'script -q -e -c "bash {0}"' + # A hung step (e.g. a command stopped on the TTY) should still leave logs. + timeout-minutes: 75 + run: | + set -e + mkdir -p "$RUNNER_TEMP/behat/junit" + base="$EXCLUDE_TAGS" + case "${{ matrix.lane }}" in le*) base="$LE_TAGS" ;; esac + behat=(sudo -E vendor/bin/behat --strict --tags="${base}&&${{ matrix.tags }}" + --format=progress --out=std + --format=junit --out="$RUNNER_TEMP/behat/junit" + --format=pretty --out="$RUNNER_TEMP/behat/pretty.log") + "${behat[@]}" + + - name: Collect logs + if: always() + run: | + out="$RUNNER_TEMP/behat" + mkdir -p "$out" + sudo cp /opt/easyengine/logs/ee.log "$out/" 2>/dev/null || echo 'No ee.log.' + sudo stat -c '%a %U:%G %n' /opt/easyengine /opt/easyengine/logs > "$out/dirs.txt" 2>/dev/null || true + sudo docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' > "$out/containers.txt" || true + if [[ "${{ matrix.lane }}" == le* ]]; then + sudo pkg/.github/behat/pebble-setup.sh logs > "$out/pebble.log" 2>&1 || true + tail -n 60 "$out/pebble.log" || true + fi + sudo chown -R "$USER" "$out" + # One block per failed scenario, from the junit files. + python3 - "$out" <<'PY' + import glob, re, sys, xml.etree.ElementTree as ET + out = sys.argv[1] + lines = [] + for f in sorted(glob.glob(out + '/junit/*.xml')): + # Behat copies raw output (docker logs with ANSI escapes) into the junit file; XML 1.0 forbids those bytes. + data = re.sub(rb'[\x00-\x08\x0b\x0c\x0e-\x1f]', b'', open(f, 'rb').read()) + for case in ET.fromstring(data).iter('testcase'): + for bad in list(case.findall('failure')) + list(case.findall('error')): + lines.append('### %s :: %s\n%s\n%s\n' % (case.get('classname'), case.get('name'), bad.get('message') or '', bad.text or '')) + open(out + '/failures.txt', 'w').write('\n'.join(lines) or 'No failures.\n') + print('%d failed scenarios' % len(lines)) + PY + head -c 20000 "$out/failures.txt" + + - name: Tear down Pebble and the Let's Encrypt block + if: always() + run: sudo pkg/.github/behat/pebble-setup.sh teardown + + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: behat-${{ matrix.lane }}-php${{ matrix.php }} + path: ${{ runner.temp }}/behat + retention-days: 14 diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml deleted file mode 100644 index f8fce960..00000000 --- a/.github/workflows/test_and_build.yml +++ /dev/null @@ -1,94 +0,0 @@ -on: - pull_request: - push: - branches: - - master - - develop - -name: Test 👨‍🔧 - -jobs: - - test: #---------------------------------------------------------------------- - runs-on: ubuntu-latest - name: Behat Tests - PHP ${{ matrix.php }} - strategy: - fail-fast: false - matrix: - php: ['7.4', '8.0'] - steps: - - name: Check out source code - uses: actions/checkout@v2 - - - name: Get Composer cache directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Set up Composer caching - uses: actions/cache@v4 - env: - cache-name: cache-composer-dependencies - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '${{ matrix.php }}' - coverage: none - tools: composer - extensions: pcntl, curl, sqlite3, zip, dom, mbstring, json - - - name: Update docker - run: | - sudo apt purge nginx nginx-common docker docker-engine docker.io docker-ce containerd runc - curl -fsSL https://get.docker.com/ | sudo bash - sudo systemctl restart docker.service - - - name: Install docker-compose - run: | - VERSION=$(curl --silent "https://api.github.com/repos/docker/compose/releases/latest" | - grep '"tag_name":' | - sed -E 's/.*"([^"]+)".*/\1/' - ) - sudo curl -L "https://github.com/docker/compose/releases/download/$VERSION/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose - sudo chmod +x /usr/local/bin/docker-compose - - - name: Install dependencies - run: | - cd "$GITHUB_WORKSPACE/.." - git clone https://github.com/EasyEngine/easyengine.git easyengine --depth=1 - cd easyengine - rm -rf features - cp -R $GITHUB_WORKSPACE/features . - # Run composer install for master else update. - if [[ "$GITHUB_REF" = "refs/heads/master" ]]; then - composer install --prefer-dist --no-progress --no-interaction - else - sed -i 's/\(easyengine\/.*\):\ \".*\"/\1:\ \"dev-develop\"/' composer.json - composer update --prefer-dist --no-progress --no-interaction - fi - composer dump-autoload --no-dev - php -dphar.readonly=0 ./utils/make-phar.php easyengine.phar --quite > /dev/null - composer dump-autoload - sudo php easyengine.phar cli info - - - name: Test - shell: 'script -q -e -c "bash {0}"' - run: | - set -e - cd "$GITHUB_WORKSPACE/../easyengine" - sudo -E ./vendor/bin/behat - env: - COMPOSE_INTERACTIVE_NO_CLI: 1 - - - name: Output logs - if: ${{ always() }} - run: | - # The logs directory is readable by root only. - sudo test -f /opt/easyengine/logs/install.log && sudo cat /opt/easyengine/logs/install.log || echo 'No install log.' - sudo test -f /opt/easyengine/logs/ee.log && sudo cat /opt/easyengine/logs/ee.log || echo 'No run log.' - diff --git a/features/COVERAGE.md b/features/COVERAGE.md new file mode 100644 index 00000000..2f4ced49 --- /dev/null +++ b/features/COVERAGE.md @@ -0,0 +1,233 @@ +# site-command Behat coverage + +Suite for `easyengine/site-command` (the `ee site` command and the html site type). 18 features, 179 scenarios (211 with outlines expanded): main lane 190, `@wp` 10, `@le` 11. Run it the CI way (`.github/workflows/behat.yml`): the files are copied into a core checkout's `features/` and core's `vendor/bin/behat` runs from the core root as root. + +## Tags + +| Tag | Scenarios | Meaning | +|---|---|---| +| (none of the tags below) | 171 | Local only: `*.test` sites, no public DNS, no remote storage, no global restarts. This is the default safe run. | +| `@known-bug` | 0 | Scenarios that expose open bugs are kept outside this suite and come back with their fixes. The CI lanes still exclude the tag. | +| `@destructive` | 12 | Restarts the GLOBAL nginx-proxy (proxy cache) or runs `ssl-renew --all` (refuses to start when an enabled LE site that isn't the suite's exists). Every scenario leaves the proxy running. | +| `@remote` | 10 | Uses the REAL rclone remote (`rclone-path` in config.yml). Touches only `/bh-site-command-*`: the rclone path is listed first and only listed suite directories are purged (per site before a scenario, all of them after the feature). | +| `@le` | 11 | Let's Encrypt issuance on `*.ee4-test.rt.gw`. Locally: real LE, at most 4 certificates per full run; names contain `{RUN_ID}`, so repeated runs never hit the duplicate-certificate limit. On CI all 11 run against Pebble (`.github/behat/pebble-setup.sh`: EE's hardcoded LE host is redirected to Pebble, `{LE_ISSUER}` comes from `EE_BEHAT_LE_ISSUER`). The failed-alias rollback and the ACME cleanup on delete are regression checks for EasyEngine/site-command#505. | +| `@le-real` | 0 | Needs the public LE service itself (rate limits, the real chain); never runs on CI. None so far: every `@le` scenario works on Pebble. | +| `@wp` | 10 | Needs the `wp` site type (site-type-wp in the core checkout) and `ee` on PATH; slow. | + +Default safe run: `--tags='~@le&&~@remote&&~@destructive&&~@wp'`. + +## Environment rules + +- Every site, alias, hosts entry and remote backup path contains `bh-site-command-`. `@AfterFeature` deletes only sites matching `(^|\.)bh-site-command-`, never other sites; once none is left it also removes suite-named leftovers of known bugs (proxy `certs/`, `acme-conf/certs|var/`, site dirs, volumes labelled with a suite name); `@remote` features also purge the listed `/bh-site-command-*` directories. `@AfterScenario` removes hosts entries the scenario added, the scenario's `{TMP}` dir under `/var/tmp`, and `{BACKUP_DIR}/bh-site-command-*` leftovers. +- The file-writing, hosts, delete and rclone steps refuse names and paths without the prefix (or outside `{TMP}`). +- HTTP steps resolve the site name to 127.0.0.1 (whitelisted, so no HTTP auth, and no /etc/hosts entry needed); `through the public IP` goes through `hostname -I` instead; `verified` uses real DNS and full TLS verification (LE only). Redirects are never followed. +- Waits poll (`I wait up to N seconds for URL to return CODE`, `... until the command ... succeeds`); there are no fixed sleeps, except `I wait for the next second` between back-to-back backups of one site (backup ids have one-second resolution). +- Scenarios are independent: each one uses its own site or the idempotent `Given the html site "..." exists` / `exists, created with "..."`. The exception is `le.feature`, which shares three sites in file order to stay within the LE budget (each scenario still creates what it needs when run alone). +- Commands that could hang (the proxy cache purge used to, without a terminal) run under `with a timeout of N seconds`. +- Commands that could prompt (le-mail) are guarded by `Given the EasyEngine config value "le-mail" is set`. +- The failing LE alias uses a `*.invalid` name, so no validation ever reaches a host outside `*.ee4-test.rt.gw`. + +## Estimated runtime (4 CPU test host) + +html create about 15-20 s, delete 5-10 s, proxy-cache toggle 10-15 s (proxy restart), html backup/restore 15-40 s, WordPress create 1.5-2.5 min, LE issuance 30-60 s. + +| Selection | Estimate | +|---|---| +| Default safe run | ~40 min | +| `@destructive` | ~7 min | +| `@remote` (html part) | ~7 min | +| `@wp` (clone/sync + WP backup/restore) | ~16 min | +| `@le` | ~6 min | +| Everything | ~75-80 min | + +## Inventory and mapping + +Legend: covered / partial / not covered (reason). Feature files are abbreviated: site, create, delete, list, info, en-dis (enable-disable), rr (restart-reload), alias (update-alias), ussl (update-ssl-php), pcache (proxy-cache), clean, share, ssl, redirect, labels, backup (backup-restore), clone (clone-sync), le. + +### Routing and global behaviour (`Site_Command::__invoke`, hooks.php) + +| Item | Status | Scenarios | +|---|---|---| +| `ee site` usage | covered | site: Check site command is present | +| `ee help site`, `ee help site create` | covered | site: Help lists every site subcommand; Help of site create | +| `ee help site --type=X` routing hook, unknown type error | covered | site: Help for an unregistered site type fails | +| `ee site cmd-dump` (hidden) | covered | site: cmd-dump prints the site command tree as JSON | +| `--type=X` on create, unregistered type error | covered | site: Creating a site of an unregistered type fails; create: Create an html site (`--type=html`) | +| Type auto-detection from the site name / from the cwd | covered | info: The site is detected from the working directory; site outlines (unknown site messages) | +| Wrong site name inside a site dir ("Did you mean") | covered | info: Another site name inside a site directory is rejected with a hint | +| Name normalisation: `http(s)://`, trailing slash, add `.test`, lower-case | covered | create: A site name without a dot...; The URL scheme and a trailing slash...; Upper-case site names...; info: The site name may be given as a URL | +| EE v3 compat flags (`--html`, `--wp`, `--le`, `--mysql`, ...) | partial | create: The EE v3 --html flag (`--wp`/`--php`/`--le` belong to site-type-* / LE budget) | +| Removed EE v3 flags error (`--w3tc`, ...) | not covered yet | the removed-flag message never shows (its scenario comes back with the fix) | +| Unknown parameter / too many positionals / missing required positional | covered | site: Unknown parameters are rejected; Extra positional arguments; Subcommands with a required site name print the usage | +| Missing site outside a site dir (auto_site_name) | covered | site: outline over info, update, enable, disable, restart, reload, clean, ssl-info, backup, restore; ssl-renew scenario | +| Unknown site (get_site_info) | covered | site: outlines over delete, refresh, ssl-verify, ssl, info, enable, disable, restart, update, share | +| Disabled site refused | covered | en-dis: outline over restart, reload, update, refresh, share, backup, restore | +| `site_cleanup` hooks (redis purge, share cleanup) | not covered | redis purge only fires for WP cache sites (site-type-wp suite); share cleanup needs a live ngrok share | +| Signal rollback (pcntl handlers) | not covered | the handlers are never dispatched; needs killing ee mid-create | + +### create (html) + +| Item | Status | Scenarios | +|---|---|---| +| `` happy path: files, .env, main.conf, user.conf, index.html, compose file, nginx container, 3 labelled volumes, network, hosts entry, DB row, HTTP 200 | covered | create: Create an html site; labels: both scenarios | +| Duplicate site | covered | create: Creating a site that already exists fails and keeps the site | +| Docker volume / compose project collision with another site | covered | create: A site whose docker volumes and compose project collide with an existing site is refused (EasyEngine/site-command#503) | +| `--public-dir` | covered | create: Create a site with a custom public directory | +| `--skip-status-check` | covered | create: Create a site without the status check | +| `--alias-domains` (DB, server_name, VIRTUAL_HOST, HTTP on alias) | covered | create: Create a site with alias domains | +| Alias/site conflicts on create | covered | create: A domain that is an alias of another site...; An alias domain that belongs to another site...; not covered yet: the owner lookup matches any alias containing the name (its scenario comes back with the fix) | +| `--ssl=self` | covered | ssl: Create a site with a self-signed certificate; Info of a self-signed site | +| `--ssl=custom` + `--ssl-key/--ssl-crt`, validation, rollback | covered | ssl: Create a site with a custom certificate; Custom SSL needs both...; Custom SSL files must exist | +| `--ssl=inherit` (parent checks, `--wildcard` refusal) | covered | ssl: A subdomain site inherits...; Inheriting needs an existing parent / a parent with SSL / cannot be combined with --wildcard | +| `--ssl=le`, `--ssl=le --wildcard` | covered (@le) | le: Create a site with a Let's Encrypt certificate; A wildcard certificate waits for the DNS challenge | +| `--ssl` with an invalid value on create | not covered | `--ssl` is a flag on create, so no option validation; would only fail inside init_ssl after the containers are up | +| www redirect written only when www resolves; https redirect | covered | redirect: all scenarios | +| Rollback on failure (catch_clean) leaves no site/dir | covered | ssl: custom/inherit failure scenarios (site and dir absent) | +| Site name validation | covered (regression check, EasyEngine/site-command#509) | create: Site names that are not valid hostnames are rejected... | +| Global admin-tools auth init on create | not covered | belongs to auth-command / admin-tools-command suites | +| macOS paths (dnsmasq, IS_DARWIN) | not covered | Linux test host | + +### delete + +| Item | Status | Scenarios | +|---|---|---| +| `--yes`: containers, volumes, network, site root, hosts entry, redirect conf, DB row, clean STDERR | covered | delete: Delete an html site and everything that belongs to it | +| Second delete / unknown site | covered | delete: same scenario; site: outline | +| Prompt answered n / y | covered | delete: Answering no...; Answering yes... | +| Disabled site | covered | delete: A disabled site can be deleted | +| SSL files removed (self, LE + revoke, leftover cert of a site without SSL) | covered | delete: Deleting a self-signed site removes its certificate; Deleting a site without SSL removes a leftover certificate; le: Deleting a Let's Encrypt site... | +| Proxy cache config removed | covered (@destructive) | pcache: Deleting a proxy-cached site removes its proxy config | +| Alias domains freed | covered | delete: Deleting a site frees its alias domains | +| LE leftovers (www and alias ACME state) | covered | le: Deleting a Let's Encrypt site leaves no ACME state behind | +| DB/user cleanup for external DB sites, cron rows | not covered | only php/wp sites have DBs and crons (site-type-*/cron-command suites) | + +### list / info + +| Item | Status | Scenarios | +|---|---|---| +| list `--format=table/text/json/csv/yaml/count`, invalid format | covered | list: all | +| `--enabled`, `--disabled`, both | covered | list: List only enabled/disabled; Both filters | +| "No sites found!" | not covered | depends on the whole host having no (enabled/disabled) sites | +| info table (Site, Site Root, Alias Domains, SSL, SSL Wildcard) | covered | info: Show site info as a table; ssl: Info of a self-signed site; alias: Add alias domains | +| info `--format=json`, invalid format | covered | info: Show site info as JSON; An unsupported format is rejected | +| info on a disabled site, URL-style name, cwd detection | covered | info: remaining scenarios | + +### enable / disable / refresh / restart / reload / clean + +| Item | Status | Scenarios | +|---|---|---| +| disable (containers removed, volumes kept, DB, proxy stops serving), idempotent | covered | en-dis: Disable a site; Disabling a disabled site is harmless | +| enable, already enabled, `--force`, `--refresh`, `--verify` | covered | en-dis: Enable...; Enabling an enabled site fails; Force-enable...; Enable with --refresh...; Enable with verification... | +| `refresh` subcommand | covered | en-dis: refresh is a shortcut for enable --refresh | +| enable/disable manage the www redirect | covered | redirect: Disable removes the redirect and enable restores it; not covered yet: re-enabling drops the www redirect when the www check fails (its scenario comes back with the fix) | +| enable post-configuration of postfix / admin-tools / mailhog | not covered | html sites have none (site-type-*, admin-tools, mailhog suites) | +| restart (no flag, `--nginx`, `--all`), unknown service flag | covered | rr: Restart the site containers (outline); Restart rejects services... | +| restart refuses a broken nginx config | covered | rr: Restart refuses a broken nginx config and keeps the site serving | +| reload (no flag, `--nginx`, `--all`), custom config applied | covered | rr: Reload nginx... (outline); Custom nginx config is applied on reload | +| reload with a broken config | not covered yet | reload exits 0 when `nginx -t` fails (its scenario comes back with the fix) | +| `custom-compose` config branch of restart/enable | not covered | needs a custom compose setup (config change) | +| clean: no flags, `--object`, `--page`, `--proxy` without proxy cache | covered | clean: first four scenarios | +| clean `--proxy` with proxy cache | covered (@destructive, regression check for the purge without a terminal) | clean: Cleaning the proxy cache empties it | +| clean without flags on a proxy-cached html site | not covered yet | clean without flags always fails on html sites (its scenario comes back with the fix) | +| clean object/page caches with redis | not covered | only WP cache sites have them (site-type-wp suite) | + +### update + +| Item | Status | Scenarios | +|---|---|---| +| `--add-alias-domains` (DB, env, HTTP, info) | covered | alias: Add alias domains | +| already present / partial overlap warning | covered | alias: Adding an alias that is already present fails; Adding present and new aliases... | +| `--delete-alias-domains`, primary, not a subset | covered | alias: Delete an alias domain; The primary domain cannot be deleted; Deleting an alias the site does not have fails | +| alias that is a site / alias of another site | covered | alias: Another site cannot be added...; An alias of another site cannot be added | +| add + delete in one call, wildcard alias | covered | alias: Add and delete aliases in one call; A wildcard alias serves every subdomain | +| alias change on self (keeps HTTPS), on LE (reissue), LE alias failing validation (rolled back) | covered | alias: Changing aliases on a self-signed site keeps HTTPS; le: alias scenarios; An alias that fails validation is rejected... | +| alias label validation | covered (regression check, EasyEngine/site-command#502) | alias: Alias domains with a label starting with an underscore are rejected | +| alias change on custom SSL (warning) | not covered | low value; same code path as self apart from the warning text | +| `--ssl=self` on a plain site, twice, `--ssl=off`, off on plain | covered | ussl: first four scenarios | +| `--wildcard` refusals, invalid `--ssl`, `--ssl=off --wildcard` | covered | ussl: Switching a normal site to wildcard SSL is refused; An unknown SSL type is rejected; --wildcard cannot be combined with --ssl=off | +| `--ssl=inherit` via update | covered | ussl: Inherit SSL from a self-signed parent through update; not covered yet: update skips the wildcard parent check (its scenario comes back with the fix) | +| `--ssl=le` via update | covered (@le) | le: Enable Let's Encrypt on an existing site | +| `--ssl=custom` via update | covered (regression check, EasyEngine/site-command#489) | ussl: Enable custom SSL on an existing site | +| off on a created self-signed site, wildcard flag consistency | covered (regression checks, EasyEngine/site-command#505) | ussl: SSL can be disabled on a site created with self-signed SSL; Enabling self-signed SSL through update records it as wildcard... | +| `--php` on html (fails, site intact), invalid version | covered | ussl: Changing the PHP version of an html site fails...; An unsupported PHP version is rejected; not covered yet: the refusal comes late, with a raw IOException and a leftover `.backup/` (its scenario comes back with the fix) | +| `--php` real version switch (config backup/restore, sendmail path for 5.6) | not covered | php/wp sites only (site-type-php/wp suites) | +| `--proxy-cache=on/off`, twice, custom sizes, invalid sizes | covered (@destructive) | pcache: all but the invalid switch | +| invalid `--proxy-cache` value | not covered yet | any value is accepted and anything but `on` disables the cache (its scenario comes back with the fix) | +| proxy cache for aliases (existing, newly added, deleted), failed alias sync leaves a loadable config | covered (@destructive) | pcache: Alias domains get proxy cache locations...; Deleting an alias removes its proxy cache location; A failed proxy cache alias sync leaves a config nginx can load | +| proxy cache `subdom` location (`*.site_location`) | not covered | WP subdom only (site-type-wp suite) | + +### SSL commands + +| Item | Status | Scenarios | +|---|---|---| +| ssl-info: self, custom, inherit (parent cert file), no SSL, LE, pending wildcard | covered | ssl: ssl-info scenarios; le: create and wildcard scenarios | +| ssl-info `--format=yaml`, invalid format | covered | ssl: ssl-info supports other formats; ssl-info rejects an unsupported format | +| ssl-info `--get-dns-records`: non-DNS site, DNS-01 site | covered | ssl: ssl-info DNS records of a site that does not use DNS-01; le: A wildcard certificate waits... | +| ssl-info `--get-dns-records` on a self-signed (wildcard) site | not covered yet | self-signed wildcard sites are treated as DNS-01 (its scenario comes back with the fix) | +| ssl-renew guards: no SSL, self, inherit | covered | ssl: ssl-renew only renews Let's Encrypt certificates (outline); ssl-renew refuses inherited certificates | +| ssl-renew not due (no reissue), `--all` wildcard skip | covered (@le, `--all` also @destructive) | le: ssl-renew leaves a certificate that is not due alone; ssl-renew --all skips wildcard sites... | +| ssl-renew `--force`, expired-cert path | not covered | LE budget (a forced renew is one more issuance); expiry cannot be simulated without a fake CA | +| ssl-verify / `ssl` alias: unknown site | covered | site: outline | +| ssl-verify on a valid LE site | not covered yet | ssl-verify checks only the site name of an HTTP-01 certificate that includes www and fails (its scenario comes back with the fix) | +| ssl-verify completing a DNS-01 order, Cloudflare DNS solver | not covered | needs Cloudflare API credentials (not configured) | +| Self-signed root CA validity | covered (regression check, EasyEngine/site-command#513) | ssl: Self-signed site certificates verify against the EasyEngine root CA | +| Self-signed certificate covering alias domains | not covered yet | the certificate leaves the alias domains out (its scenario comes back with the fix) | +| Failed inherit error message | not covered yet | the message dumps the parent site object (its scenario comes back with the fix) | +| SSL policy (`ssl-policy` config) | not covered | global config change (@destructive) with little behaviour beyond the template | + +### share + +| Item | Status | Scenarios | +|---|---|---| +| `--disable` / `--refresh` without an active share, SSL site refused, required name | covered | share: all; site: usage outline | +| Actual share (ngrok download, tunnel, `--token`), one share at a time, share cleanup on delete | not covered | the ngrok v2 binary URL (bin.equinox.io) is retired and ngrok needs an account token; external network | + +### backup / restore (@remote) + +| Item | Status | Scenarios | +|---|---|---| +| backup html: upload, success output, remote files (metadata.json, .zip, conf.zip), local cleanup, lock released | covered | backup: Back up an html site | +| `--list` | covered | backup: Back up an html site; A disabled site cannot be backed up but its backups can be listed | +| `--list` with no backups | not covered yet | the "No remote backups found." message is never printed (its scenario comes back with the fix) | +| restore latest, restore `--id`, unknown id | covered | backup: Restore the latest backup; Restore a specific backup by id; Restoring an unknown backup id fails | +| disabled site | covered | backup: A disabled site cannot be backed up...; en-dis outline | +| `--dash-auth` format and ed-api-url check | covered | backup: A malformed --dash-auth is rejected; --dash-auth needs ed-api-url in the config | +| `--dash-auth` success/failure callbacks, rollback of the uploaded backup | not covered | needs an EasyDash API endpoint (`ed-api-url`) | +| per-site lock blocks, lock left by a failed restore | covered | backup: A lock file of another backup blocks the backup (held with flock(1), so it holds with or without EasyEngine/site-command#480); not covered yet: a failed restore leaves the lock file and blocks later backups (its scenario comes back with the fix) | +| stale download reuse | not covered yet | a restore by id reuses files an earlier restore left behind (its scenario comes back with the fix) | +| retention (`no-of-backups`) | not covered yet | retention keeps one backup more than `no-of-backups` (its scenario comes back with the fix) | +| WP backup/restore (DB, wp-content, core download) | covered (@wp; core checksums are a regression check, EasyEngine/site-command#504) | backup: Back up and restore a WordPress site; A restored WordPress core passes checksum verification; not covered yet: the restore prints the database password (its scenario comes back with the fix) | +| custom docker-compose files in backups | not covered | needs a custom compose setup | +| disk-space checks, rclone/7z missing, apt-get auto-install, global backup lock wait | not covered | needs a full disk, uninstalled tools or a concurrent backup; the auto-install path needs a pending kernel upgrade | +| partial SQL dump left in the webroot after a failed dump | not covered | needs a failing mysqldump (fault injection) | +| invalid `no-of-backups` | not covered yet | an invalid value deletes backups without a warning (its scenario comes back with the fix) | +| per-host remote layout, hosts sharing `rclone-path` | not covered yet | hosts that share a remote mix their backups (its scenario comes back with the fix) | +| restore without any backup | not covered yet | the restore doesn't say that there is no backup (its scenario comes back with the fix) | +| files and dotfiles not in the backup | not covered yet | a restore keeps files that are not in the backup (its scenario comes back with the fix) | +| WP webroot files beside WordPress, symlinked uploads | not covered yet | files next to WordPress are left out and symlinked uploads loop (their scenarios come back with the fixes) | +| restore rollback, disk-full extraction | not covered | needs fault injection or a small filesystem | + +### clone / sync + +| Item | Status | Scenarios | +|---|---|---| +| Location validation (implicit names, both remote, same site, malformed remote) | covered | clone: first four scenarios | +| Missing source, disabled source, html source, missing destination, html sync | covered | clone: remaining validation scenarios | +| Local WP clone (files, DB, search-replace), existing destination kept | covered (@wp) | clone: Clone a WordPress site on the same host; Cloning onto an existing site fails and keeps that site | +| sync `--files`, `--db`, `--uploads` | covered (@wp) | clone: Sync only files / the database / uploads | +| clone `--ssl=...`, `--wildcard`, cert copy, admin-tools/mailhog replication | not covered | same-host clone of an SSL WP site is possible but slow; LE options need the LE budget | +| Remote clone/sync over SSH, production guard (`env: production`), non-JSON site list output, inherit-site checks on the other host | not covered | needs a second SSH-reachable EE host (or a fake ssh shim); the production guard needs a global config change | + +### Not in scope + +DB and container migrations under `migrations/` (core upgrade flow), the AcmePhp repository classes (only through LE scenarios), macOS-only code. + +## Coverage estimate + +Counting the rows above as items: 124 items, 83 covered, 1 partial, 40 not covered (16 of them, marked "not covered yet", are open bugs whose scenarios come back with their fixes), i.e. about 67% of the inventory. Every user-facing subcommand and option of the html type has at least one scenario except `ssl-renew --force`, `clone --ssl/--wildcard` and a live `share`. + +## Known bugs left out + +24 scenarios that expose open bugs are not part of this suite; each comes back with the PR that fixes its bug. They cover: the www redirect of a site whose next label starts with `w`, and the redirect after re-enabling a site when the www check fails; the owner lookup of an alias that contains another alias; `update --ssl=inherit` skipping the parent check; the inherit error message dumping the parent site object; the self-signed certificate leaving out alias domains; `ssl-info --get-dns-records` on self-signed sites; `ssl-verify` on a valid Let's Encrypt site; `site reload` exiting 0 when `nginx -t` fails; `site clean` without flags on html sites; invalid `--proxy-cache` values; the removed EE v3 cache flags message; `update --php` on html sites failing late; and in backup/restore: `--list` and restore without backups, a failed restore blocking later backups, stale downloads reused by a restore, retention, invalid `no-of-backups`, hosts sharing a remote, files left over by a restore, the database password printed by a WordPress restore, and WordPress webroot files and symlinked uploads. + +PRs that change what passing scenarios assert, to revisit when they merge: EasyEngine/site-command#480 keeps `.lock` after a successful backup/restore (drop the two "lock should not exist" lines in backup), EasyEngine/site-command#482 adds a `--list` "No remote backups found." line and new disk-space errors, EasyEngine/site-command#484 and #483 add new restore/backup abort messages. diff --git a/features/backup-restore.feature b/features/backup-restore.feature new file mode 100644 index 00000000..b2142139 --- /dev/null +++ b/features/backup-restore.feature @@ -0,0 +1,132 @@ +@remote +Feature: Backup and restore + `ee site backup` / `ee site restore` against the REAL rclone remote from config.yml (rclone-path). + Only /bh-site-command-* is touched; it is purged after the feature. + + Background: + Given the EasyEngine config value "rclone-path" is set + + Scenario: Back up an html site + Given the html site "bh-site-command-140.test" exists + And there are no remote backups for site "bh-site-command-140.test" + And I write "bh-site-command-marker-140" to the file "{SITES_DIR}/bh-site-command-140.test/app/htdocs/bh-marker.txt" + When I run 'bin/ee site backup bh-site-command-140.test' + Then the command should succeed + And STDOUT should contain "Backup uploaded to remote storage." + And STDOUT should contain "Backup created successfully." + And the remote backup storage should have 1 backup for site "bh-site-command-140.test" + And the directory "{BACKUP_DIR}/bh-site-command-140.test" should not exist + # PR #480 keeps the lock file (it becomes an flock); drop this line when it merges. + And the file "{BACKUP_DIR}/bh-site-command-140.test.lock" should not exist + When I run 'bin/ee site backup bh-site-command-140.test --list' + Then the command should succeed + And STDOUT should match "/^\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/m" + When I save the first match of "/^(\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})$/m" in STDOUT as "BACKUP_ID" + Then the remote backup "{BACKUP_ID}" of site "bh-site-command-140.test" should contain the file "metadata.json" + And the remote backup "{BACKUP_ID}" of site "bh-site-command-140.test" should contain the file "bh-site-command-140.test.zip" + And the remote backup "{BACKUP_ID}" of site "bh-site-command-140.test" should contain the file "conf.zip" + + Scenario: Restore the latest backup + Given the html site "bh-site-command-141.test" exists + And there are no remote backups for site "bh-site-command-141.test" + And I write "original" to the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/bh-marker.txt" + When I run 'bin/ee site backup bh-site-command-141.test' + Then the command should succeed + When I write "changed" to the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/bh-marker.txt" + And I remove the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/index.html" + And I run 'bin/ee site restore bh-site-command-141.test' + Then the command should succeed + And STDOUT should contain "Site restored successfully." + And the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/bh-marker.txt" should contain "original" + And the file "{SITES_DIR}/bh-site-command-141.test/app/htdocs/index.html" should exist + # PR #480 keeps the lock file (it becomes an flock); drop this line when it merges. + And the file "{BACKUP_DIR}/bh-site-command-141.test.lock" should not exist + When I wait up to 60 seconds for "http://bh-site-command-141.test/bh-marker.txt" to return 200 + Then the response body should contain "original" + + Scenario: Restore a specific backup by id + Given the html site "bh-site-command-142.test" exists + And there are no remote backups for site "bh-site-command-142.test" + And I write "first" to the file "{SITES_DIR}/bh-site-command-142.test/app/htdocs/bh-marker.txt" + When I run 'bin/ee site backup bh-site-command-142.test' + Then the command should succeed + When I run 'bin/ee site backup bh-site-command-142.test --list' + And I save the first match of "/^(\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})$/m" in STDOUT as "FIRST_ID" + And I write "second" to the file "{SITES_DIR}/bh-site-command-142.test/app/htdocs/bh-marker.txt" + And I wait for the next second + And I run 'bin/ee site backup bh-site-command-142.test' + Then the command should succeed + And the remote backup storage should have 2 backups for site "bh-site-command-142.test" + When I run 'bin/ee site restore bh-site-command-142.test --id={FIRST_ID}' + Then the command should succeed + And the file "{SITES_DIR}/bh-site-command-142.test/app/htdocs/bh-marker.txt" should contain "first" + + Scenario: Restoring an unknown backup id fails + Given the html site "bh-site-command-140.test" exists + When I run 'bin/ee site restore bh-site-command-140.test --id=1000000000_2001-09-09-01-46-40' + Then the command should fail + And STDERR should contain "Invalid backup ID provided." + + Scenario: A disabled site cannot be backed up but its backups can be listed + Given the html site "bh-site-command-143.test" exists + And there are no remote backups for site "bh-site-command-143.test" + When I run 'bin/ee site backup bh-site-command-143.test' + Then the command should succeed + When I run 'bin/ee site disable bh-site-command-143.test' + And I run 'bin/ee site backup bh-site-command-143.test' + Then the command should fail + And STDERR should contain "Site bh-site-command-143.test is not enabled." + When I run 'bin/ee site backup bh-site-command-143.test --list' + Then the command should succeed + And STDOUT should match "/^\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/m" + + Scenario: A malformed --dash-auth is rejected + Given the html site "bh-site-command-140.test" exists + When I run 'bin/ee site backup bh-site-command-140.test --dash-auth=no-separator' + Then the command should fail + And STDERR should contain "Invalid --dash-auth format. Expected: backup-id:backup-verification-token" + + Scenario: --dash-auth needs ed-api-url in the config + Given the EasyEngine config value "ed-api-url" is not set + And the html site "bh-site-command-140.test" exists + When I run 'bin/ee site backup bh-site-command-140.test --dash-auth=bh-id:bh-token' + Then the command should fail + And STDERR should contain "ed-api-url is not configured." + + # The lock file is also held with flock(1): develop only checks that the file exists, PR #480 only honours a held flock. + Scenario: A lock file of another backup blocks the backup + Given the html site "bh-site-command-144.test" exists + And I write "lock" to the file "{BACKUP_DIR}/bh-site-command-144.test.lock" + When I run 'flock {BACKUP_DIR}/bh-site-command-144.test.lock bin/ee site backup bh-site-command-144.test' + Then the command should fail + And STDERR should contain "Another backup/restore process is running. Please wait for it to complete." + + @wp + Scenario: Back up and restore a WordPress site + Given "ee" is installed + And the site "bh-site-command-148.test" exists, created with "--type=wp" + And there are no remote backups for site "bh-site-command-148.test" + When I run "bin/ee shell bh-site-command-148.test --skip-tty --command='wp option update blogname bh-site-command-before'" + Then the command should succeed + When I run 'bin/ee site backup bh-site-command-148.test' + Then the command should succeed + And STDOUT should contain "Backup created successfully." + When I run "bin/ee shell bh-site-command-148.test --skip-tty --command='wp option update blogname bh-site-command-after'" + And I run 'bin/ee site restore bh-site-command-148.test' + Then the command should succeed + And STDOUT should contain "Site restored successfully." + When I run "bin/ee shell bh-site-command-148.test --skip-tty --command='wp option get blogname'" + Then STDOUT should contain "bh-site-command-before" + When I wait up to 60 seconds for "http://bh-site-command-148.test/" to return 200 + + # Regression check (fixed in v4.13.1, EasyEngine/site-command#504): `wp core download` (used by restore) writes a corrupted WordPress 7.x tree with WP-CLI 2.12. + @wp + Scenario: A restored WordPress core passes checksum verification + Given "ee" is installed + And the site "bh-site-command-148.test" exists, created with "--type=wp" + When I run 'bin/ee site backup bh-site-command-148.test' + Then the command should succeed + When I run 'bin/ee site restore bh-site-command-148.test' + Then the command should succeed + When I run "bin/ee shell bh-site-command-148.test --skip-tty --command='wp core verify-checksums'" + Then the command should succeed diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index c24728c6..1c3d8582 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -22,7 +22,10 @@ $root_alias = '/Users/' . $sys_name; } } -define( 'EE_ROOT_DIR', $root_alias . '/easyengine' ); +// php/init-ee.php may already have defined it (same value on Linux). +if ( ! defined( 'EE_ROOT_DIR' ) ) { + define( 'EE_ROOT_DIR', $root_alias . '/easyengine' ); +} require_once EE_ROOT . '/php/bootstrap.php'; @@ -55,33 +58,71 @@ use Behat\Behat\Context\Context; use Behat\Behat\Hook\Scope\AfterFeatureScope; use Behat\Behat\Hook\Scope\AfterScenarioScope; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Behat\Gherkin\Node\PyStringNode, Behat\Gherkin\Node\TableNode; define( 'EE_SITE_ROOT', EE_ROOT_DIR . '/sites' ); +if ( ! defined( 'EE_TEST_PROXY_CONTAINER' ) ) { + define( 'EE_TEST_PROXY_CONTAINER', 'services_global-nginx-proxy_1' ); +} + class FeatureContext implements Context { + /** + * Every site, hosts entry, file and remote backup the suite creates or removes must contain this prefix. + * Packages copying this context change only this constant. + */ + const SITE_PREFIX = 'bh-site-command-'; + public $command; + public $commands; public $webroot_path; public $ee_path; /** - * Contain all test sites name. + * Variables usable as {NAME} in step arguments. + * + * @var array + */ + private $variables = []; + + /** + * Last HTTP response: code, headers (lowercased name => value), raw_headers, body, error. + * + * @var array|null + */ + private $response = null; + + /** + * Container start times noted by "I note the start time of ...". + * + * @var array + */ + private $start_times = []; + + /** + * Hosts entries added by the current scenario, removed afterwards. * * @var array */ - public static $test_sites = [ - 'wp.test', - 'wpsubdom.test', - 'wpsubdir.test', - 'example.test', - 'www.example1.test', - 'example2.test', - 'www.example3.test', - 'labels.test' - ]; + private $added_hosts = []; + + /** + * Per-scenario scratch directory ({TMP}). + * + * @var string + */ + private $tmp_dir = ''; + + /** + * Unique id of this Behat run ({RUN_ID}), used to keep Let's Encrypt names distinct across runs. + * + * @var string + */ + private static $run_id = ''; /** * Initializes context. @@ -90,6 +131,9 @@ public function __construct() { $this->commands = []; $this->ee_path = getcwd(); + if ( empty( self::$run_id ) ) { + self::$run_id = date( 'mdHis' ); + } } /** @@ -126,11 +170,13 @@ public function isInstalled($command) } /** - * @When /I run '(.*)'|"(.*)"/ + * Runs a shell command from the core root; {NAME} variables are expanded. + * + * @When /^I run (['"])(.*)\1$/ */ - public function iRun($command) + public function iRun($quote, $command) { - $this->commands[] = EE::launch($command, false, true); + $this->commands[] = EE::launch( $this->v( $command ), false, true ); } /** @@ -149,17 +195,17 @@ public function iCreateSubsiteInOfType($subsite, $site ) public function returnValueShouldBe0() { if ( 0 !== $this->commands[0]->return_code ) { - throw new Exception("Actual return code is not zero: \n" . $this->command); + throw new Exception( "Actual return code is not zero:\n" . $this->describe( $this->commands[0] ) ); } } /** - * @Then return value of command :index should be 0 + * @Then /^return value of command (\d+) should be 0$/ */ public function returnValueOfCommandShouldBe0($index) { if ( 0 !== $this->commands[ $index - 1 ]->return_code ) { - throw new Exception("Actual return code is not zero: \n" . $this->command); + throw new Exception( "Actual return code is not zero:\n" . $this->describe( $this->commands[ $index - 1 ] ) ); } } @@ -172,13 +218,24 @@ public function afterDelayOfSeconds( $time ) } /** - * @Then /(STDOUT|STDERR) should return exactly/ + * Backup ids have one-second resolution: back-to-back backups need a second boundary between them. + * + * @When I wait for the next second + */ + public function iWaitForTheNextSecond() + { + $now = microtime( true ); + usleep( (int) ( ( floor( $now ) + 1 - $now ) * 1000000 ) + 1000 ); + } + + /** + * @Then /^(STDOUT|STDERR) should return exactly$/ */ public function stdoutShouldReturnExactly($output_stream, PyStringNode $expected_output) { $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; - $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); + $command_output = $this->strip_colors( $command_output ); if ( $expected_output->getRaw() !== trim($command_output)) { throw new Exception("Actual output is:\n" . $command_output); @@ -186,43 +243,40 @@ public function stdoutShouldReturnExactly($output_stream, PyStringNode $expected } /** - * @Then /(STDOUT|STDERR) of command :index should return exactly/ + * @Then /^(STDOUT|STDERR) of command (\d+) should return exactly$/ */ public function stdoutOfCommandShouldReturnExactly($output_stream, $index, PyStringNode $expected_output) { $command_output = $output_stream === "STDOUT" ? $this->commands[ $index - 1 ]->stdout : $this->commands[ $index - 1 ]->stderr; - $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); + $command_output = $this->strip_colors( $command_output ); - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if ( $expected_out !== trim($command_output)) { + if ( $expected_output->getRaw() !== trim($command_output)) { throw new Exception("Actual output is:\n" . $command_output); } } /** - * @Then /(STDOUT|STDERR) should return something like/ + * @Then /^(STDOUT|STDERR) should return something like$/ */ public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $expected_output) { $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if (strpos($command_output, $expected_out) === false) { + if (strpos($command_output, $expected_output->getRaw()) === false) { throw new Exception("Actual output is:\n" . $command_output); } } /** - * @Then /(STDOUT|STDERR) of command :index should return something like/ + * @Then /^(STDOUT|STDERR) of command (\d+) should return something like$/ */ public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, PyStringNode $expected_output) { $command_output = $output_stream === "STDOUT" ? $this->commands[ $index - 1 ]->stdout : $this->commands[ $index - 1 ]->stderr; - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if (strpos($command_output, $expected_out) === false) { + if (strpos($command_output, $expected_output->getRaw()) === false) { throw new Exception("Actual output is:\n" . $command_output); } } @@ -254,11 +308,9 @@ public function theSiteShouldBeMultisite( $site, $type ) */ public function theDbEntryShouldBeRemoved($site) { - $out = shell_exec("sudo bin/ee site list"); - if (strpos($out, $site) !== false) { + if ( false !== $this->get_site_row( $site ) ) { throw new Exception("$site db entry not been removed!"); } - } /** @@ -276,17 +328,10 @@ public function theWebrootShouldBeRemoved($site) */ public function followingContainersOfSiteShouldBeRemoved($site, TableNode $table) { - $containers = $table->getHash(); - $site_name = implode(explode('.', $site)); - - foreach ($containers as $container) { - - $sevice = $container['container']; - $container_name = $site_name . '_' . $sevice . '_1'; - - exec("docker inspect -f '{{.State.Running}}' $container_name > /dev/null 2>&1", $exec_out, $return); - if (!$return) { - throw new Exception("$container_name has not been removed!"); + foreach ( $table->getHash() as $container ) { + $ids = $this->site_container_ids( $site, $container['container'], true ); + if ( ! empty( $ids ) ) { + throw new Exception( "The {$container['container']} container of $site has not been removed: " . implode( ', ', $ids ) ); } } } @@ -316,23 +361,11 @@ public function theSiteShouldHaveWordpress($site) */ public function requestOnShouldContainFollowingHeaders($site, TableNode $table) { - $url = 'http://' . $site; - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_HEADER, true); - curl_setopt($ch, CURLOPT_NOBODY, true); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_VERBOSE, true); - $headers = curl_exec($ch); - - curl_close($ch); - - $rows = $table->getHash(); + $response = $this->http_request( 'http://' . $site . '/', 'HEAD' ); - foreach ($rows as $row) { - if (strpos($headers, $row['header']) === false) { - throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + foreach ( $table->getHash() as $row ) { + if ( strpos( $response['raw_headers'], $row['header'] ) === false ) { + throw new Exception( "Unable to find " . $row['header'] . "\nActual output is : " . $response['raw_headers'] . $response['error'] ); } } } @@ -349,7 +382,6 @@ public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, curl_setopt($ch, CURLOPT_HTTPHEADER, [ $header ]); curl_setopt($ch, CURLOPT_NOBODY, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_VERBOSE, true); $headers = curl_exec($ch); curl_close($ch); @@ -377,7 +409,6 @@ public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_RESOLVE, [ $resolve ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_VERBOSE, true); $headers = curl_exec($ch); curl_close($ch); @@ -390,6 +421,45 @@ public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $ } } + /** + * @Then There should be :expected_running_containers containers with labels + */ + public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) + { + $filters = ''; + foreach ( $pyStringNode->getStrings() as $label ) { + $filters .= ' --filter ' . escapeshellarg( 'label=' . trim( $this->v( $label ) ) ); + } + + $result = EE::launch( "docker ps -q $filters | wc -l", false, true ); + $running_containers = (int) trim( $result->stdout ); + + if ( (int) $expected_running_containers !== $running_containers ) { + throw new Exception( "Expected $expected_running_containers running containers. Found: $running_containers" ); + } + } + + /* ===================================================================== + * Shared generic steps. + * + * Written to be copied as-is into other packages' contexts: only SITE_PREFIX changes. + * Every string argument goes through v(), so {NAME} variables work everywhere: + * built-ins are {EE}, {EE_ROOT_DIR}, {SITES_DIR}, {PROXY_DIR}, {BACKUP_DIR}, {TMP}, {RUN_ID}, {PUBLIC_IP}, + * {RCLONE_PATH} and {LE_ISSUER}; others come from the "I save ..." steps. + * ===================================================================== */ + + /** + * @BeforeScenario + */ + public function prepareScenario( BeforeScenarioScope $scope ) + { + $this->tmp_dir = '/var/tmp/' . self::SITE_PREFIX . 'behat-' . getmypid() . '-' . mt_rand( 1000, 9999 ); + @mkdir( $this->tmp_dir, 0755, true ); + $this->variables = []; + $this->response = null; + $this->start_times = []; + } + /** * @AfterScenario */ @@ -397,35 +467,75 @@ public function cleanupScenario(AfterScenarioScope $scope) { $this->commands = []; chdir($this->ee_path); + + foreach ( $this->added_hosts as $host ) { + $this->remove_hosts_entry( $host ); + } + $this->added_hosts = []; + + if ( $this->tmp_dir && 0 === strpos( $this->tmp_dir, '/var/tmp/' . self::SITE_PREFIX ) ) { + EE::launch( 'rm -rf ' . escapeshellarg( $this->tmp_dir ), false, true ); + } + + // Leftovers of failed backups/restores of our own sites would make the next one fail. + foreach ( glob( EE_ROOT_DIR . '/.backup/' . self::SITE_PREFIX . '*' ) ?: [] as $path ) { + EE::launch( 'rm -rf ' . escapeshellarg( $path ), false, true ); + } } /** - * @Then There should be :expected_running_containers containers with labels + * On CI, EE's hardcoded Let's Encrypt host must point at Pebble or a blackhole (.github/behat/pebble-setup.sh), never the real service. + * + * @BeforeSuite */ - public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) + public static function refuseRealAcmeOnCi() { - $labels = $pyStringNode->getStrings(); - $label_string = implode(' -f label=',$labels); - - $result = EE::launch( "docker ps -qf label=$label_string | wc -l", false, true ); - $running_containers = (int) trim($result->stdout); + if ( getenv( 'CI' ) && ! self::acme_host_is_local() ) { + throw new Exception( 'acme-v02.api.letsencrypt.org resolves to ' . gethostbyname( 'acme-v02.api.letsencrypt.org' ) . ' on CI; run `.github/behat/pebble-setup.sh setup` (le lanes) or `block` (every other lane) first.' ); + } + } - if($expected_running_containers === $running_containers) { - throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); + /** + * @le issues certificates: only against Pebble, or on a real test host with EE_BEHAT_LE_REAL=1 (ignored on CI). + * + * @BeforeScenario @le + */ + public function requireTestAcme() + { + if ( getenv( 'EE_BEHAT_LE_ISSUER' ) && self::acme_host_is_local() ) { + return; } + if ( '1' === getenv( 'EE_BEHAT_LE_REAL' ) && ! getenv( 'CI' ) ) { + return; + } + throw new Exception( '@le needs Pebble (.github/behat/pebble-setup.sh setup), or EE_BEHAT_LE_REAL=1 on a host with real DNS for *.ee4-test.rt.gw.' ); + } + + /** + * True when the LE host resolves to a private, loopback or TEST-NET address (Pebble's front proxy or the blackhole). + */ + private static function acme_host_is_local() + { + $ip = gethostbyname( 'acme-v02.api.letsencrypt.org' ); + + return (bool) preg_match( '/^(10\.|127\.|192\.168\.|192\.0\.2\.|172\.(1[6-9]|2\d|3[01])\.)/', $ip ); } /** + * Deletes only the sites (and remote backups) of this suite; never touches other sites. + * * @AfterFeature */ public static function cleanup(AfterFeatureScope $scope) { - $result = EE::launch( 'sudo bin/ee site list --format=text',false, true ); - $running_sites = explode( "\n", $result->stdout ); - $sites_to_delete = array_intersect( self::$test_sites, $running_sites ); + foreach ( self::own_sites() as $site ) { + EE::launch( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes', false, true ); + } + + self::purge_own_leftovers(); - foreach ( $sites_to_delete as $site ) { - exec("sudo bin/ee site delete $site --yes" ); + if ( in_array( 'remote', $scope->getFeature()->getTags(), true ) ) { + self::purge_own_remote_backups(); } if(file_exists('ee.phar')) { @@ -435,4 +545,1299 @@ public static function cleanup(AfterFeatureScope $scope) unlink('ee-old.phar'); } } + + /* ----- command execution and output ----- */ + + /** + * Runs a command, killing it after the given time (for commands that could hang, e.g. the proxy cache purge without a terminal). + * + * @When /^I run (['"])(.*)\1 with a timeout of (\d+) seconds$/ + */ + public function iRunWithATimeout( $quote, $command, $seconds ) + { + $this->commands[] = EE::launch( sprintf( 'timeout -k 10 %d sh -c %s', $seconds, escapeshellarg( $this->v( $command ) ) ), false, true ); + } + + /** + * @Then the command should succeed + */ + public function theCommandShouldSucceed() + { + $run = $this->last_command(); + if ( 0 !== $run->return_code ) { + throw new Exception( "Expected exit code 0.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then the command should fail + */ + public function theCommandShouldFail() + { + $run = $this->last_command(); + if ( 0 === $run->return_code ) { + throw new Exception( "Expected a non-zero exit code.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then /^the exit code should be (\d+)$/ + */ + public function theExitCodeShouldBe( $code ) + { + $run = $this->last_command(); + if ( (int) $code !== $run->return_code ) { + throw new Exception( "Expected exit code $code.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should (not )?contain "(.*)"$/ + */ + public function outputShouldContain( $stream, $not, $text ) + { + $this->assert_output_contains( $stream, $not, $this->v( $text ) ); + } + + /** + * @Then /^(STDOUT|STDERR) should (not )?contain:$/ + */ + public function outputShouldContainBlock( $stream, $not, PyStringNode $text ) + { + $this->assert_output_contains( $stream, $not, $this->v( $text->getRaw() ) ); + } + + /** + * @Then /^(STDOUT|STDERR) should (not )?match "(.*)"$/ + */ + public function outputShouldMatch( $stream, $not, $regex ) + { + $run = $this->last_command(); + $output = $this->stream_of( $run, $stream ); + $regex = $this->v( $regex ); + $found = preg_match( $regex, $output ); + if ( false === $found ) { + throw new Exception( "Invalid regular expression: $regex" ); + } + if ( (bool) $found === (bool) $not ) { + throw new Exception( sprintf( "Expected %s %sto match %s.\n%s", $stream, $not, $regex, $this->describe( $run ) ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should be empty$/ + */ + public function outputShouldBeEmpty( $stream ) + { + $run = $this->last_command(); + if ( '' !== trim( $this->stream_of( $run, $stream ) ) ) { + throw new Exception( "Expected $stream to be empty.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should be exactly:$/ + */ + public function outputShouldBeExactly( $stream, PyStringNode $expected ) + { + $run = $this->last_command(); + if ( trim( $this->v( $expected->getRaw() ) ) !== trim( $this->stream_of( $run, $stream ) ) ) { + throw new Exception( "Expected $stream to be exactly:\n" . $this->v( $expected->getRaw() ) . "\n" . $this->describe( $run ) ); + } + } + + /** + * @Then STDOUT should be valid JSON + */ + public function stdoutShouldBeValidJson() + { + $this->decode_stdout_json(); + } + + /** + * Dot path into the decoded STDOUT JSON, e.g. "site_ssl" or "0.ssl_type". + * + * @Then /^the JSON field "([^"]*)" in STDOUT should (not )?be "(.*)"$/ + */ + public function theJsonFieldShouldBe( $path, $not, $expected ) + { + $actual = $this->json_path( $this->decode_stdout_json(), $path ); + $expected = $this->v( $expected ); + $actual = is_bool( $actual ) ? ( $actual ? 'true' : 'false' ) : ( is_scalar( $actual ) || null === $actual ? (string) $actual : json_encode( $actual ) ); + if ( ( $actual === $expected ) === (bool) $not ) { + throw new Exception( sprintf( "Expected JSON field %s %sto be %s, got %s.\n%s", $path, $not, var_export( $expected, true ), var_export( $actual, true ), $this->describe( $this->last_command() ) ) ); + } + } + + /** + * @Then /^the JSON field "([^"]*)" in STDOUT should contain "(.*)"$/ + */ + public function theJsonFieldShouldContain( $path, $expected ) + { + $actual = $this->json_path( $this->decode_stdout_json(), $path ); + $actual = is_scalar( $actual ) ? (string) $actual : json_encode( $actual ); + $expected = $this->v( $expected ); + if ( false === strpos( $actual, $expected ) ) { + throw new Exception( sprintf( "Expected JSON field %s to contain %s, got %s.\n%s", $path, var_export( $expected, true ), var_export( $actual, true ), $this->describe( $this->last_command() ) ) ); + } + } + + /** + * @Then /^the JSON array in STDOUT should (not )?contain an item with "([^"]*)" set to "(.*)"$/ + */ + public function theJsonArrayShouldContainItem( $not, $key, $value ) + { + $items = $this->decode_stdout_json(); + $value = $this->v( $value ); + if ( ! is_array( $items ) ) { + throw new Exception( "STDOUT JSON is not an array.\n" . $this->describe( $this->last_command() ) ); + } + $found = false; + foreach ( $items as $item ) { + if ( is_array( $item ) && array_key_exists( $key, $item ) && (string) $item[ $key ] === $value ) { + $found = true; + break; + } + } + if ( $found === (bool) $not ) { + throw new Exception( sprintf( "Expected the JSON array %sto contain an item with %s = %s.\n%s", $not, $key, $value, $this->describe( $this->last_command() ) ) ); + } + } + + /** + * @When /^I save the JSON field "([^"]*)" in STDOUT as "([A-Z][A-Z0-9_]*)"$/ + */ + public function iSaveTheJsonField( $path, $name ) + { + $value = $this->json_path( $this->decode_stdout_json(), $path ); + $this->variables[ $name ] = is_scalar( $value ) ? (string) $value : json_encode( $value ); + } + + /** + * Saves the first capture group (or the whole match) of a regex applied to STDOUT. + * + * @When /^I save the first match of "(.*)" in STDOUT as "([A-Z][A-Z0-9_]*)"$/ + */ + public function iSaveTheFirstMatch( $regex, $name ) + { + $run = $this->last_command(); + if ( ! preg_match( $regex, $this->stream_of( $run, 'STDOUT' ), $matches ) ) { + throw new Exception( "No match for $regex in STDOUT.\n" . $this->describe( $run ) ); + } + $this->variables[ $name ] = isset( $matches[1] ) ? $matches[1] : $matches[0]; + } + + /** + * @Given /^the variable "([A-Z][A-Z0-9_]*)" is "(.*)"$/ + */ + public function theVariableIs( $name, $value ) + { + $this->variables[ $name ] = $this->v( $value ); + } + + /* ----- sites (database state) ----- */ + + /** + * @Given /^the html site "([^"]*)" exists$/ + */ + public function theHtmlSiteExists( $site ) + { + $this->ensure_site( $this->v( $site ), '--type=html' ); + } + + /** + * @Given /^the site "([^"]*)" exists, created with "(.*)"$/ + */ + public function theSiteExistsCreatedWith( $site, $flags ) + { + $this->ensure_site( $this->v( $site ), $this->v( $flags ) ); + } + + /** + * @Given /^the site "([^"]*)" does not exist$/ + */ + public function theSiteDoesNotExist( $site ) + { + $site = $this->v( $site ); + $this->assert_own_name( $site ); + if ( false !== $this->get_site_row( $site ) ) { + $run = EE::launch( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes', false, true ); + if ( 0 !== $run->return_code ) { + throw new Exception( "Could not delete $site.\n" . $this->describe( $run ) ); + } + } + } + + /** + * @Given /^the site "([^"]*)" is (enabled|disabled)$/ + */ + public function theSiteIs( $site, $state ) + { + $site = $this->v( $site ); + $row = $this->get_site_row( $site ); + if ( false === $row ) { + throw new Exception( "Site $site does not exist." ); + } + if ( ( 'enabled' === $state ) !== (bool) $row['site_enabled'] ) { + $run = EE::launch( 'bin/ee site ' . ( 'enabled' === $state ? 'enable' : 'disable' ) . ' ' . escapeshellarg( $site ), false, true ); + if ( 0 !== $run->return_code ) { + throw new Exception( "Could not make $site $state.\n" . $this->describe( $run ) ); + } + } + } + + /** + * @Then /^the site "([^"]*)" should (not )?exist$/ + */ + public function theSiteShouldExist( $site, $not = '' ) + { + $site = $this->v( $site ); + $row = $this->get_site_row( $site ); + if ( ( false !== $row ) === (bool) $not ) { + throw new Exception( "Expected site $site " . ( $not ? 'not ' : '' ) . 'to exist in the EasyEngine database.' ); + } + } + + /** + * @Then /^the site "([^"]*)" should be (enabled|disabled)$/ + */ + public function theSiteShouldBe( $site, $state ) + { + $site = $this->v( $site ); + $row = $this->get_site_row( $site ); + if ( false === $row ) { + throw new Exception( "Site $site does not exist in the EasyEngine database." ); + } + if ( ( 'enabled' === $state ) !== (bool) $row['site_enabled'] ) { + throw new Exception( "Expected $site to be $state, site_enabled is " . var_export( $row['site_enabled'], true ) ); + } + } + + /** + * @Then /^the "([^"]*)" field of site "([^"]*)" in the database should (not )?be "(.*)"$/ + */ + public function theFieldOfSiteShouldBe( $field, $site, $not, $expected ) + { + $site = $this->v( $site ); + $row = $this->get_site_row( $site ); + if ( false === $row ) { + throw new Exception( "Site $site does not exist in the EasyEngine database." ); + } + if ( ! array_key_exists( $field, $row ) ) { + throw new Exception( "Unknown column $field. Columns: " . implode( ', ', array_keys( $row ) ) ); + } + $expected = $this->v( $expected ); + $actual = null === $row[ $field ] ? '' : (string) $row[ $field ]; + if ( ( $actual === $expected ) === (bool) $not ) { + throw new Exception( sprintf( 'Expected %s of %s %sto be %s, got %s', $field, $site, $not, var_export( $expected, true ), var_export( $actual, true ) ) ); + } + } + + /* ----- files ----- */ + + /** + * @Then /^the (file|directory|symlink) "([^"]*)" should (not )?exist$/ + */ + public function thePathShouldExist( $type, $path, $not = '' ) + { + // PHP 7.4 serves is_file() of the last path it stat'ed from its stat cache, even after ee deleted it. + clearstatcache(); + $path = $this->v( $path ); + $exists = 'file' === $type ? is_file( $path ) : ( 'directory' === $type ? is_dir( $path ) : is_link( $path ) ); + if ( $exists === (bool) $not ) { + $listing = EE::launch( 'ls -la ' . escapeshellarg( dirname( $path ) ), false, true ); + throw new Exception( sprintf( "Expected %s %s %sto exist.\nls -la %s:\n%s", $type, $path, $not, dirname( $path ), $listing->stdout . $listing->stderr ) ); + } + } + + /** + * @Then /^the file "([^"]*)" should (not )?contain "(.*)"$/ + */ + public function theFileShouldContain( $path, $not, $text ) + { + $this->assert_file_contains( $this->v( $path ), $not, $this->v( $text ) ); + } + + /** + * @Then /^the file "([^"]*)" should (not )?contain:$/ + */ + public function theFileShouldContainBlock( $path, $not, PyStringNode $text ) + { + $this->assert_file_contains( $this->v( $path ), $not, $this->v( $text->getRaw() ) ); + } + + /** + * @Then /^the file "([^"]*)" should be identical to the file "([^"]*)"$/ + */ + public function theFileShouldBeIdenticalTo( $path, $other ) + { + $path = $this->v( $path ); + $other = $this->v( $other ); + if ( ! is_file( $path ) || ! is_file( $other ) || sha1_file( $path ) !== sha1_file( $other ) ) { + throw new Exception( "Expected $path to be identical to $other." ); + } + } + + /** + * @Then /^the directory "([^"]*)" should be empty$/ + */ + public function theDirectoryShouldBeEmpty( $path ) + { + $path = $this->v( $path ); + $entries = is_dir( $path ) ? array_diff( scandir( $path ), [ '.', '..' ] ) : false; + if ( false === $entries || ! empty( $entries ) ) { + throw new Exception( "Expected $path to be an empty directory, found: " . var_export( $entries, true ) ); + } + } + + /** + * Writes only inside {TMP} or paths containing the site prefix. + * + * @When /^I (write|append) "(.*)" to the file "([^"]*)"$/ + */ + public function iWriteToTheFile( $mode, $content, $path ) + { + $path = $this->v( $path ); + $this->assert_writable_path( $path ); + if ( ! is_dir( dirname( $path ) ) ) { + mkdir( dirname( $path ), 0755, true ); + } + $content = str_replace( '\n', "\n", $this->v( $content ) ); + if ( false === file_put_contents( $path, $content, 'append' === $mode ? FILE_APPEND : 0 ) ) { + throw new Exception( "Could not write to $path" ); + } + } + + /** + * @When /^I remove the (?:file|directory) "([^"]*)"$/ + */ + public function iRemoveThePath( $path ) + { + $path = $this->v( $path ); + $this->assert_writable_path( $path ); + EE::launch( 'rm -rf ' . escapeshellarg( $path ), false, true ); + } + + /* ----- /etc/hosts ----- */ + + /** + * Adds a 127.0.0.1 entry that is removed again after the scenario. + * + * @Given /^the hosts file contains an entry for "([^"]*)"$/ + */ + public function theHostsFileContainsAnEntryFor( $host ) + { + $host = $this->v( $host ); + $this->assert_own_name( $host ); + if ( ! $this->hosts_has_entry( $host ) ) { + $hosts = file_get_contents( '/etc/hosts' ); + $lead = ( '' === $hosts || "\n" === substr( $hosts, -1 ) ) ? '' : "\n"; + file_put_contents( '/etc/hosts', "{$lead}127.0.0.1\t$host\n", FILE_APPEND ); + $this->added_hosts[] = $host; + } + } + + /** + * @When /^I remove the hosts entry for "([^"]*)"$/ + */ + public function iRemoveTheHostsEntryFor( $host ) + { + $host = $this->v( $host ); + $this->assert_own_name( $host ); + $this->remove_hosts_entry( $host ); + $this->added_hosts = array_values( array_diff( $this->added_hosts, [ $host ] ) ); + } + + /** + * @Then /^the hosts file should (not )?contain an entry for "([^"]*)"$/ + */ + public function theHostsFileShouldContainAnEntryFor( $not, $host ) + { + $host = $this->v( $host ); + if ( $this->hosts_has_entry( $host ) === (bool) $not ) { + throw new Exception( "Expected /etc/hosts {$not}to contain an entry for $host:\n" . file_get_contents( '/etc/hosts' ) ); + } + } + + /* ----- docker ----- */ + + /** + * @Then /^the service "([^"]*)" of site "([^"]*)" should be running$/ + */ + public function theServiceOfSiteShouldBeRunning( $service, $site ) + { + $site = $this->v( $site ); + if ( empty( $this->site_container_ids( $site, $service ) ) ) { + $all = EE::launch( 'docker ps -a --filter ' . escapeshellarg( 'label=io.easyengine.site=' . $site ) . ' --format "{{.Names}} {{.Status}}"', false, true ); + throw new Exception( "No running $service container for $site. Containers:\n" . $all->stdout ); + } + } + + /** + * @Then /^the site "([^"]*)" should have no containers$/ + */ + public function theSiteShouldHaveNoContainers( $site ) + { + $site = $this->v( $site ); + $ids = $this->site_container_ids( $site, null, true ); + if ( ! empty( $ids ) ) { + throw new Exception( "Expected no containers for $site, found: " . implode( ', ', $ids ) ); + } + } + + /** + * @Then /^the site "([^"]*)" should have (\d+) docker volumes?$/ + */ + public function theSiteShouldHaveDockerVolumes( $site, $count ) + { + $site = $this->v( $site ); + $run = EE::launch( 'docker volume ls -q --filter label=org.label-schema.vendor=EasyEngine --filter ' . escapeshellarg( 'label=io.easyengine.site=' . $site ), false, true ); + $volumes = array_filter( explode( "\n", trim( $run->stdout ) ) ); + if ( (int) $count !== count( $volumes ) ) { + throw new Exception( "Expected $count volumes for $site, found " . count( $volumes ) . ': ' . implode( ', ', $volumes ) ); + } + } + + /** + * @Then /^the docker (network|volume|container) "([^"]*)" should (not )?exist$/ + */ + public function theDockerObjectShouldExist( $type, $name, $not = '' ) + { + $name = $this->v( $name ); + $run = EE::launch( sprintf( 'docker %s inspect %s', $type, escapeshellarg( $name ) ), false, true ); + if ( ( 0 === $run->return_code ) === (bool) $not ) { + throw new Exception( "Expected docker $type $name {$not}to exist.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then /^the docker container "([^"]*)" should be running$/ + */ + public function theDockerContainerShouldBeRunning( $name ) + { + $name = $this->v( $name ); + $run = EE::launch( "docker inspect -f '{{.State.Running}}' " . escapeshellarg( $name ), false, true ); + if ( 'true' !== trim( $run->stdout ) ) { + throw new Exception( "Container $name is not running.\n" . $this->describe( $run ) ); + } + } + + /** + * @Then /^the service "([^"]*)" of site "([^"]*)" should (not )?have the environment variable "([^"]*)" containing "(.*)"$/ + */ + public function theServiceShouldHaveEnv( $service, $site, $not, $variable, $text ) + { + $site = $this->v( $site ); + $text = $this->v( $text ); + $ids = $this->site_container_ids( $site, $service ); + if ( empty( $ids ) ) { + throw new Exception( "No running $service container for $site." ); + } + $run = EE::launch( "docker inspect -f '{{json .Config.Env}}' " . $ids[0], false, true ); + $env = json_decode( trim( $run->stdout ), true ) ?: []; + $found = false; + foreach ( $env as $line ) { + if ( 0 === strpos( $line, $variable . '=' ) && false !== strpos( substr( $line, strlen( $variable ) + 1 ), $text ) ) { + $found = true; + } + } + if ( $found === (bool) $not ) { + throw new Exception( "Expected $variable of $service ($site) {$not}to contain $text. Environment:\n" . implode( "\n", $env ) ); + } + } + + /** + * @When /^I note the start time of service "([^"]*)" of site "([^"]*)"$/ + */ + public function iNoteTheStartTimeOfService( $service, $site ) + { + $site = $this->v( $site ); + $this->start_times[ "$site/$service" ] = $this->service_started_at( $site, $service ); + } + + /** + * @Then /^the service "([^"]*)" of site "([^"]*)" should (not )?have been restarted$/ + */ + public function theServiceShouldHaveBeenRestarted( $service, $site, $not = '' ) + { + $site = $this->v( $site ); + if ( ! isset( $this->start_times[ "$site/$service" ] ) ) { + throw new Exception( "The start time of $service ($site) was not noted." ); + } + $before = $this->start_times[ "$site/$service" ]; + $now = $this->service_started_at( $site, $service ); + if ( ( $before !== $now ) === (bool) $not ) { + throw new Exception( "Expected $service of $site {$not}to have been restarted (StartedAt before: $before, now: $now)." ); + } + } + + /* ----- HTTP ----- */ + + /** + * The host of the URL is resolved to 127.0.0.1 (whitelisted, no HTTP auth), TLS is not verified, redirects are not followed. + * + * @When /^I send a (GET|HEAD) request to "([^"]*)"$/ + */ + public function iSendARequestTo( $method, $url ) + { + $this->response = $this->http_request( $this->v( $url ), $method ); + } + + /** + * Resolves through the host's public IP instead, so the global whitelist does not apply. + * + * @When /^I send a GET request to "([^"]*)" through the public IP$/ + */ + public function iSendARequestThroughThePublicIp( $url ) + { + $this->response = $this->http_request( $this->v( $url ), 'GET', [ 'resolve_ip' => $this->public_ip() ] ); + } + + /** + * Real DNS and full TLS verification (Let's Encrypt sites). + * + * @When /^I send a verified GET request to "([^"]*)"$/ + */ + public function iSendAVerifiedRequestTo( $url ) + { + $this->response = $this->http_request( $this->v( $url ), 'GET', [ 'resolve_ip' => false, 'verify' => true ] ); + } + + /** + * @Then /^the response code should (not )?be (\d{3})$/ + */ + public function theResponseCodeShouldBe( $not, $code ) + { + $response = $this->last_response(); + if ( ( (int) $code === $response['code'] ) === (bool) $not ) { + throw new Exception( "Expected HTTP {$not}$code, got {$response['code']}.\n" . $this->describe_response( $response ) ); + } + } + + /** + * @Then /^the response body should (not )?contain "(.*)"$/ + */ + public function theResponseBodyShouldContain( $not, $text ) + { + $response = $this->last_response(); + $text = $this->v( $text ); + if ( ( false !== strpos( $response['body'], $text ) ) === (bool) $not ) { + throw new Exception( "Expected the response body {$not}to contain $text.\n" . $this->describe_response( $response ) ); + } + } + + /** + * @Then /^the response header "([^"]*)" should (not )?(be|contain) "(.*)"$/ + */ + public function theResponseHeaderShould( $header, $not, $mode, $value ) + { + $response = $this->last_response(); + $value = $this->v( $value ); + $name = strtolower( $header ); + $actual = isset( $response['headers'][ $name ] ) ? $response['headers'][ $name ] : null; + $matches = null !== $actual && ( 'be' === $mode ? $actual === $value : false !== strpos( $actual, $value ) ); + if ( $matches === (bool) $not ) { + throw new Exception( "Expected header $header {$not}to $mode $value.\n" . $this->describe_response( $response ) ); + } + } + + /** + * @Then /^the response should (not )?have the header "([^"]*)"$/ + */ + public function theResponseShouldHaveTheHeader( $not, $header ) + { + $response = $this->last_response(); + if ( isset( $response['headers'][ strtolower( $header ) ] ) === (bool) $not ) { + throw new Exception( "Expected the response {$not}to have the header $header.\n" . $this->describe_response( $response ) ); + } + } + + /** + * @Then /^a GET request to "([^"]*)" with Host "([^"]*)" should return (\d{3})$/ + */ + public function aGetRequestWithHostShouldReturn( $url, $host, $code ) + { + $response = $this->http_request( $this->v( $url ), 'GET', [ 'resolve_ip' => false, 'host_header' => $this->v( $host ) ] ); + if ( (int) $code !== $response['code'] ) { + throw new Exception( "Expected HTTP $code, got {$response['code']}.\n" . $this->describe_response( $response ) ); + } + } + + /** + * Polls instead of sleeping (proxy reloads, container restarts). + * + * @When /^I wait up to (\d+) seconds for "([^"]*)" to (not )?return (\d{3})$/ + */ + public function iWaitForUrlToReturn( $seconds, $url, $not, $code ) + { + $url = $this->v( $url ); + $deadline = time() + (int) $seconds; + do { + $this->response = $this->http_request( $url, 'GET' ); + if ( ( (int) $code === $this->response['code'] ) !== (bool) $not ) { + return; + } + sleep( 1 ); + } while ( time() < $deadline ); + throw new Exception( "$url did not {$not}return $code within $seconds seconds.\n" . $this->describe_response( $this->response ) ); + } + + /** + * @When /^I wait up to (\d+) seconds until the command "(.*)" succeeds$/ + */ + public function iWaitUntilTheCommandSucceeds( $seconds, $command ) + { + $command = $this->v( $command ); + $deadline = time() + (int) $seconds; + do { + $run = EE::launch( $command, false, true ); + if ( 0 === $run->return_code ) { + return; + } + sleep( 1 ); + } while ( time() < $deadline ); + throw new Exception( "Command did not succeed within $seconds seconds.\n" . $this->describe( $run ) ); + } + + /* ----- TLS ----- */ + + /** + * Connects to 127.0.0.1:443 with SNI set to the host. + * + * @Then /^the TLS certificate served for "([^"]*)" should (not )?cover "([^"]*)"$/ + */ + public function theTlsCertificateShouldCover( $host, $not, $domain ) + { + $cert = $this->peer_certificate( $this->v( $host ) ); + $domain = $this->v( $domain ); + $names = $this->certificate_names( $cert ); + $covered = false; + foreach ( $names as $name ) { + if ( strtolower( $name ) === strtolower( $domain ) || ( 0 === strpos( $name, '*.' ) && strtolower( substr( $name, 1 ) ) === strtolower( strstr( $domain, '.' ) ) ) ) { + $covered = true; + } + } + if ( $covered === (bool) $not ) { + throw new Exception( "Expected the certificate served for $host {$not}to cover $domain. Names: " . implode( ', ', $names ) ); + } + } + + /** + * The proxy can serve its default certificate for a moment while it regenerates after an alias change. + * + * @Then /^the TLS certificate served for "([^"]*)" should cover "([^"]*)" within (\d+) seconds$/ + */ + public function theTlsCertificateShouldCoverWithin( $host, $domain, $seconds ) + { + $deadline = time() + (int) $seconds; + while ( true ) { + try { + $this->theTlsCertificateShouldCover( $host, '', $domain ); + return; + } catch ( Exception $e ) { + if ( time() >= $deadline ) { + throw $e; + } + sleep( 2 ); + } + } + } + + /** + * @Then /^the TLS certificate served for "([^"]*)" should be issued by "(.*)"$/ + */ + public function theTlsCertificateShouldBeIssuedBy( $host, $issuer ) + { + $cert = $this->peer_certificate( $this->v( $host ) ); + $issuer = $this->v( $issuer ); + $parsed = openssl_x509_parse( $cert ); + $actual = isset( $parsed['issuer'] ) ? $parsed['issuer'] : []; + if ( false === strpos( implode( ' / ', $actual ), $issuer ) ) { + throw new Exception( "Expected the certificate for $host to be issued by $issuer, issuer is: " . json_encode( $actual ) ); + } + } + + /* ----- configuration ----- */ + + /** + * Fails fast instead of letting a command prompt for input (e.g. le-mail). + * + * @Given /^the EasyEngine config value "([^"]*)" is set$/ + */ + public function theConfigValueIsSet( $key ) + { + if ( '' === (string) $this->config_value( $key ) ) { + throw new Exception( "$key is not set in " . EE_ROOT_DIR . '/config/config.yml; this scenario needs it.' ); + } + } + + /** + * @Given /^the EasyEngine config value "([^"]*)" is (not set|"(.*)")$/ + */ + public function theConfigValueIs( $key, $what, $value = null ) + { + $actual = (string) $this->config_value( $key ); + if ( 'not set' === $what ? '' !== $actual : $actual !== $value ) { + throw new Exception( "This scenario needs $key to be $what in " . EE_ROOT_DIR . "/config/config.yml, it is '$actual'." ); + } + } + + /** + * Guard for host-wide commands (e.g. `ssl-renew --all`): refuses to run when other people's sites would be touched. + * + * @Given /^no enabled Let's Encrypt site other than this suite's exists$/ + */ + public function noForeignLetsEncryptSiteExists() + { + $sites = is_file( EE_ROOT_DIR . '/db/ee.sqlite' ) ? self::db()->query( "SELECT site_url FROM sites WHERE site_ssl = 'le' AND site_enabled = 1" )->fetchAll( PDO::FETCH_COLUMN ) : []; + $foreign = array_filter( $sites, function ( $site ) { + return ! $this->is_own_name( $site ); + } ); + if ( ! empty( $foreign ) ) { + throw new Exception( 'Refusing to run: these Let\'s Encrypt sites would be renewed too: ' . implode( ', ', $foreign ) ); + } + } + + /* ----- remote backups (rclone) ----- */ + + /** + * @Given /^there are no remote backups for site "([^"]*)"$/ + */ + public function thereAreNoRemoteBackupsForSite( $site ) + { + $site = $this->v( $site ); + $this->assert_own_name( $site ); + // List first and purge only this exact site directory, never anything else on the real remote. + if ( in_array( $site, self::remote_dirs( $this->rclone_path() ), true ) ) { + EE::launch( 'rclone purge ' . escapeshellarg( $this->rclone_path() . '/' . $site ), false, true ); + } + if ( ! empty( $this->remote_backups( $site ) ) ) { + throw new Exception( "Could not remove the remote backups of $site." ); + } + } + + /** + * @Then /^the remote backup storage should have (\d+) backups? for site "([^"]*)"$/ + */ + public function theRemoteStorageShouldHaveBackups( $count, $site ) + { + $backups = $this->remote_backups( $this->v( $site ) ); + if ( (int) $count !== count( $backups ) ) { + throw new Exception( "Expected $count remote backups for $site, found " . count( $backups ) . ":\n" . implode( "\n", $backups ) ); + } + } + + /** + * @Then /^the remote backup storage should have at most the configured "no-of-backups" backups for site "([^"]*)"$/ + */ + public function theRemoteStorageShouldRespectRetention( $site ) + { + $limit = (int) ( $this->config_value( 'no-of-backups' ) ?: 7 ); + $backups = $this->remote_backups( $this->v( $site ) ); + if ( count( $backups ) > $limit ) { + throw new Exception( "no-of-backups is $limit but " . count( $backups ) . " backups are kept for $site:\n" . implode( "\n", $backups ) ); + } + } + + /** + * @Then /^the remote backup "([^"]*)" of site "([^"]*)" should contain the file "([^"]*)"$/ + */ + public function theRemoteBackupShouldContainTheFile( $backup_id, $site, $file ) + { + $site = $this->v( $site ); + $run = EE::launch( 'rclone lsf ' . escapeshellarg( $this->remote_backup_path( $site, $this->v( $backup_id ) ) ), false, true ); + if ( ! in_array( $this->v( $file ), explode( "\n", trim( $run->stdout ) ), true ) ) { + throw new Exception( "Remote backup does not contain $file.\n" . $this->describe( $run ) ); + } + } + + /** + * Simulates a restore that died after downloading. + * + * @When /^I download the remote backup "([^"]*)" of site "([^"]*)" into the local backup directory$/ + */ + public function iDownloadTheRemoteBackup( $backup_id, $site ) + { + $site = $this->v( $site ); + $this->assert_own_name( $site ); + $run = EE::launch( sprintf( 'rclone copy %s %s', escapeshellarg( $this->remote_backup_path( $site, $this->v( $backup_id ) ) ), escapeshellarg( EE_ROOT_DIR . '/.backup/' . $site ) ), false, true ); + if ( 0 !== $run->return_code ) { + throw new Exception( "rclone copy failed.\n" . $this->describe( $run ) ); + } + } + + /* ----- helpers ----- */ + + /** + * Expands {NAME} variables. + */ + private function v( $value ) + { + if ( ! is_string( $value ) || false === strpos( $value, '{' ) ) { + return $value; + } + + return preg_replace_callback( + '/\{([A-Z][A-Z0-9_]*)\}/', + function ( $m ) { + $value = $this->variable( $m[1] ); + + return null === $value ? $m[0] : $value; + }, + $value + ); + } + + private function variable( $name ) + { + if ( array_key_exists( $name, $this->variables ) ) { + return $this->variables[ $name ]; + } + switch ( $name ) { + case 'EE': + return $this->ee_path . '/bin/ee'; + case 'EE_ROOT_DIR': + return EE_ROOT_DIR; + case 'SITES_DIR': + return EE_SITE_ROOT; + case 'PROXY_DIR': + return EE_ROOT_DIR . '/services/nginx-proxy'; + case 'BACKUP_DIR': + return EE_ROOT_DIR . '/.backup'; + case 'TMP': + return $this->tmp_dir; + case 'RUN_ID': + return self::$run_id; + case 'PUBLIC_IP': + return $this->public_ip(); + case 'RCLONE_PATH': + return $this->rclone_path(); + case 'LE_ISSUER': + // CI runs @le against Pebble, whose intermediate CN is random per start. + return getenv( 'EE_BEHAT_LE_ISSUER' ) ?: "Let's Encrypt"; + } + + return null; + } + + private function last_command() + { + if ( empty( $this->commands ) ) { + throw new Exception( 'No command has been run in this scenario.' ); + } + + return end( $this->commands ); + } + + private function strip_colors( $text ) + { + return preg_replace( '/\x1b\[[0-9;]*[A-Za-z]/', '', (string) $text ); + } + + private function stream_of( $run, $stream ) + { + return $this->strip_colors( 'STDOUT' === $stream ? $run->stdout : $run->stderr ); + } + + private function describe( $run ) + { + return sprintf( + "Command: %s\nExit code: %s\n--- STDOUT ---\n%s\n--- STDERR ---\n%s", + $run->command, + $run->return_code, + rtrim( $this->strip_colors( $run->stdout ) ), + rtrim( $this->strip_colors( $run->stderr ) ) + ); + } + + private function assert_output_contains( $stream, $not, $text ) + { + $run = $this->last_command(); + $found = false !== strpos( $this->stream_of( $run, $stream ), $text ); + if ( $found === (bool) $not ) { + throw new Exception( sprintf( "Expected %s %sto contain:\n%s\n%s", $stream, $not, $text, $this->describe( $run ) ) ); + } + } + + private function assert_file_contains( $path, $not, $text ) + { + if ( ! is_file( $path ) ) { + throw new Exception( "File $path does not exist." ); + } + $content = file_get_contents( $path ); + if ( ( false !== strpos( $content, $text ) ) === (bool) $not ) { + throw new Exception( "Expected $path {$not}to contain:\n$text\n--- content ---\n$content" ); + } + } + + private function decode_stdout_json() + { + $run = $this->last_command(); + $data = json_decode( trim( $this->stream_of( $run, 'STDOUT' ) ), true ); + if ( null === $data && JSON_ERROR_NONE !== json_last_error() ) { + throw new Exception( 'STDOUT is not valid JSON (' . json_last_error_msg() . ").\n" . $this->describe( $run ) ); + } + + return $data; + } + + private function json_path( $data, $path ) + { + foreach ( explode( '.', $path ) as $key ) { + if ( ! is_array( $data ) || ! array_key_exists( $key, $data ) ) { + throw new Exception( "JSON path $path not found in: " . json_encode( $data ) ); + } + $data = $data[ $key ]; + } + + return $data; + } + + private function is_own_name( $name ) + { + return 1 === preg_match( '/(^|\.)' . preg_quote( self::SITE_PREFIX, '/' ) . '/', strtolower( $name ) ); + } + + private function assert_own_name( $name ) + { + if ( ! $this->is_own_name( $name ) ) { + throw new Exception( "Refusing to touch $name: it does not use the suite prefix " . self::SITE_PREFIX ); + } + } + + private function assert_writable_path( $path ) + { + if ( ( $this->tmp_dir && 0 === strpos( $path, $this->tmp_dir ) ) || false !== strpos( $path, '/' . self::SITE_PREFIX ) || false !== strpos( $path, '.' . self::SITE_PREFIX ) ) { + return; + } + throw new Exception( "Refusing to modify $path: only {TMP} and paths of the suite's own sites may be changed." ); + } + + private static function db() + { + static $pdo = null; + if ( null === $pdo ) { + $pdo = new PDO( 'sqlite:' . EE_ROOT_DIR . '/db/ee.sqlite' ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + } + + return $pdo; + } + + private function get_site_row( $site ) + { + if ( ! is_file( EE_ROOT_DIR . '/db/ee.sqlite' ) ) { + return false; + } + $stmt = self::db()->prepare( 'SELECT * FROM sites WHERE site_url = ?' ); + $stmt->execute( [ $site ] ); + + return $stmt->fetch( PDO::FETCH_ASSOC ); + } + + private static function own_sites() + { + // Opening a missing SQLite file would create it. + if ( ! is_file( EE_ROOT_DIR . '/db/ee.sqlite' ) ) { + return []; + } + $sites = self::db()->query( 'SELECT site_url FROM sites' )->fetchAll( PDO::FETCH_COLUMN ); + + return array_values( array_filter( $sites, function ( $site ) { + return 1 === preg_match( '/(^|\.)' . preg_quote( self::SITE_PREFIX, '/' ) . '/', $site ); + } ) ); + } + + private function ensure_site( $site, $flags ) + { + $this->assert_own_name( $site ); + $row = $this->get_site_row( $site ); + if ( false === $row ) { + $run = EE::launch( 'bin/ee site create ' . escapeshellarg( $site ) . ' ' . $flags, false, true ); + if ( 0 !== $run->return_code || false === $this->get_site_row( $site ) ) { + throw new Exception( "Could not create $site.\n" . $this->describe( $run ) ); + } + } elseif ( ! $row['site_enabled'] ) { + $this->theSiteIs( $site, 'enabled' ); + } + } + + private function site_container_ids( $site, $service = null, $all = false ) + { + $command = 'docker ps -q' . ( $all ? ' -a' : '' ) . ' --filter ' . escapeshellarg( 'label=io.easyengine.site=' . $site ); + if ( $service ) { + $command .= ' --filter ' . escapeshellarg( 'label=com.docker.compose.service=' . $service ); + } + $run = EE::launch( $command, false, true ); + + return array_values( array_filter( explode( "\n", trim( $run->stdout ) ) ) ); + } + + private function service_started_at( $site, $service ) + { + $ids = $this->site_container_ids( $site, $service ); + if ( empty( $ids ) ) { + throw new Exception( "No running $service container for $site." ); + } + $run = EE::launch( "docker inspect -f '{{.State.StartedAt}}' " . $ids[0], false, true ); + + return trim( $run->stdout ); + } + + private function hosts_has_entry( $host ) + { + return 1 === preg_match( '/^\s*[0-9a-f.:]+\s+(.*\s)?' . preg_quote( $host, '/' ) . '(\s|$)/mi', file_get_contents( '/etc/hosts' ) ); + } + + private function remove_hosts_entry( $host ) + { + $content = file_get_contents( '/etc/hosts' ); + $updated = preg_replace( '/^127\.0\.0\.1\t' . preg_quote( $host, '/' ) . '\n/m', '', $content, 1 ); + if ( null !== $updated && $updated !== $content ) { + file_put_contents( '/etc/hosts', $updated ); + } + } + + private function public_ip() + { + $run = EE::launch( "hostname -I | awk '{print $1}'", false, true ); + + return trim( $run->stdout ); + } + + private function config_value( $key ) + { + $file = EE_ROOT_DIR . '/config/config.yml'; + if ( ! is_file( $file ) ) { + return null; + } + $config = class_exists( 'Mustangostang\Spyc' ) ? Mustangostang\Spyc::YAMLLoad( $file ) : []; + + return isset( $config[ $key ] ) ? $config[ $key ] : null; + } + + private function rclone_path() + { + return rtrim( (string) ( $this->config_value( 'rclone-path' ) ?: 'easyengine:easyengine' ), '/' ); + } + + /** + * Backup ids of a site: // (develop) and /// (per-host layout). + */ + private function remote_backups( $site ) + { + $ids = []; + foreach ( $this->remote_backup_paths( $site ) as $path ) { + $ids[] = basename( $path ); + } + + return $ids; + } + + /** + * @return string[] Remote path of each backup of $site. + */ + private function remote_backup_paths( $site ) + { + $paths = []; + $root = $this->rclone_path() . '/' . $site; + foreach ( self::list_remote_dirs( $root ) as $dir ) { + if ( self::is_backup_id( $dir ) ) { + $paths[] = "$root/$dir"; + continue; + } + foreach ( self::list_remote_dirs( "$root/$dir" ) as $id ) { + if ( self::is_backup_id( $id ) ) { + $paths[] = "$root/$dir/$id"; + } + } + } + + return $paths; + } + + private function remote_backup_path( $site, $backup_id ) + { + foreach ( $this->remote_backup_paths( $site ) as $path ) { + if ( basename( $path ) === basename( $backup_id ) ) { + return $path; + } + } + + return $this->rclone_path() . '/' . $site . '/' . $backup_id; + } + + private static function list_remote_dirs( $path ) + { + $run = EE::launch( 'rclone lsf --dirs-only ' . escapeshellarg( $path ), false, true ); + + return array_values( array_filter( array_map( function ( $line ) { + return rtrim( trim( $line ), '/' ); + }, explode( "\n", $run->stdout ) ), 'strlen' ) ); + } + + private static function is_backup_id( $name ) + { + return 1 === preg_match( '/^\d+_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/', $name ); + } + + private static function purge_own_remote_backups() + { + $context = new self(); + $base = $context->rclone_path(); + foreach ( self::remote_dirs( $base ) as $dir ) { + if ( 0 === strpos( $dir, self::SITE_PREFIX ) ) { + EE::launch( 'rclone purge ' . escapeshellarg( $base . '/' . $dir ), false, true ); + } + } + } + + /** + * Top-level directories under the rclone path that look like site names (no slashes or dot-segments). + */ + private static function remote_dirs( $base ) + { + $run = EE::launch( 'rclone lsf --dirs-only ' . escapeshellarg( $base ), false, true ); + if ( 0 !== $run->return_code ) { + return []; + } + $dirs = array_map( function ( $dir ) { + return rtrim( trim( $dir ), '/' ); + }, explode( "\n", $run->stdout ) ); + + return array_values( array_filter( $dirs, function ( $dir ) { + return 1 === preg_match( '/^[a-z0-9][a-z0-9.-]*$/', $dir ) && false === strpos( $dir, '..' ); + } ) ); + } + + /** + * Removes proxy certs/ACME state, site dirs and volumes named after this suite once none of its sites is left + * (known bugs or a failed create can leave them behind), so runs do not pile up state. + */ + private static function purge_own_leftovers() + { + if ( ! empty( self::own_sites() ) ) { + return; + } + $own = function ( $name ) { + return 1 === preg_match( '/(^|\.)' . preg_quote( self::SITE_PREFIX, '/' ) . '/', $name ); + }; + $proxy = EE_ROOT_DIR . '/services/nginx-proxy'; + foreach ( [ "$proxy/certs", "$proxy/acme-conf/certs", "$proxy/acme-conf/var", EE_SITE_ROOT ] as $dir ) { + foreach ( glob( $dir . '/*' . self::SITE_PREFIX . '*' ) ?: [] as $path ) { + if ( $own( basename( $path ) ) ) { + EE::launch( 'rm -rf ' . escapeshellarg( $path ), false, true ); + } + } + } + + $run = EE::launch( 'docker volume ls -q --filter label=org.label-schema.vendor=EasyEngine', false, true ); + foreach ( array_filter( explode( "\n", trim( $run->stdout ) ) ) as $volume ) { + $label = EE::launch( "docker volume inspect -f '{{index .Labels \"io.easyengine.site\"}}' " . escapeshellarg( $volume ), false, true ); + if ( $own( trim( $label->stdout ) ) ) { + EE::launch( 'docker volume rm ' . escapeshellarg( $volume ), false, true ); + } + } + } + + private function http_request( $url, $method = 'GET', $options = [] ) + { + $options = array_merge( [ 'resolve_ip' => '127.0.0.1', 'verify' => false, 'host_header' => null ], $options ); + $parts = parse_url( $url ); + $ch = curl_init( $url ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_HEADER, true ); + curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, false ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 30 ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, (bool) $options['verify'] ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, $options['verify'] ? 2 : 0 ); + if ( 'HEAD' === $method ) { + curl_setopt( $ch, CURLOPT_NOBODY, true ); + } + if ( $options['resolve_ip'] && ! empty( $parts['host'] ) ) { + $port = isset( $parts['port'] ) ? $parts['port'] : ( 'https' === $parts['scheme'] ? 443 : 80 ); + curl_setopt( $ch, CURLOPT_RESOLVE, [ $parts['host'] . ':' . $port . ':' . $options['resolve_ip'] ] ); + } + if ( $options['host_header'] ) { + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ 'Host: ' . $options['host_header'] ] ); + } + $raw = curl_exec( $ch ); + $error = curl_error( $ch ); + $code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $header_size = (int) curl_getinfo( $ch, CURLINFO_HEADER_SIZE ); + curl_close( $ch ); + + $raw_headers = false === $raw ? '' : substr( $raw, 0, $header_size ); + $headers = []; + foreach ( explode( "\n", $raw_headers ) as $line ) { + if ( false !== strpos( $line, ':' ) ) { + list( $name, $value ) = explode( ':', $line, 2 ); + $headers[ strtolower( trim( $name ) ) ] = trim( $value ); + } + } + + return [ + 'url' => $url, + 'code' => $code, + 'headers' => $headers, + 'raw_headers' => $raw_headers, + 'body' => false === $raw ? '' : substr( $raw, $header_size ), + 'error' => $error, + ]; + } + + private function last_response() + { + if ( null === $this->response ) { + throw new Exception( 'No HTTP request has been sent in this scenario.' ); + } + + return $this->response; + } + + private function describe_response( $response ) + { + return sprintf( "URL: %s\nError: %s\n--- headers ---\n%s--- body (first 500 bytes) ---\n%s", $response['url'], $response['error'], $response['raw_headers'], substr( $response['body'], 0, 500 ) ); + } + + private function peer_certificate( $host ) + { + $context = stream_context_create( [ + 'ssl' => [ + 'capture_peer_cert' => true, + 'verify_peer' => false, + 'verify_peer_name' => false, + 'peer_name' => $host, + 'SNI_enabled' => true, + ], + ] ); + $socket = @stream_socket_client( 'ssl://127.0.0.1:443', $errno, $errstr, 15, STREAM_CLIENT_CONNECT, $context ); + if ( ! $socket ) { + throw new Exception( "TLS connection for $host failed: $errstr ($errno)" ); + } + $params = stream_context_get_params( $socket ); + fclose( $socket ); + + return $params['options']['ssl']['peer_certificate']; + } + + private function certificate_names( $cert ) + { + $parsed = openssl_x509_parse( $cert ); + $names = []; + if ( ! empty( $parsed['extensions']['subjectAltName'] ) ) { + foreach ( explode( ',', $parsed['extensions']['subjectAltName'] ) as $san ) { + $san = trim( $san ); + if ( 0 === strpos( $san, 'DNS:' ) ) { + $names[] = substr( $san, 4 ); + } + } + } + // Clients ignore the Common Name when DNS SANs are present (RFC 6125). + if ( ! $names && ! empty( $parsed['subject']['CN'] ) ) { + $names[] = $parsed['subject']['CN']; + } + + return array_values( array_unique( $names ) ); + } } diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php index ed9a5b56..a5f1a1e6 100644 --- a/features/bootstrap/support.php +++ b/features/bootstrap/support.php @@ -81,13 +81,27 @@ function compareContents( $expected, $actual ) { if ( is_object( $expected ) ) { foreach ( get_object_vars( $expected ) as $name => $value ) { - if ( ! compareContents( $value, $actual->$name ) ) { + if ( ! property_exists( $actual, $name ) || ! compareContents( $value, $actual->$name ) ) { + return false; + } + } + } else if ( is_array( $expected ) && array_values( $expected ) === $expected ) { + // Lists match by membership, so [1,3,5] is contained in [1,2,3,4,5]. + foreach ( $expected as $value ) { + $found = false; + foreach ( $actual as $candidate ) { + if ( compareContents( $value, $candidate ) ) { + $found = true; + break; + } + } + if ( ! $found ) { return false; } } } else if ( is_array( $expected ) ) { foreach ( $expected as $key => $value ) { - if ( ! compareContents( $value, $actual[$key] ) ) { + if ( ! array_key_exists( $key, $actual ) || ! compareContents( $value, $actual[ $key ] ) ) { return false; } } diff --git a/features/clean.feature b/features/clean.feature new file mode 100644 index 00000000..b56de51b --- /dev/null +++ b/features/clean.feature @@ -0,0 +1,47 @@ +Feature: Clean site caches + `ee site clean` on html sites: object/page caches never exist for html, proxy cache only when enabled. + + Scenario: Cleaning all caches of a plain html site fails + Given the html site "bh-site-command-88.test" exists + When I run 'bin/ee site clean bh-site-command-88.test' + Then the command should fail + And STDERR should contain "Site object cache is not enabled. Site page cache is not enabled." + + Scenario: Cleaning the object cache of an html site fails + Given the html site "bh-site-command-88.test" exists + When I run 'bin/ee site clean bh-site-command-88.test --object' + Then the command should fail + And STDERR should contain "Site object cache is not enabled." + And STDERR should not contain "page cache" + + Scenario: Cleaning the page cache of an html site fails + Given the html site "bh-site-command-88.test" exists + When I run 'bin/ee site clean bh-site-command-88.test --page' + Then the command should fail + And STDERR should contain "Site page cache is not enabled." + And STDERR should not contain "object cache" + + Scenario: Cleaning the proxy cache when it is off fails + Given the html site "bh-site-command-88.test" exists + When I run 'bin/ee site clean bh-site-command-88.test --proxy' + Then the command should fail + And STDERR should contain "Proxy cache is not enabled on site." + + # Regression check (fixed in v4.13.2 by site-command 276d118): the proxy purge used `docker exec -it` and ignored the exit code (no TTY: failed silently; nested ptys: could hang), hence the timeout. + @destructive + Scenario: Cleaning the proxy cache empties it + Given the html site "bh-site-command-89.test" exists + When I run 'bin/ee site update bh-site-command-89.test --proxy-cache=on' + Then the command should succeed + When I wait up to 60 seconds for "http://bh-site-command-89.test/" to return 200 + And I send a GET request to "http://bh-site-command-89.test/" + And I wait up to 30 seconds until the command "docker exec services_global-nginx-proxy_1 sh -c 'find /var/cache/nginx/bh-site-command-89.test -type f | grep -q .'" succeeds + And I run 'bin/ee site clean bh-site-command-89.test --proxy' with a timeout of 120 seconds + Then the command should succeed + And STDOUT should contain "Proxy cache cleared for bh-site-command-89.test" + When I run "docker exec services_global-nginx-proxy_1 sh -c 'find /var/cache/nginx/bh-site-command-89.test -type f | wc -l'" + Then STDOUT should be exactly: + """ + 0 + """ + And the docker container "services_global-nginx-proxy_1" should be running diff --git a/features/clone-sync.feature b/features/clone-sync.feature new file mode 100644 index 00000000..d3f02d6d --- /dev/null +++ b/features/clone-sync.feature @@ -0,0 +1,194 @@ +Feature: Clone and sync sites + `ee site clone` / `ee site sync` on the same host. The commands shell out to `ee` from PATH (not bin/ee), so PATH must point at the same install. + Remote (user@host:site) transfers need a second SSH-reachable EE host and are not covered. + + Background: + Given "ee" is installed + + Scenario: Source and destination cannot both be implicit + When I run 'bin/ee site clone . .' + Then the command should fail + And STDERR should contain "No sitename found in source and destination site." + + Scenario: Source and destination cannot both be remote + When I run 'bin/ee site clone root@bh-host-a:bh-site-command-150.test root@bh-host-b:bh-site-command-151.test' + Then the command should fail + And STDERR should contain "Both source and destination sites cannot be remote." + + Scenario: A site cannot be cloned onto itself + When I run 'bin/ee site clone bh-site-command-150.test bh-site-command-150.test' + Then the command should fail + And STDERR should contain "Cannot copy 'bh-site-command-150.test' on 'localhost' to 'bh-site-command-150.test' on 'localhost'" + + Scenario: A malformed remote location is rejected + When I run 'bin/ee site clone root@bh-site-command-150.test bh-site-command-151.test' + Then the command should fail + And STDERR should contain "Invalid format for remote site. Please use [user@ssh-hostname:] sitename" + + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): the same-site guard only knows the literal "localhost", so a loopback address syncs a site onto itself. + Scenario: A site cannot be synced onto itself through a loopback address + When I run 'bin/ee site sync bh-site-command-150.test root@127.0.0.1:bh-site-command-150.test' + Then the command should fail + And STDERR should contain "Cannot copy 'bh-site-command-150.test' on 'localhost' to 'bh-site-command-150.test' on '127.0.0.1'" + + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): the host part accepts spaces, so ssh options pass through. + Scenario: ssh options in a remote location are rejected + When I run "bin/ee site clone 'root@bh-host-a -oProxyCommand=true:bh-site-command-150.test' bh-site-command-151.test" + Then the command should fail + And STDERR should contain "Invalid format for remote site. Please use [user@ssh-hostname:] sitename" + + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): `user@host:.` gives the site name "." instead of "same name as the other side". + Scenario: A remote dot means the name of the other side + When I run 'bin/ee site clone root@bh-host-a:. .' + Then the command should fail + And STDERR should contain "No sitename found in source and destination site." + + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): site names go into shell commands unchecked. + Scenario: A site name with shell syntax is rejected + When I run "bin/ee site clone 'bh-site-command-150.test;true' bh-site-command-151.test" + Then the command should fail + And STDERR should contain "Invalid site name: bh-site-command-150.test;true" + + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): the sync help shows `--ssl=le --wildcard`, which sync doesn't accept. + Scenario: The sync help has no SSL example + When I run 'bin/ee help site sync' + Then the command should succeed + And STDOUT should not contain "--wildcard" + + # With no site on the host the error is "Unable to get site list" instead, so keep one site around. + Scenario: Cloning a site that does not exist fails + Given the html site "bh-site-command-154.test" exists + And the site "bh-site-command-159.test" does not exist + And the site "bh-site-command-151.test" does not exist + When I run 'bin/ee site clone bh-site-command-159.test bh-site-command-151.test' + Then the command should fail + And STDERR should contain "Unable to find 'bh-site-command-159.test' on 'localhost'" + And the site "bh-site-command-151.test" should not exist + + Scenario: Cloning a disabled site fails + Given the html site "bh-site-command-152.test" exists + And the site "bh-site-command-152.test" is disabled + When I run 'bin/ee site clone bh-site-command-152.test bh-site-command-153.test' + Then the command should fail + And STDERR should contain "The site that you want to clone has been disabled. Please enable the site and clone again." + And the site "bh-site-command-153.test" should not exist + + Scenario: Only WordPress sites can be cloned + Given the html site "bh-site-command-154.test" exists + And the site "bh-site-command-155.test" does not exist + When I run 'bin/ee site clone bh-site-command-154.test bh-site-command-155.test' + Then the command should fail + And STDERR should contain "Only clone of WordPress sites is supported as of now." + And the site "bh-site-command-155.test" should not exist + + Scenario: Syncing to a site that does not exist fails + Given the html site "bh-site-command-154.test" exists + And the site "bh-site-command-159.test" does not exist + When I run 'bin/ee site sync bh-site-command-154.test bh-site-command-159.test' + Then the command should fail + And STDERR should contain "Unable to find 'bh-site-command-159.test' on 'localhost'" + + Scenario: Only WordPress sites can be synced + Given the html site "bh-site-command-154.test" exists + And the html site "bh-site-command-156.test" exists + When I run 'bin/ee site sync bh-site-command-154.test bh-site-command-156.test' + Then the command should fail + And STDERR should contain "Only Sync of WordPress sites is supported as of now." + + @wp + Scenario: Clone a WordPress site on the same host + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-158.test" does not exist + And I write "bh-site-command-clone-marker" to the file "{SITES_DIR}/bh-site-command-157.test/app/htdocs/bh-marker.txt" + When I run "bin/ee shell bh-site-command-157.test --skip-tty --command='wp option update blogname bh-site-command-clone-source'" + Then the command should succeed + When I run 'bin/ee site clone bh-site-command-157.test bh-site-command-158.test' + Then the command should succeed + And STDOUT should contain "Site cloned successfully." + And the site "bh-site-command-158.test" should exist + And the "site_type" field of site "bh-site-command-158.test" in the database should be "wp" + And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/bh-marker.txt" should contain "bh-site-command-clone-marker" + When I run "bin/ee shell bh-site-command-158.test --skip-tty --command='wp option get blogname'" + Then STDOUT should contain "bh-site-command-clone-source" + When I run "bin/ee shell bh-site-command-158.test --skip-tty --command='wp option get siteurl'" + Then STDOUT should contain "bh-site-command-158.test" + When I wait up to 60 seconds for "http://bh-site-command-158.test/" to return 200 + + @wp + Scenario: Cloning onto an existing site fails and keeps that site + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-158.test" exists, created with "--type=wp" + When I run 'bin/ee site clone bh-site-command-157.test bh-site-command-158.test' + Then the command should fail + And STDERR should contain "already exists on 'localhost'" + And the site "bh-site-command-158.test" should exist + And the site "bh-site-command-158.test" should be enabled + + @wp + Scenario: Sync only files + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-158.test" exists, created with "--type=wp" + And I write "bh-site-command-files-v2" to the file "{SITES_DIR}/bh-site-command-157.test/app/htdocs/bh-marker.txt" + When I run "bin/ee shell bh-site-command-157.test --skip-tty --command='wp option update blogname bh-site-command-files-only'" + And I run 'bin/ee site sync bh-site-command-157.test bh-site-command-158.test --files' + Then the command should succeed + And STDOUT should contain "Site synced successfully" + And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/bh-marker.txt" should contain "bh-site-command-files-v2" + When I run "bin/ee shell bh-site-command-158.test --skip-tty --command='wp option get blogname'" + Then STDOUT should not contain "bh-site-command-files-only" + + @wp + Scenario: Sync only the database + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-158.test" exists, created with "--type=wp" + And I write "bh-site-command-db-only-file" to the file "{SITES_DIR}/bh-site-command-157.test/app/htdocs/bh-db-only.txt" + When I run "bin/ee shell bh-site-command-157.test --skip-tty --command='wp option update blogname bh-site-command-db-v2'" + And I run 'bin/ee site sync bh-site-command-157.test bh-site-command-158.test --db' + Then the command should succeed + And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/bh-db-only.txt" should not exist + When I run "bin/ee shell bh-site-command-158.test --skip-tty --command='wp option get blogname'" + Then STDOUT should contain "bh-site-command-db-v2" + When I run "bin/ee shell bh-site-command-158.test --skip-tty --command='wp option get home'" + Then STDOUT should contain "bh-site-command-158.test" + + @wp + Scenario: Sync only uploads + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-158.test" exists, created with "--type=wp" + And I write "bh-site-command-upload" to the file "{SITES_DIR}/bh-site-command-157.test/app/htdocs/wp-content/uploads/bh-upload.txt" + And I write "bh-site-command-not-an-upload" to the file "{SITES_DIR}/bh-site-command-157.test/app/htdocs/bh-not-upload.txt" + When I run 'bin/ee site sync bh-site-command-157.test bh-site-command-158.test --uploads' + Then the command should succeed + And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/wp-content/uploads/bh-upload.txt" should contain "bh-site-command-upload" + And the file "{SITES_DIR}/bh-site-command-158.test/app/htdocs/bh-not-upload.txt" should not exist + + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): `?? false && … ?? false` precedence lets a missing --ssl-crt through to is_file(null). + @wp + Scenario: Cloning with --ssl=custom needs both certificate files + Given the site "bh-site-command-157.test" exists, created with "--type=wp" + And the site "bh-site-command-170.test" does not exist + When I run 'bin/ee site clone bh-site-command-157.test bh-site-command-170.test --ssl=custom --ssl-key=/etc/hostname' + Then the command should fail + And STDERR should contain "You need to specify --ssl-crt and --ssl-key with --ssl=custom" + And STDERR should not contain "is_file" + And the site "bh-site-command-170.test" should not exist + + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): the admin password goes unquoted into `ee site create`, so a space, ; or & breaks the create. + @wp + Scenario: Clone copies an admin password with spaces and shell operators + Given the site "bh-site-command-171.test" exists, created with "--type=wp --admin-pass='bh pw;1&x'" + And the site "bh-site-command-172.test" does not exist + When I run 'bin/ee site clone bh-site-command-171.test bh-site-command-172.test' + Then the command should succeed + And STDOUT should contain "Site cloned successfully." + And the "app_admin_password" field of site "bh-site-command-172.test" in the database should be "bh pw;1&x" + + # Regression check (fixed in v4.13.2, EasyEngine/site-command#510): values with $ or quotes would be expanded by site-type-wp's double-quoted `bash -c`, so clone leaves them out. + @wp + Scenario: Clone does not pass an admin password the destination shell would expand + Given the site "bh-site-command-174.test" exists, created with "--type=wp --admin-pass='bh$pw1xyz'" + And the site "bh-site-command-173.test" does not exist + When I run 'bin/ee site clone bh-site-command-174.test bh-site-command-173.test' + Then the command should succeed + And STDERR should contain "Not copying --admin-pass of bh-site-command-174.test" + And the "app_admin_password" field of site "bh-site-command-173.test" in the database should not be "bh$pw1xyz" diff --git a/features/create.feature b/features/create.feature new file mode 100644 index 00000000..e06e9fd7 --- /dev/null +++ b/features/create.feature @@ -0,0 +1,150 @@ +Feature: Create html sites + `ee site create` for the html site type: files, containers, volumes, network, hosts entry, DB row and HTTP. + + Scenario: Create an html site + Given the site "bh-site-command-10.test" does not exist + When I run 'bin/ee site create bh-site-command-10.test --type=html' + Then the command should succeed + And STDOUT should contain "Configuration files copied." + And STDOUT should contain "Site entry created." + And STDOUT should contain "http://bh-site-command-10.test" + And the site "bh-site-command-10.test" should exist + And the site "bh-site-command-10.test" should be enabled + And the "site_type" field of site "bh-site-command-10.test" in the database should be "html" + And the "site_ssl" field of site "bh-site-command-10.test" in the database should be "" + And the "alias_domains" field of site "bh-site-command-10.test" in the database should be "bh-site-command-10.test" + And the "site_fs_path" field of site "bh-site-command-10.test" in the database should be "{SITES_DIR}/bh-site-command-10.test" + And the "site_container_fs_path" field of site "bh-site-command-10.test" in the database should be "/var/www/htdocs" + And the "proxy_cache" field of site "bh-site-command-10.test" in the database should be "off" + And the file "{SITES_DIR}/bh-site-command-10.test/docker-compose.yml" should contain "io.easyengine.site=${VIRTUAL_HOST}" + And the file "{SITES_DIR}/bh-site-command-10.test/.env" should contain "VIRTUAL_HOST=bh-site-command-10.test" + And the file "{SITES_DIR}/bh-site-command-10.test/config/nginx/conf.d/main.conf" should contain "server_name bh-site-command-10.test;" + And the file "{SITES_DIR}/bh-site-command-10.test/config/nginx/conf.d/main.conf" should contain "root /var/www/htdocs;" + And the file "{SITES_DIR}/bh-site-command-10.test/config/nginx/custom/user.conf" should exist + And the file "{SITES_DIR}/bh-site-command-10.test/app/htdocs/index.html" should contain "Your html site with EasyEngine" + And the service "nginx" of site "bh-site-command-10.test" should be running + And the service "nginx" of site "bh-site-command-10.test" should have the environment variable "HTTPS_METHOD" containing "nohttps" + And the site "bh-site-command-10.test" should have 3 docker volumes + And the docker network "bh-site-command-10.test" should exist + And the hosts file should contain an entry for "bh-site-command-10.test" + When I send a GET request to "http://bh-site-command-10.test/" + Then the response code should be 200 + And the response body should contain "Your html site with EasyEngine" + + # EasyEngine/site-command#503: volume names drop "." and "-" and the compose project drops ".", so both names map to the volumes + # bhsitecommand160xtest_* and the compose project bh-site-command-160xtest. The second create is refused. + Scenario: A site whose docker volumes and compose project collide with an existing site is refused + Given the html site "bh-site-command-160.x.test" exists + When I run 'bin/ee site create bh-site-command-160x.test --type=html' + Then the command should fail + And STDERR should contain "Site bh-site-command-160x.test would share docker volumes (bhsitecommand160xtest_*) with the existing site bh-site-command-160.x.test." + And the site "bh-site-command-160x.test" should not exist + And the site "bh-site-command-160.x.test" should have 3 docker volumes + And the service "nginx" of site "bh-site-command-160.x.test" should be running + When I send a GET request to "http://bh-site-command-160.x.test/" + Then the response code should be 200 + + Scenario: The old site assertions still work on a created site + Given the html site "bh-site-command-10.test" exists + Then The site 'bh-site-command-10.test' should have webroot + And Request on 'bh-site-command-10.test' should contain following headers: + | header | + | HTTP/1.1 200 OK | + + Scenario: Creating a site that already exists fails and keeps the site + Given the html site "bh-site-command-10.test" exists + When I run 'bin/ee site create bh-site-command-10.test --type=html' + Then the command should fail + And STDERR should contain "Site bh-site-command-10.test already exists." + And the site "bh-site-command-10.test" should be enabled + When I send a GET request to "http://bh-site-command-10.test/" + Then the response code should be 200 + + Scenario: A site name without a dot gets the .test TLD + Given the site "bh-site-command-11.test" does not exist + When I run 'bin/ee site create bh-site-command-11' + Then the command should succeed + And the site "bh-site-command-11.test" should exist + And the site "bh-site-command-11" should not exist + + Scenario: The URL scheme and a trailing slash are stripped from the site name + Given the site "bh-site-command-12.test" does not exist + When I run 'bin/ee site create http://bh-site-command-12.test/' + Then the command should succeed + And the site "bh-site-command-12.test" should exist + And the directory "{SITES_DIR}/bh-site-command-12.test" should exist + + Scenario: Upper-case site names are lower-cased + Given the site "bh-site-command-13.test" does not exist + When I run 'bin/ee site create BH-SITE-COMMAND-13.TEST' + Then the command should succeed + And the site "bh-site-command-13.test" should exist + And the directory "{SITES_DIR}/bh-site-command-13.test" should exist + + Scenario: Create a site with a custom public directory + Given the site "bh-site-command-14.test" does not exist + When I run 'bin/ee site create bh-site-command-14.test --public-dir=public' + Then the command should succeed + And the "site_container_fs_path" field of site "bh-site-command-14.test" in the database should be "/var/www/htdocs/public" + And the file "{SITES_DIR}/bh-site-command-14.test/app/htdocs/public/index.html" should exist + And the file "{SITES_DIR}/bh-site-command-14.test/config/nginx/conf.d/main.conf" should contain "root /var/www/htdocs/public;" + When I send a GET request to "http://bh-site-command-14.test/" + Then the response code should be 200 + And the response body should contain "Your html site with EasyEngine" + + Scenario: Create a site without the status check + Given the site "bh-site-command-15.test" does not exist + When I run 'bin/ee site create bh-site-command-15.test --skip-status-check' + Then the command should succeed + And STDOUT should not contain "Checking and verifying site-up status" + And the site "bh-site-command-15.test" should exist + When I wait up to 60 seconds for "http://bh-site-command-15.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + + Scenario: Create a site with alias domains + Given the site "bh-site-command-16.test" does not exist + When I run 'bin/ee site create bh-site-command-16.test --alias-domains=a.bh-site-command-16.test,bh-site-command-16-alias.test' + Then the command should succeed + And STDOUT should contain "a.bh-site-command-16.test,bh-site-command-16-alias.test" + And the "alias_domains" field of site "bh-site-command-16.test" in the database should be "bh-site-command-16.test,a.bh-site-command-16.test,bh-site-command-16-alias.test" + And the file "{SITES_DIR}/bh-site-command-16.test/config/nginx/conf.d/main.conf" should contain "server_name bh-site-command-16.test a.bh-site-command-16.test bh-site-command-16-alias.test;" + And the service "nginx" of site "bh-site-command-16.test" should have the environment variable "VIRTUAL_HOST" containing "bh-site-command-16.test,a.bh-site-command-16.test,bh-site-command-16-alias.test" + When I send a GET request to "http://bh-site-command-16-alias.test/" + Then the response code should be 200 + And the response body should contain "Your html site with EasyEngine" + When I send a GET request to "http://a.bh-site-command-16.test/" + Then the response code should be 200 + + Scenario: A domain that is an alias of another site cannot be created as a site + Given the site "bh-site-command-16.test" exists, created with "--type=html --alias-domains=a.bh-site-command-16.test,bh-site-command-16-alias.test" + When I run 'bin/ee site create bh-site-command-16-alias.test' + Then the command should fail + And STDERR should contain "Site bh-site-command-16-alias.test already exists as an alias domain for site: bh-site-command-16.test." + And the site "bh-site-command-16-alias.test" should not exist + + Scenario: An alias domain that belongs to another site is rejected on create + Given the site "bh-site-command-16.test" exists, created with "--type=html --alias-domains=a.bh-site-command-16.test,bh-site-command-16-alias.test" + And the site "bh-site-command-17.test" does not exist + When I run 'bin/ee site create bh-site-command-17.test --alias-domains=bh-site-command-16-alias.test' + Then the command should fail + And STDERR should contain "Site bh-site-command-16-alias.test already exists as an alias domain for site: bh-site-command-16.test." + And the site "bh-site-command-17.test" should not exist + And the directory "{SITES_DIR}/bh-site-command-17.test" should not exist + + Scenario: The EE v3 --html flag still selects the html type + Given the site "bh-site-command-18.test" does not exist + When I run 'bin/ee site create bh-site-command-18.test --html' + Then the command should succeed + And the "site_type" field of site "bh-site-command-18.test" in the database should be "html" + + # Regression check (fixed in v4.13.2, EasyEngine/site-command#509): site names are not validated, and unescaped shell sinks run before the create fails: the ";" splits + # `docker volume create ... bhsitecommand22;idtest_htdocs`, creating a volume "bhsitecommand22" and running "idtest_htdocs". + Scenario: Site names that are not valid hostnames are rejected before anything runs + Given the site "bh-site-command-22;id.test" does not exist + When I run "bin/ee site create 'bh-site-command-22;id.test'" + Then the command should fail + And STDERR should contain "Invalid site name" + And the site "bh-site-command-22;id.test" should not exist + And the directory "{SITES_DIR}/bh-site-command-22;id.test" should not exist + And the site "bh-site-command-22;id.test" should have 0 docker volumes + And the docker volume "bhsitecommand22" should not exist diff --git a/features/delete.feature b/features/delete.feature new file mode 100644 index 00000000..9c6b11e8 --- /dev/null +++ b/features/delete.feature @@ -0,0 +1,75 @@ +Feature: Delete sites + `ee site delete` removes containers, volumes, network, files, hosts entry, proxy config and the DB row. + + Scenario: Delete an html site and everything that belongs to it + Given the html site "bh-site-command-30.test" exists + When I run 'bin/ee site delete bh-site-command-30.test --yes' + Then the command should succeed + And STDOUT should contain "Site bh-site-command-30.test deleted." + And STDERR should be empty + And the site "bh-site-command-30.test" should not exist + And The 'bh-site-command-30.test' db entry should be removed + And The 'bh-site-command-30.test' webroot should be removed + And Following containers of site 'bh-site-command-30.test' should be removed: + | container | + | nginx | + And the site "bh-site-command-30.test" should have no containers + And the site "bh-site-command-30.test" should have 0 docker volumes + And the docker network "bh-site-command-30.test" should not exist + And the hosts file should not contain an entry for "bh-site-command-30.test" + And the file "{PROXY_DIR}/conf.d/bh-site-command-30.test-redirect.conf" should not exist + When I run 'bin/ee site delete bh-site-command-30.test --yes' + Then the command should fail + And STDERR should contain "Site bh-site-command-30.test does not exist." + + Scenario: Answering no at the prompt keeps the site + Given the html site "bh-site-command-31.test" exists + When I run 'echo n | bin/ee site delete bh-site-command-31.test' + Then the command should succeed + And STDOUT should contain "Are you sure you want to delete bh-site-command-31.test? [y/n]" + And the site "bh-site-command-31.test" should exist + And the service "nginx" of site "bh-site-command-31.test" should be running + + Scenario: Answering yes at the prompt deletes the site + Given the html site "bh-site-command-31.test" exists + When I run 'echo y | bin/ee site delete bh-site-command-31.test' + Then the command should succeed + And STDOUT should contain "Site bh-site-command-31.test deleted." + And the site "bh-site-command-31.test" should not exist + + Scenario: A disabled site can be deleted + Given the html site "bh-site-command-32.test" exists + And the site "bh-site-command-32.test" is disabled + When I run 'bin/ee site delete bh-site-command-32.test --yes' + Then the command should succeed + And the site "bh-site-command-32.test" should not exist + And the directory "{SITES_DIR}/bh-site-command-32.test" should not exist + And the site "bh-site-command-32.test" should have 0 docker volumes + + Scenario: Deleting a self-signed site removes its certificate + Given the site "bh-site-command-33.test" exists, created with "--type=html --ssl=self" + And the file "{PROXY_DIR}/certs/bh-site-command-33.test.crt" should exist + When I run 'bin/ee site delete bh-site-command-33.test --yes' + Then the command should succeed + And STDOUT should contain "Removing ssl certs and other config files." + And the file "{PROXY_DIR}/certs/bh-site-command-33.test.crt" should not exist + And the file "{PROXY_DIR}/certs/bh-site-command-33.test.key" should not exist + + # Delete removes the site's certificate files even when site_ssl is empty (SSL turned off or lost), since nginx-proxy keeps matching them. + Scenario: Deleting a site without SSL removes a leftover certificate + Given the html site "bh-site-command-161.test" exists + When I write "leftover" to the file "{PROXY_DIR}/certs/bh-site-command-161.test.crt" + And I write "leftover" to the file "{PROXY_DIR}/certs/bh-site-command-161.test.key" + And I run 'bin/ee site delete bh-site-command-161.test --yes' + Then the command should succeed + And the file "{PROXY_DIR}/certs/bh-site-command-161.test.crt" should not exist + And the file "{PROXY_DIR}/certs/bh-site-command-161.test.key" should not exist + + Scenario: Deleting a site frees its alias domains + Given the site "bh-site-command-34.test" exists, created with "--type=html --alias-domains=bh-site-command-34-alias.test" + And the site "bh-site-command-34-alias.test" does not exist + When I run 'bin/ee site delete bh-site-command-34.test --yes' + Then the command should succeed + When I run 'bin/ee site create bh-site-command-34-alias.test' + Then the command should succeed + And the site "bh-site-command-34-alias.test" should exist diff --git a/features/enable-disable.feature b/features/enable-disable.feature new file mode 100644 index 00000000..6d06b9e1 --- /dev/null +++ b/features/enable-disable.feature @@ -0,0 +1,94 @@ +Feature: Enable, disable and refresh sites + `ee site disable` stops and removes the containers, `ee site enable` brings them back, `--refresh`/`refresh` regenerate docker-compose.yml. + + Scenario: Disable a site + Given the html site "bh-site-command-50.test" exists + When I run 'bin/ee site disable bh-site-command-50.test' + Then the command should succeed + And STDOUT should contain "Disabling site bh-site-command-50.test." + And STDOUT should contain "Site bh-site-command-50.test disabled." + And the site "bh-site-command-50.test" should be disabled + And the site "bh-site-command-50.test" should have no containers + And the site "bh-site-command-50.test" should have 3 docker volumes + And the directory "{SITES_DIR}/bh-site-command-50.test/app/htdocs" should exist + When I wait up to 60 seconds for "http://bh-site-command-50.test/" to not return 200 + Then the response code should not be 200 + + Scenario: Disabling a disabled site is harmless + Given the html site "bh-site-command-50.test" exists + And the site "bh-site-command-50.test" is disabled + When I run 'bin/ee site disable bh-site-command-50.test' + Then the command should succeed + And STDOUT should contain "Site bh-site-command-50.test disabled." + And the site "bh-site-command-50.test" should be disabled + + Scenario Outline: Subcommands that need a running site refuse a disabled one + Given the html site "bh-site-command-51.test" exists + And the site "bh-site-command-51.test" is disabled + When I run 'bin/ee site bh-site-command-51.test ' + Then the command should fail + And STDERR should contain "Site bh-site-command-51.test is not enabled. Use `ee site enable bh-site-command-51.test` to enable it." + + Examples: + | command | flags | + | restart | | + | reload | | + | update | --ssl=self | + | refresh | | + | share | | + | backup | | + | restore | | + + Scenario: Enable a disabled site + Given the html site "bh-site-command-52.test" exists + And the site "bh-site-command-52.test" is disabled + When I run 'bin/ee site enable bh-site-command-52.test' + Then the command should succeed + And STDOUT should contain "Enabling site bh-site-command-52.test." + And STDOUT should contain "Site bh-site-command-52.test enabled." + And STDOUT should contain "Post enable configurations complete." + And the site "bh-site-command-52.test" should be enabled + And the service "nginx" of site "bh-site-command-52.test" should be running + When I wait up to 60 seconds for "http://bh-site-command-52.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + + Scenario: Enabling an enabled site fails + Given the html site "bh-site-command-52.test" exists + When I run 'bin/ee site enable bh-site-command-52.test' + Then the command should fail + And STDERR should contain "bh-site-command-52.test is already enabled!" + + Scenario: Force-enable an enabled site + Given the html site "bh-site-command-52.test" exists + When I run 'bin/ee site enable bh-site-command-52.test --force' + Then the command should succeed + And STDOUT should contain "Site bh-site-command-52.test enabled." + And the service "nginx" of site "bh-site-command-52.test" should be running + + Scenario: Enable with --refresh regenerates docker-compose.yml + Given the html site "bh-site-command-53.test" exists + When I append "# bh-site-command-marker" to the file "{SITES_DIR}/bh-site-command-53.test/docker-compose.yml" + And I run 'bin/ee site enable bh-site-command-53.test --refresh' + Then the command should succeed + And the file "{SITES_DIR}/bh-site-command-53.test/docker-compose.yml" should not contain "# bh-site-command-marker" + And the file "{SITES_DIR}/bh-site-command-53.test/docker-compose.yml" should contain "HTTPS_METHOD=nohttps" + And the service "nginx" of site "bh-site-command-53.test" should be running + When I wait up to 60 seconds for "http://bh-site-command-53.test/" to return 200 + + Scenario: refresh is a shortcut for enable --refresh + Given the html site "bh-site-command-53.test" exists + When I append "# bh-site-command-marker" to the file "{SITES_DIR}/bh-site-command-53.test/docker-compose.yml" + And I run 'bin/ee site refresh bh-site-command-53.test' + Then the command should succeed + And STDOUT should contain "Site bh-site-command-53.test enabled." + And the file "{SITES_DIR}/bh-site-command-53.test/docker-compose.yml" should not contain "# bh-site-command-marker" + When I wait up to 60 seconds for "http://bh-site-command-53.test/" to return 200 + + Scenario: Enable with verification of the global services + Given the html site "bh-site-command-54.test" exists + And the site "bh-site-command-54.test" is disabled + When I run 'bin/ee site enable bh-site-command-54.test --verify' + Then the command should succeed + And the site "bh-site-command-54.test" should be enabled + And the docker container "services_global-nginx-proxy_1" should be running + When I wait up to 60 seconds for "http://bh-site-command-54.test/" to return 200 diff --git a/features/info.feature b/features/info.feature new file mode 100644 index 00000000..cf2b7588 --- /dev/null +++ b/features/info.feature @@ -0,0 +1,51 @@ +Feature: Site info + `ee site info` table and JSON output, site auto-detection from the working directory. + + Background: + Given the html site "bh-site-command-45.test" exists + + Scenario: Show site info as a table + When I run 'bin/ee site info bh-site-command-45.test' + Then the command should succeed + And STDOUT should match "/\|\s*Site\s*\|\s*http:\/\/bh-site-command-45\.test\s*\|/" + And STDOUT should match "/\|\s*Site Root\s*\|\s*\/opt\/easyengine\/sites\/bh-site-command-45\.test\s*\|/" + And STDOUT should match "/\|\s*Alias Domains\s*\|\s*None\s*\|/" + And STDOUT should match "/\|\s*SSL\s*\|\s*Not Enabled\s*\|/" + And STDOUT should not contain "SSL Wildcard" + + Scenario: Show site info as JSON + When I run 'bin/ee site info bh-site-command-45.test --format=json' + Then the command should succeed + And STDOUT should be valid JSON + And the JSON field "site_url" in STDOUT should be "bh-site-command-45.test" + And the JSON field "site_type" in STDOUT should be "html" + And the JSON field "site_enabled" in STDOUT should be "1" + And the JSON field "site_fs_path" in STDOUT should be "{SITES_DIR}/bh-site-command-45.test" + And the JSON field "alias_domains" in STDOUT should be "bh-site-command-45.test" + + Scenario: The site name may be given as a URL + When I run 'bin/ee site info http://bh-site-command-45.test/' + Then the command should succeed + And STDOUT should contain "http://bh-site-command-45.test" + + Scenario: Info works for a disabled site + Given the html site "bh-site-command-46.test" exists + And the site "bh-site-command-46.test" is disabled + When I run 'bin/ee site info bh-site-command-46.test' + Then the command should succeed + And STDOUT should contain "http://bh-site-command-46.test" + + Scenario: The site is detected from the working directory + When I run 'cd {SITES_DIR}/bh-site-command-45.test && {EE} site info' + Then the command should succeed + And STDOUT should contain "http://bh-site-command-45.test" + + Scenario: Another site name inside a site directory is rejected with a hint + When I run 'cd {SITES_DIR}/bh-site-command-45.test && {EE} site info bh-site-command-47.test' + Then the command should fail + And STDERR should contain "bh-site-command-47.test is not a valid site-name. Did you mean `ee site info bh-site-command-45.test`?" + + Scenario: An unsupported format is rejected + When I run 'bin/ee site info bh-site-command-45.test --format=csv' + Then the command should fail + And STDERR should contain "Invalid value specified for 'format'" diff --git a/features/labels.feature b/features/labels.feature index 25ff2184..1102171c 100644 --- a/features/labels.feature +++ b/features/labels.feature @@ -1,9 +1,25 @@ Feature: Container Labels - + Site containers, volumes and networks carry the EasyEngine labels other commands filter on. Scenario: All easyengine containers are tagged - Given I run "bin/ee site create labels.test" + Given the html site "bh-site-command-130.test" exists Then There should be 1 containers with labels """ - io.easyengine.site=labels.test + io.easyengine.site=bh-site-command-130.test + """ + And There should be 1 containers with labels """ + io.easyengine.site=bh-site-command-130.test + com.docker.compose.service=nginx + """ + + Scenario: Site volumes and network are tagged + Given the html site "bh-site-command-130.test" exists + Then the site "bh-site-command-130.test" should have 3 docker volumes + And the docker volume "bhsitecommand130test_htdocs" should exist + And the docker volume "bhsitecommand130test_config_nginx" should exist + And the docker volume "bhsitecommand130test_log_nginx" should exist + When I run 'docker network inspect -f "{{json .Labels}}" bh-site-command-130.test' + Then the command should succeed + And STDOUT should contain ""io.easyengine.site":"bh-site-command-130.test"" + And STDOUT should contain ""org.label-schema.vendor":"EasyEngine"" diff --git a/features/le.feature b/features/le.feature new file mode 100644 index 00000000..2cb3b88d --- /dev/null +++ b/features/le.feature @@ -0,0 +1,156 @@ +@le +Feature: Let's Encrypt certificates + Real issuance against public DNS (*.ee4-test.rt.gw points at the test host). Names include {RUN_ID} so repeated runs never hit + the duplicate-certificate limit. Budget per full run: 4 issuances (create, alias add, alias delete, update --ssl=le); the wildcard + and failing-alias scenarios create orders but issue nothing. Scenarios run in order and share LE1/LE2/LE3 to stay in budget; + run alone, a scenario issues its own certificate. + + Background: + Given the EasyEngine config value "le-mail" is set + And the variable "LE1" is "bh-site-command-le1-{RUN_ID}.ee4-test.rt.gw" + And the variable "LE2" is "bh-site-command-le2-{RUN_ID}.ee4-test.rt.gw" + And the variable "LE3" is "bh-site-command-le3-{RUN_ID}.ee4-test.rt.gw" + + Scenario: Create a site with a Let's Encrypt certificate + Given the site "{LE1}" does not exist + When I run 'bin/ee site create {LE1} --ssl=le' + Then the command should succeed + And the "site_ssl" field of site "{LE1}" in the database should be "le" + And the "site_ssl_wildcard" field of site "{LE1}" in the database should be "0" + And the file "{PROXY_DIR}/certs/{LE1}.crt" should exist + And the file "{PROXY_DIR}/certs/{LE1}.key" should exist + And the directory "{PROXY_DIR}/acme-conf/certs/{LE1}" should exist + And the hosts file should not contain an entry for "{LE1}" + # The proxy reload that picks up the new certificate is asynchronous. + When I wait up to 60 seconds for "https://{LE1}/" to return 200 + And I send a verified GET request to "https://{LE1}/" + Then the response code should be 200 + And the response body should contain "Your html site with EasyEngine" + And the TLS certificate served for "{LE1}" should cover "{LE1}" + And the TLS certificate served for "{LE1}" should cover "www.{LE1}" + And the TLS certificate served for "{LE1}" should be issued by "{LE_ISSUER}" + When I send a GET request to "http://{LE1}/" + Then the response code should be 301 + And the response header "Location" should be "https://{LE1}/" + When I send a verified GET request to "https://www.{LE1}/" + Then the response code should be 301 + And the response header "Location" should be "https://{LE1}/" + When I run 'bin/ee site ssl-info {LE1}' + Then the JSON field "0.ssl_type" in STDOUT should be "le" + And the JSON field "0.issued_by_CN" in STDOUT should be "{LE_ISSUER}" + And the JSON field "0.SANs" in STDOUT should contain "www.{LE1}" + + Scenario: ssl-renew leaves a certificate that is not due alone + Given the site "{LE1}" exists, created with "--type=html --ssl=le" + When I run 'bin/ee site ssl-info {LE1}' + And I save the JSON field "0.serial_number" in STDOUT as "SERIAL" + And I run 'bin/ee site ssl-renew {LE1}' + Then the command should succeed + And STDOUT should contain "SSL renewal completed." + When I run 'bin/ee site ssl-info {LE1}' + Then the JSON field "0.serial_number" in STDOUT should be "{SERIAL}" + + Scenario: Adding an alias reissues the certificate to cover it + Given the site "{LE1}" exists, created with "--type=html --ssl=le" + When I run 'bin/ee site update {LE1} --add-alias-domains=a1.{LE1}' + Then the command should succeed + And STDOUT should contain "Updating and force renewing SSL certificate to accomodated alias domain changes." + And STDOUT should contain "Alias domains updated on site {LE1}." + And the "alias_domains" field of site "{LE1}" in the database should be "{LE1},a1.{LE1}" + And the "site_ssl" field of site "{LE1}" in the database should be "le" + When I wait up to 60 seconds for "https://a1.{LE1}/" to return 200 + And I send a verified GET request to "https://a1.{LE1}/" + Then the response code should be 200 + And the TLS certificate served for "a1.{LE1}" should cover "a1.{LE1}" + And the TLS certificate served for "{LE1}" should cover "{LE1}" + + Scenario: Removing an alias reissues the certificate without it + Given the site "{LE1}" exists, created with "--type=html --ssl=le --alias-domains=a1.{LE1}" + When I run 'bin/ee site update {LE1} --delete-alias-domains=a1.{LE1}' + Then the command should succeed + And the "alias_domains" field of site "{LE1}" in the database should be "{LE1}" + When I wait up to 60 seconds for "https://{LE1}/" to return 200 + And I send a verified GET request to "https://{LE1}/" + Then the response code should be 200 + And the TLS certificate served for "{LE1}" should not cover "a1.{LE1}" + + Scenario: A wildcard certificate waits for the DNS challenge + Given the EasyEngine config value "cloudflare-api-key" is not set + And the site "{LE3}" does not exist + When I run 'bin/ee site create {LE3} --ssl=le --wildcard' + Then the command should succeed + And STDOUT should contain "Run `ee site ssl-verify {LE3}` once the DNS changes have propagated" + And the "site_ssl_wildcard" field of site "{LE3}" in the database should be "1" + When I run 'bin/ee site ssl-info {LE3}' + Then the JSON field "0.status" in STDOUT should be "Certificate file not found / yet to be issued." + When I run 'bin/ee site ssl-info {LE3} --get-dns-records' + Then the command should succeed + And the JSON field "0.dns_challenges" in STDOUT should contain "_acme-challenge.{LE3}" + + # --all walks every LE site on the host (and force-enables expired ones), so it only runs when all of them are ours. + @destructive + Scenario: ssl-renew --all skips wildcard sites without Cloudflare credentials + Given the EasyEngine config value "cloudflare-api-key" is not set + And no enabled Let's Encrypt site other than this suite's exists + And the site "{LE3}" exists, created with "--type=html --ssl=le --wildcard" + When I run 'bin/ee site ssl-renew --all' + Then the command should succeed + And STDERR should contain "Wildcard site found: {LE3}, skipping it" + + Scenario: Enable Let's Encrypt on an existing site + Given the html site "{LE2}" exists + When I run 'bin/ee site update {LE2} --ssl=le' + Then the command should succeed + And STDOUT should contain "Enabled SSL for {LE2}" + And the "site_ssl" field of site "{LE2}" in the database should be "le" + When I wait up to 60 seconds for "https://{LE2}/" to return 200 + And I send a verified GET request to "https://{LE2}/" + Then the response code should be 200 + And the TLS certificate served for "{LE2}" should be issued by "{LE_ISSUER}" + + # Regression check (fixed in v4.13.1, EasyEngine/site-command#505): a failed issuance used to only warn, save the alias with SSL "Not Enabled" and revoke the served cert. + # The alias is under the reserved .invalid TLD: the order is refused outright, so nothing outside *.ee4-test.rt.gw is validated. + Scenario: An alias that fails validation is rejected and the site keeps its certificate + Given the site "{LE2}" exists, created with "--type=html --ssl=le" + When I run 'bin/ee site update {LE2} --add-alias-domains=bh-site-command-nx-{RUN_ID}.invalid' + Then the command should fail + And STDERR should contain "Alias domains of {LE2} were not changed" + And STDOUT should not contain "Alias domains updated" + And the "alias_domains" field of site "{LE2}" in the database should be "{LE2}" + And the "site_ssl" field of site "{LE2}" in the database should be "le" + And the directory "{PROXY_DIR}/acme-conf/var/bh-site-command-nx-{RUN_ID}.invalid" should not exist + When I wait up to 60 seconds for "https://{LE2}/" to return 200 + And I send a verified GET request to "https://{LE2}/" + Then the response code should be 200 + And the TLS certificate served for "{LE2}" should cover "{LE2}" + When I run 'bin/ee site ssl-renew {LE2}' + Then the command should succeed + And STDERR should not contain "Only Letsencrypt certificate renewal is supported." + + # Turning SSL off removes the certificate and ACME state (no issuance: runs on the site of the scenario above). + Scenario: Turning Let's Encrypt off removes the certificate + Given the site "{LE2}" exists, created with "--type=html --ssl=le" + When I run 'bin/ee site update {LE2} --ssl=off' + Then the command should succeed + And the file "{PROXY_DIR}/certs/{LE2}.crt" should not exist + And the file "{PROXY_DIR}/certs/{LE2}.key" should not exist + And the directory "{PROXY_DIR}/acme-conf/certs/{LE2}" should not exist + And the directory "{PROXY_DIR}/acme-conf/var/{LE2}" should not exist + When I wait up to 60 seconds for "http://{LE2}/" to return 200 + + Scenario: Deleting a Let's Encrypt site revokes and removes its certificate + Given the site "{LE1}" exists, created with "--type=html --ssl=le" + When I run 'bin/ee site delete {LE1} --yes' + Then the command should succeed + And STDOUT should contain "Revoking certificate." + And the site "{LE1}" should not exist + And the file "{PROXY_DIR}/certs/{LE1}.crt" should not exist + And the file "{PROXY_DIR}/certs/{LE1}.key" should not exist + And the directory "{PROXY_DIR}/acme-conf/certs/{LE1}" should not exist + And the directory "{PROXY_DIR}/acme-conf/var/{LE1}" should not exist + + # Regression check (fixed in v4.13.1, EasyEngine/site-command#505): delete used to leave acme-conf/var/www. and alias dirs behind. Run after the delete scenario above. + Scenario: Deleting a Let's Encrypt site leaves no ACME state behind + Given the site "{LE1}" does not exist + Then the directory "{PROXY_DIR}/acme-conf/var/www.{LE1}" should not exist + And the directory "{PROXY_DIR}/acme-conf/var/a1.{LE1}" should not exist diff --git a/features/list.feature b/features/list.feature new file mode 100644 index 00000000..393aa6f8 --- /dev/null +++ b/features/list.feature @@ -0,0 +1,71 @@ +Feature: List sites + `ee site list` formats and the enabled/disabled filters. Other sites may exist on the host, so only this suite's sites are asserted. + + Background: + Given the html site "bh-site-command-40.test" exists + And the html site "bh-site-command-41.test" exists + And the site "bh-site-command-41.test" is disabled + + Scenario: List sites as text + When I run 'bin/ee site list --format=text' + Then the command should succeed + And STDOUT should match "/^bh-site-command-40\.test$/m" + And STDOUT should match "/^bh-site-command-41\.test$/m" + + Scenario: List sites as a table by default + # Without a TTY the default format is tab-separated; SHELL_PIPE=0 makes it render as it does in a terminal. + When I run 'SHELL_PIPE=0 bin/ee site list' + Then the command should succeed + And STDOUT should match "/\|\s*site\s*\|\s*status\s*\|/" + And STDOUT should match "/\|\s*bh-site-command-40\.test\s*\|\s*enabled\s*\|/" + And STDOUT should match "/\|\s*bh-site-command-41\.test\s*\|\s*disabled\s*\|/" + + Scenario: List sites as JSON + When I run 'bin/ee site list --format=json' + Then the command should succeed + And STDOUT should be valid JSON + And STDOUT should match "/\{\s*"site":\s*"bh-site-command-40\.test",\s*"status":\s*"enabled"\s*\}/" + And STDOUT should match "/\{\s*"site":\s*"bh-site-command-41\.test",\s*"status":\s*"disabled"\s*\}/" + + Scenario: List sites as CSV + When I run 'bin/ee site list --format=csv' + Then the command should succeed + And STDOUT should match "/^site,status$/m" + And STDOUT should match "/^bh-site-command-40\.test,enabled$/m" + And STDOUT should match "/^bh-site-command-41\.test,disabled$/m" + + Scenario: List sites as YAML + When I run 'bin/ee site list --format=yaml' + Then the command should succeed + And STDOUT should contain "site: bh-site-command-40.test" + And STDOUT should contain "site: bh-site-command-41.test" + + Scenario: Count sites + When I run 'bin/ee site list --format=count' + Then the command should succeed + And STDOUT should match "/^\s*([2-9]|\d{2,})\s*$/" + + Scenario: List only enabled sites + When I run 'bin/ee site list --enabled --format=json' + Then the command should succeed + And the JSON array in STDOUT should contain an item with "site" set to "bh-site-command-40.test" + And the JSON array in STDOUT should not contain an item with "site" set to "bh-site-command-41.test" + And the JSON array in STDOUT should not contain an item with "status" set to "disabled" + + Scenario: List only disabled sites + When I run 'bin/ee site list --disabled --format=json' + Then the command should succeed + And the JSON array in STDOUT should contain an item with "site" set to "bh-site-command-41.test" + And the JSON array in STDOUT should not contain an item with "site" set to "bh-site-command-40.test" + And the JSON array in STDOUT should not contain an item with "status" set to "enabled" + + Scenario: Both filters together list every site + When I run 'bin/ee site list --enabled --disabled --format=json' + Then the command should succeed + And the JSON array in STDOUT should contain an item with "site" set to "bh-site-command-40.test" + And the JSON array in STDOUT should contain an item with "site" set to "bh-site-command-41.test" + + Scenario: An unsupported format is rejected + When I run 'bin/ee site list --format=xml' + Then the command should fail + And STDERR should contain "Invalid value specified for 'format'" diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature new file mode 100644 index 00000000..8790e735 --- /dev/null +++ b/features/proxy-cache.feature @@ -0,0 +1,113 @@ +@destructive +Feature: Proxy cache + `ee site update --proxy-cache` writes nginx-proxy config and restarts the GLOBAL nginx-proxy (all sites blip), hence @destructive. + Every scenario ends with the proxy running. + + Scenario: Enable the proxy cache with defaults + Given the html site "bh-site-command-80.test" exists + When I run 'bin/ee site update bh-site-command-80.test --proxy-cache=on' + Then the command should succeed + And STDOUT should contain "Enabling proxy cache for: bh-site-command-80.test" + And STDOUT should contain "Proxy cache update on site bh-site-command-80.test completed." + And the "proxy_cache" field of site "bh-site-command-80.test" in the database should be "on" + And the file "{PROXY_DIR}/conf.d/bh-site-command-80.test.conf" should contain "keys_zone=bh-site-command-80-test:10m max_size=256m" + And the file "{PROXY_DIR}/vhost.d/bh-site-command-80.test_location" should contain "proxy_cache_valid 200 301 302 404 1s;" + And the docker container "services_global-nginx-proxy_1" should be running + When I wait up to 60 seconds for "http://bh-site-command-80.test/" to return 200 + Then the response should have the header "X-Proxy-Cache" + + Scenario: Enabling the proxy cache twice fails + Given the html site "bh-site-command-81.test" exists + When I run 'bin/ee site update bh-site-command-81.test --proxy-cache=on' + Then the command should succeed + When I run 'bin/ee site update bh-site-command-81.test --proxy-cache=on' + Then the command should fail + And STDERR should contain "Site bh-site-command-81.test already has proxy cache: on" + + Scenario: Enable the proxy cache with custom sizes and time + Given the html site "bh-site-command-82.test" exists + When I run 'bin/ee site update bh-site-command-82.test --proxy-cache=on --proxy-cache-max-size=1g --proxy-cache-max-time=30s --proxy-cache-key-zone-size=20m' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-82.test.conf" should contain "keys_zone=bh-site-command-82-test:20m max_size=1g" + And the file "{PROXY_DIR}/vhost.d/bh-site-command-82.test_location" should contain "proxy_cache_valid 200 301 302 404 30s;" + When I wait up to 60 seconds for "http://bh-site-command-82.test/" to return 200 + + Scenario: Invalid proxy cache values fall back to the defaults with warnings + Given the html site "bh-site-command-83.test" exists + When I run 'bin/ee site update bh-site-command-83.test --proxy-cache=on --proxy-cache-max-size=big --proxy-cache-max-time=forever --proxy-cache-key-zone-size=1g' + Then the command should succeed + And STDERR should contain "Wrong value `big` supplied to param: `proxy-cache-max-size`, replacing it with default:256m" + And STDERR should contain "Wrong value `forever` supplied to param: `proxy-cache-max-time`, replacing it with default:1s" + And STDERR should contain "Wrong value `1g` supplied to param: `proxy-cache-key-zone-size`, replacing it with default:10m" + And the file "{PROXY_DIR}/conf.d/bh-site-command-83.test.conf" should contain "keys_zone=bh-site-command-83-test:10m max_size=256m" + When I wait up to 60 seconds for "http://bh-site-command-83.test/" to return 200 + + Scenario: Disable the proxy cache + Given the html site "bh-site-command-84.test" exists + When I run 'bin/ee site update bh-site-command-84.test --proxy-cache=on' + Then the command should succeed + When I run 'bin/ee site update bh-site-command-84.test --proxy-cache=off' + Then the command should succeed + And STDOUT should contain "Disabling proxy cache for: bh-site-command-84.test" + And the "proxy_cache" field of site "bh-site-command-84.test" in the database should be "off" + And the file "{PROXY_DIR}/conf.d/bh-site-command-84.test.conf" should not exist + And the file "{PROXY_DIR}/vhost.d/bh-site-command-84.test_location" should not exist + When I wait up to 60 seconds for "http://bh-site-command-84.test/" to return 200 + Then the response should not have the header "X-Proxy-Cache" + + Scenario: Disabling a proxy cache that is off fails + Given the html site "bh-site-command-85.test" exists + When I run 'bin/ee site update bh-site-command-85.test --proxy-cache=off' + Then the command should fail + And STDERR should contain "Site bh-site-command-85.test already has proxy cache: off" + + Scenario: Alias domains get proxy cache locations, also when added later + Given the site "bh-site-command-86.test" exists, created with "--type=html --alias-domains=bh-site-command-86-a.test" + When I run 'bin/ee site update bh-site-command-86.test --proxy-cache=on' + Then the command should succeed + And the file "{PROXY_DIR}/vhost.d/bh-site-command-86-a.test_location" should exist + When I run 'bin/ee site update bh-site-command-86.test --add-alias-domains=bh-site-command-86-b.test' + Then the command should succeed + And STDOUT should contain "As proxy cache is enabled on this site, updating config to enable it in newly added alias domains." + And the file "{PROXY_DIR}/vhost.d/bh-site-command-86-b.test_location" should exist + And the docker container "services_global-nginx-proxy_1" should be running + + Scenario: Deleting an alias removes its proxy cache location + Given the site "bh-site-command-162.test" exists, created with "--type=html --alias-domains=bh-site-command-162-a.test,bh-site-command-162-b.test" + When I run 'bin/ee site update bh-site-command-162.test --proxy-cache=on' + Then the command should succeed + When I run 'bin/ee site update bh-site-command-162.test --delete-alias-domains=bh-site-command-162-a.test' + Then the command should succeed + And the file "{PROXY_DIR}/vhost.d/bh-site-command-162-a.test_location" should not exist + And the file "{PROXY_DIR}/vhost.d/bh-site-command-162-b.test_location" should exist + And the file "{PROXY_DIR}/vhost.d/bh-site-command-162.test_location" should exist + + # Regression check (fixed in v4.13.1, EasyEngine/site-command#505): a failed nginx -t during the alias sync used to remove the cache zone but keep the locations using it, so nginx never passed -t again and could not start after a restart. + # The broken include is removed right after the update, before any assertion can fail. + Scenario: A failed proxy cache alias sync leaves a config nginx can load + Given the site "bh-site-command-163.test" exists, created with "--type=html --alias-domains=bh-site-command-163-a.test" + When I run 'bin/ee site update bh-site-command-163.test --proxy-cache=on' + Then the command should succeed + When I write "bh_broken_directive;" to the file "{PROXY_DIR}/conf.d/bh-site-command-163-broken.conf" + And I run 'bin/ee site update bh-site-command-163.test --add-alias-domains=bh-site-command-163-b.test' + And I remove the file "{PROXY_DIR}/conf.d/bh-site-command-163-broken.conf" + Then the command should succeed + And STDERR should contain "Could not enable proxy cache on bh-site-command-163.test, nginx config test failed. The previous proxy config was restored." + And the file "{PROXY_DIR}/conf.d/bh-site-command-163.test.conf" should exist + And the file "{PROXY_DIR}/vhost.d/bh-site-command-163-b.test_location" should not exist + When I run 'docker exec services_global-nginx-proxy_1 nginx -t' + Then the command should succeed + When I run 'docker restart services_global-nginx-proxy_1' + Then the command should succeed + When I wait up to 60 seconds for "http://bh-site-command-163.test/" to return 200 + Then the docker container "services_global-nginx-proxy_1" should be running + + Scenario: Deleting a proxy-cached site removes its proxy config + Given the html site "bh-site-command-87.test" exists + When I run 'bin/ee site update bh-site-command-87.test --proxy-cache=on' + Then the command should succeed + When I run 'bin/ee site delete bh-site-command-87.test --yes' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-87.test.conf" should not exist + And the file "{PROXY_DIR}/vhost.d/bh-site-command-87.test_location" should not exist + And the docker container "services_global-nginx-proxy_1" should be running diff --git a/features/redirect.feature b/features/redirect.feature index 3af18947..cdb34faa 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -1,19 +1,60 @@ Feature: Site Redirection + www <-> non-www redirects in nginx-proxy. EE only writes the redirect when the other name resolves to this host, so each scenario adds (and afterwards removes) the needed hosts entry. Scenario: no_www-no_ssl redirection works properly - When I run '/bin/bash -c 'echo "127.0.0.1 www.example.test" >> /etc/hosts'' - And I run 'bin/ee site create example.test' - Then After delay of 5 seconds - Then Request on 'localhost' with header 'Host: www.example.test' should contain following headers: - | header | - | HTTP/1.1 301 Moved Permanently | - | Location: http://example.test/ | + Given the hosts file contains an entry for "www.bh-site-command-120.test" + And the site "bh-site-command-120.test" does not exist + When I run 'bin/ee site create bh-site-command-120.test' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-120.test-redirect.conf" should contain "server_name www.bh-site-command-120.test;" + When I wait up to 60 seconds for "http://www.bh-site-command-120.test/" to return 301 + Then the response header "Location" should be "http://bh-site-command-120.test/" Scenario: www-no_ssl redirection works properly - When I run '/bin/bash -c 'echo "127.0.0.1 example1.test" >> /etc/hosts'' - And I run 'bin/ee site create www.example1.test' - Then After delay of 5 seconds - Then Request on 'localhost' with header 'Host: example1.test' should contain following headers: - | header | - | HTTP/1.1 301 Moved Permanently | - | Location: http://www.example1.test/ | + Given the hosts file contains an entry for "bh-site-command-121.test" + And the site "www.bh-site-command-121.test" does not exist + When I run 'bin/ee site create www.bh-site-command-121.test' + Then the command should succeed + When I wait up to 60 seconds for "http://bh-site-command-121.test/" to return 301 + Then the response header "Location" should be "http://www.bh-site-command-121.test/" + + Scenario: No redirect is written when the www name does not resolve here + Given the site "bh-site-command-122.test" does not exist + And the hosts file should not contain an entry for "www.bh-site-command-122.test" + When I run 'bin/ee site create bh-site-command-122.test' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-122.test-redirect.conf" should not exist + + Scenario: Disable removes the redirect and enable restores it + Given the hosts file contains an entry for "www.bh-site-command-123.test" + And the site "bh-site-command-123.test" does not exist + When I run 'bin/ee site create bh-site-command-123.test' + Then the file "{PROXY_DIR}/conf.d/bh-site-command-123.test-redirect.conf" should exist + When I run 'bin/ee site disable bh-site-command-123.test' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-123.test-redirect.conf" should not exist + When I run 'bin/ee site enable bh-site-command-123.test' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-123.test-redirect.conf" should exist + When I wait up to 60 seconds for "http://www.bh-site-command-123.test/" to return 301 + Then the response header "Location" should be "http://bh-site-command-123.test/" + + Scenario: Delete removes the redirect + Given the hosts file contains an entry for "www.bh-site-command-124.test" + And the site "bh-site-command-124.test" does not exist + When I run 'bin/ee site create bh-site-command-124.test' + Then the file "{PROXY_DIR}/conf.d/bh-site-command-124.test-redirect.conf" should exist + When I run 'bin/ee site delete bh-site-command-124.test --yes' + Then the command should succeed + And the file "{PROXY_DIR}/conf.d/bh-site-command-124.test-redirect.conf" should not exist + + Scenario: HTTPS www redirect on a self-signed site + Given the hosts file contains an entry for "www.bh-site-command-126.test" + And the site "bh-site-command-126.test" does not exist + When I run 'bin/ee site create bh-site-command-126.test --ssl=self' + Then the command should succeed + When I wait up to 60 seconds for "https://www.bh-site-command-126.test/" to return 301 + Then the response header "Location" should be "https://bh-site-command-126.test/" + When I send a GET request to "http://www.bh-site-command-126.test/" + Then the response code should be 301 + And the response header "Location" should be "https://bh-site-command-126.test/" diff --git a/features/restart-reload.feature b/features/restart-reload.feature new file mode 100644 index 00000000..788b61a7 --- /dev/null +++ b/features/restart-reload.feature @@ -0,0 +1,64 @@ +Feature: Restart and reload site services + `ee site restart` recreates nothing but restarts containers after `nginx -t`; `ee site reload` reloads nginx in place. + + Scenario Outline: Restart the site containers + Given the html site "bh-site-command-56.test" exists + When I note the start time of service "nginx" of site "bh-site-command-56.test" + And I run 'bin/ee site restart bh-site-command-56.test ' + Then the command should succeed + And STDOUT should contain "Restarting nginx" + And the service "nginx" of site "bh-site-command-56.test" should have been restarted + When I wait up to 60 seconds for "http://bh-site-command-56.test/" to return 200 + + Examples: + | flags | + | | + | --nginx | + | --all | + + Scenario: Restart rejects services the html type does not have + Given the html site "bh-site-command-56.test" exists + When I run 'bin/ee site restart bh-site-command-56.test --php' + Then the command should fail + And STDERR should contain "unknown --php parameter" + + Scenario Outline: Reload nginx without restarting the container + Given the html site "bh-site-command-56.test" exists + When I note the start time of service "nginx" of site "bh-site-command-56.test" + And I run 'bin/ee site reload bh-site-command-56.test ' + Then the command should succeed + And STDOUT should contain "Reloading nginx" + And the service "nginx" of site "bh-site-command-56.test" should not have been restarted + When I send a GET request to "http://bh-site-command-56.test/" + Then the response code should be 200 + + Examples: + | flags | + | | + | --nginx | + | --all | + + Scenario: Restart refuses a broken nginx config and keeps the site serving + Given the html site "bh-site-command-57.test" exists + And I write "# Custom Nginx configuration can be put here\nbh_invalid_directive on;\n" to the file "{SITES_DIR}/bh-site-command-57.test/config/nginx/custom/user.conf" + When I note the start time of service "nginx" of site "bh-site-command-57.test" + And I run 'bin/ee site restart bh-site-command-57.test --nginx' + Then the command should fail + And STDERR should contain "Nginx test failed" + And the service "nginx" of site "bh-site-command-57.test" should not have been restarted + When I send a GET request to "http://bh-site-command-57.test/" + Then the response code should be 200 + When I write "# Custom Nginx configuration can be put here\n" to the file "{SITES_DIR}/bh-site-command-57.test/config/nginx/custom/user.conf" + And I run 'bin/ee site restart bh-site-command-57.test --nginx' + Then the command should succeed + + Scenario: Custom nginx config is applied on reload + Given the html site "bh-site-command-58.test" exists + When I write "location = /bh-probe { return 200 'bh-site-command-probe'; }\n" to the file "{SITES_DIR}/bh-site-command-58.test/config/nginx/custom/user.conf" + And I run 'bin/ee site reload bh-site-command-58.test' + Then the command should succeed + When I wait up to 30 seconds for "http://bh-site-command-58.test/bh-probe" to return 200 + Then the response body should contain "bh-site-command-probe" + When I write "# Custom Nginx configuration can be put here\n" to the file "{SITES_DIR}/bh-site-command-58.test/config/nginx/custom/user.conf" + And I run 'bin/ee site reload bh-site-command-58.test' + Then the command should succeed diff --git a/features/share.feature b/features/share.feature new file mode 100644 index 00000000..5265b915 --- /dev/null +++ b/features/share.feature @@ -0,0 +1,21 @@ +Feature: Share a site + `ee site share` guards. Sharing itself needs the retired ngrok v2 download and an ngrok account, so it is not exercised. + + Scenario: Disabling a share that is not running fails + Given the html site "bh-site-command-90.test" exists + When I run 'bin/ee site share bh-site-command-90.test --disable' + Then the command should fail + And STDERR should contain "bh-site-command-90.test does not have active share running." + + Scenario: Refreshing a share that is not running fails + Given the html site "bh-site-command-90.test" exists + When I run 'bin/ee site share bh-site-command-90.test --refresh' + Then the command should fail + And STDERR should contain "bh-site-command-90.test does not have active share running." + + Scenario: SSL sites cannot be shared + Given the site "bh-site-command-91.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site share bh-site-command-91.test' + Then the command should fail + And STDERR should contain "site share is not yet supported for ssl sites." + And the file "{EE_ROOT_DIR}/services/ngrok/ngrok" should not exist diff --git a/features/site.feature b/features/site.feature index 827f6ed8..ea57232c 100644 --- a/features/site.feature +++ b/features/site.feature @@ -1,4 +1,5 @@ -Feature: Site Command +Feature: Site command basics + Routing, help, argument validation and error messages that need no site of their own. Scenario: ee executable is command working correctly Given 'bin/ee' is installed @@ -12,36 +13,133 @@ Feature: Site Command Scenario: Check site command is present When I run 'bin/ee site' - Then STDOUT should return something like - """ - usage: ee site - """ + Then STDOUT should contain "usage: ee site" - Scenario: Create html site successfully - When I run 'bin/ee site create site.test --type=html' - Then The site 'site.test' should have webroot - And Request on 'site.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | + Scenario: Help lists every site subcommand + When I run 'bin/ee help site' + Then the command should succeed + And STDOUT should contain "backup" + And STDOUT should contain "clean" + And STDOUT should contain "clone" + And STDOUT should contain "create" + And STDOUT should contain "delete" + And STDOUT should contain "disable" + And STDOUT should contain "enable" + And STDOUT should contain "info" + And STDOUT should contain "list" + And STDOUT should contain "refresh" + And STDOUT should contain "reload" + And STDOUT should contain "restart" + And STDOUT should contain "restore" + And STDOUT should contain "share" + And STDOUT should contain "ssl-info" + And STDOUT should contain "ssl-renew" + And STDOUT should contain "ssl-verify" + And STDOUT should contain "sync" + And STDOUT should contain "update" - Scenario: List the sites - When I run 'bin/ee site list --format=text' - Then STDOUT should return exactly - """ - site.test - """ + Scenario: Help of site create documents the html options + When I run 'bin/ee help site create' + Then the command should succeed + And STDOUT should contain "--ssl" + And STDOUT should contain "--alias-domains" + And STDOUT should contain "--public-dir" + And STDOUT should contain "--skip-status-check" - Scenario: Delete the sites - When I run 'bin/ee site delete site.test --yes' - Then STDOUT should return something like - """ - Site site.test deleted. - """ - And STDERR should return exactly - """ - """ - And The 'site.test' db entry should be removed - And The 'site.test' webroot should be removed - And Following containers of site 'site.test' should be removed: - | container | - | nginx | + Scenario: Help for an unregistered site type fails + When I run 'bin/ee help site --type=bh-bogus' + Then the command should fail + And STDERR should contain "'bh-bogus' is not a registered site type of 'ee site --type=bh-bogus'" + + Scenario: cmd-dump prints the site command tree as JSON + When I run 'bin/ee site cmd-dump' + Then the command should succeed + And STDOUT should be valid JSON + And STDOUT should contain "ssl-renew" + + Scenario: Creating a site of an unregistered type fails + Given the site "bh-site-command-1.test" does not exist + When I run 'bin/ee site create bh-site-command-1.test --type=bh-bogus' + Then the command should fail + And STDERR should contain "'bh-bogus' is not a registered site type of 'ee site --type=bh-bogus'" + And the site "bh-site-command-1.test" should not exist + And the directory "{SITES_DIR}/bh-site-command-1.test" should not exist + + Scenario: Creating a site without a name prints the usage + When I run 'bin/ee site create' + Then the exit code should be 1 + And STDOUT should contain "usage: ee site create " + + Scenario Outline: Subcommands that need a site fail when none is given outside a site directory + When I run 'bin/ee site ' + Then the command should fail + And STDERR should contain "Could not find the site you wish to run site command on." + + Examples: + | command | function | + | info | info | + | update | update | + | enable | enable | + | disable | disable | + | restart | restart | + | reload | reload | + | clean | clean | + | ssl-info | ssl_info | + | backup | backup | + | restore | restore | + + Scenario: ssl-renew without a site fails outside a site directory + Given the EasyEngine config value "le-mail" is set + When I run 'bin/ee site ssl-renew' + Then the command should fail + And STDERR should contain "Could not find the site you wish to run site ssl_renew command on." + + Scenario Outline: Subcommands with a required site name print the usage without one + When I run 'bin/ee site ' + Then the exit code should be 1 + And STDOUT should contain "usage: ee site " + + Examples: + | command | + | delete | + | ssl-verify | + | share | + | clone | + | sync | + + Scenario Outline: Subcommands that look the site up directly report an unknown site + When I run 'bin/ee site bh-site-command-9.test' + Then the command should fail + And STDERR should contain "Site bh-site-command-9.test does not exist." + + Examples: + | command | + | delete | + | refresh | + | ssl-verify | + | ssl | + + Scenario Outline: Subcommands that auto-detect the site report an unknown site + When I run 'bin/ee site bh-site-command-9.test' + Then the command should fail + And STDERR should contain "Could not find the site you wish to run site command on." + + Examples: + | command | function | + | info | info | + | enable | enable | + | disable | disable | + | restart | restart | + | update | update | + | share | share | + + Scenario: Unknown parameters are rejected + When I run 'bin/ee site list --bh-unknown' + Then the command should fail + And STDERR should contain "unknown --bh-unknown parameter" + + Scenario: Extra positional arguments are rejected + Given the html site "bh-site-command-2.test" exists + When I run 'bin/ee site info bh-site-command-2.test bh-site-command-2.test' + Then the command should fail + And STDERR should contain "Too many positional arguments" diff --git a/features/ssl.feature b/features/ssl.feature new file mode 100644 index 00000000..6775fdef --- /dev/null +++ b/features/ssl.feature @@ -0,0 +1,159 @@ +Feature: Self-signed, custom and inherited SSL + `ee site create --ssl=self|custom|inherit`, `ee site ssl-info` and the `ee site ssl-renew` guards. Let's Encrypt is in le.feature. + + Scenario: Create a site with a self-signed certificate + Given the site "bh-site-command-100.test" does not exist + When I run 'bin/ee site create bh-site-command-100.test --ssl=self' + Then the command should succeed + And the "site_ssl" field of site "bh-site-command-100.test" in the database should be "self" + And the "site_ssl_wildcard" field of site "bh-site-command-100.test" in the database should be "1" + And the file "{PROXY_DIR}/certs/bh-site-command-100.test.crt" should exist + And the file "{PROXY_DIR}/certs/bh-site-command-100.test.key" should exist + And the hosts file should contain an entry for "bh-site-command-100.test" + And the service "nginx" of site "bh-site-command-100.test" should not have the environment variable "HTTPS_METHOD" containing "nohttps" + When I wait up to 60 seconds for "https://bh-site-command-100.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + And the TLS certificate served for "bh-site-command-100.test" should cover "bh-site-command-100.test" + And the TLS certificate served for "bh-site-command-100.test" should cover "anything.bh-site-command-100.test" + And the TLS certificate served for "bh-site-command-100.test" should be issued by "EasyEngine" + When I send a GET request to "http://bh-site-command-100.test/" + Then the response code should be 301 + And the response header "Location" should be "https://bh-site-command-100.test/" + + Scenario: Info of a self-signed site shows SSL and wildcard + Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site info bh-site-command-100.test' + Then the command should succeed + And STDOUT should match "/\|\s*Site\s*\|\s*https:\/\/bh-site-command-100\.test\s*\|/" + And STDOUT should match "/\|\s*SSL\s*\|\s*Enabled\s*\|/" + And STDOUT should match "/\|\s*SSL Wildcard\s*\|\s*Yes\s*\|/" + + Scenario: ssl-info shows the certificate of a self-signed site + Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site ssl-info bh-site-command-100.test' + Then the command should succeed + And STDOUT should be valid JSON + And the JSON field "0.ssl_type" in STDOUT should be "self" + And the JSON field "0.cert_file" in STDOUT should be "{PROXY_DIR}/certs/bh-site-command-100.test.crt" + And the JSON field "0.issued_to_CN" in STDOUT should be "bh-site-command-100.test" + And the JSON field "0.issued_by_CN" in STDOUT should be "EasyEngine" + And the JSON field "0.SANs" in STDOUT should contain "*.bh-site-command-100.test" + And the JSON field "0.status" in STDOUT should be "SSL certificate details loaded." + + Scenario: ssl-info supports other formats + Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site ssl-info bh-site-command-100.test --format=yaml' + Then the command should succeed + And STDOUT should contain "ssl_type: self" + + Scenario: ssl-info rejects an unsupported format + Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site ssl-info bh-site-command-100.test --format=xml' + Then the command should fail + And STDERR should contain "Invalid value specified for 'format'" + + Scenario: ssl-info of a site without SSL + Given the html site "bh-site-command-101.test" exists + When I run 'bin/ee site ssl-info bh-site-command-101.test' + Then the command should succeed + And the JSON field "0.ssl_type" in STDOUT should be "off" + And the JSON field "0.status" in STDOUT should be "SSL is not enabled for this site." + + Scenario: ssl-info DNS records of a site that does not use DNS-01 + Given the html site "bh-site-command-101.test" exists + When I run 'bin/ee site ssl-info bh-site-command-101.test --get-dns-records' + Then the command should succeed + And the JSON field "0.warnings" in STDOUT should contain "This site does not use DNS-based (DNS-01) SSL challenge." + + Scenario Outline: ssl-renew only renews Let's Encrypt certificates + Given the EasyEngine config value "le-mail" is set + And the site "" exists, created with "" + When I run 'bin/ee site ssl-renew ' + Then the command should fail + And STDERR should contain "Only Letsencrypt certificate renewal is supported." + + Examples: + | site | flags | + | bh-site-command-101.test | --type=html | + | bh-site-command-100.test | --type=html --ssl=self | + + Scenario: Create a site with a custom certificate + Given the site "bh-site-command-103.test" does not exist + When I run "openssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout {TMP}/bh.key -out {TMP}/bh.crt -subj '/CN=bh-site-command-103.test/O=bh-site-command-custom' -addext 'subjectAltName=DNS:bh-site-command-103.test'" + Then the command should succeed + When I run 'bin/ee site create bh-site-command-103.test --ssl=custom --ssl-key={TMP}/bh.key --ssl-crt={TMP}/bh.crt' + Then the command should succeed + And the "site_ssl" field of site "bh-site-command-103.test" in the database should be "custom" + And the file "{PROXY_DIR}/certs/bh-site-command-103.test.crt" should be identical to the file "{TMP}/bh.crt" + And the file "{PROXY_DIR}/certs/bh-site-command-103.test.key" should be identical to the file "{TMP}/bh.key" + When I wait up to 60 seconds for "https://bh-site-command-103.test/" to return 200 + Then the TLS certificate served for "bh-site-command-103.test" should be issued by "bh-site-command-custom" + When I run 'bin/ee site ssl-info bh-site-command-103.test' + Then the JSON field "0.ssl_type" in STDOUT should be "custom" + And the JSON field "0.issued_by_CN" in STDOUT should be "bh-site-command-103.test" + + Scenario: Custom SSL needs both a key and a certificate + Given the site "bh-site-command-104.test" does not exist + When I run 'bin/ee site create bh-site-command-104.test --ssl=custom' + Then the command should fail + And STDERR should contain "Pass --ssl-key and --ssl-crt for custom SSL" + And the site "bh-site-command-104.test" should not exist + And the directory "{SITES_DIR}/bh-site-command-104.test" should not exist + + Scenario: Custom SSL files must exist + Given the site "bh-site-command-104.test" does not exist + When I run 'bin/ee site create bh-site-command-104.test --ssl=custom --ssl-key={TMP}/missing.key --ssl-crt={TMP}/missing.crt' + Then the command should fail + And STDERR should contain "ssl-key OR ssl-crt path does not exist" + And the site "bh-site-command-104.test" should not exist + + Scenario: A subdomain site inherits the wildcard certificate of its parent + Given the site "bh-site-command-105.test" exists, created with "--type=html --ssl=self" + And the site "sub.bh-site-command-105.test" does not exist + When I run 'bin/ee site create sub.bh-site-command-105.test --ssl=inherit' + Then the command should succeed + And STDOUT should contain "Inherited certs from parent" + And the "site_ssl" field of site "sub.bh-site-command-105.test" in the database should be "inherit" + When I wait up to 60 seconds for "https://sub.bh-site-command-105.test/" to return 200 + Then the TLS certificate served for "sub.bh-site-command-105.test" should cover "sub.bh-site-command-105.test" + When I run 'bin/ee site ssl-info sub.bh-site-command-105.test' + Then the JSON field "0.cert_file" in STDOUT should be "{PROXY_DIR}/certs/bh-site-command-105.test.crt" + + Scenario: ssl-renew refuses inherited certificates + Given the EasyEngine config value "le-mail" is set + And the site "bh-site-command-105.test" exists, created with "--type=html --ssl=self" + And the site "sub.bh-site-command-105.test" exists, created with "--ssl=inherit" + When I run 'bin/ee site ssl-renew sub.bh-site-command-105.test' + Then the command should fail + And STDERR should contain "No need to renew certs for site who have inherited ssl. Please renew certificate of the parent site." + + Scenario: Inheriting needs an existing parent site + Given the site "bh-site-command-106.test" does not exist + And the site "sub.bh-site-command-106.test" does not exist + When I run 'bin/ee site create sub.bh-site-command-106.test --ssl=inherit' + Then the command should fail + And STDERR should contain "Unable to find existing site: bh-site-command-106.test" + And the site "sub.bh-site-command-106.test" should not exist + And the directory "{SITES_DIR}/sub.bh-site-command-106.test" should not exist + + Scenario: Inheriting needs a parent with SSL + Given the html site "bh-site-command-107.test" exists + And the site "sub.bh-site-command-107.test" does not exist + When I run 'bin/ee site create sub.bh-site-command-107.test --ssl=inherit' + Then the command should fail + And STDERR should contain "Cannot inherit from bh-site-command-107.test as site does not have SSL cert" + And the site "sub.bh-site-command-107.test" should not exist + + Scenario: Inheriting cannot be combined with --wildcard + Given the site "bh-site-command-105.test" exists, created with "--type=html --ssl=self" + And the site "sub2.bh-site-command-105.test" does not exist + When I run 'bin/ee site create sub2.bh-site-command-105.test --ssl=inherit --wildcard' + Then the command should fail + And STDERR should contain "Cannot use --wildcard with --ssl=inherit" + And the site "sub2.bh-site-command-105.test" should not exist + + # Regression check (fixed in v4.13.2, EasyEngine/site-command#513): the EE root CA lacks basicConstraints CA:TRUE, so OpenSSL 3.5 rejects every leaf it signed (error 79). + Scenario: Self-signed site certificates verify against the EasyEngine root CA + Given the site "bh-site-command-100.test" exists, created with "--type=html --ssl=self" + When I run 'openssl verify -CAfile {PROXY_DIR}/self-signed-certs/rootCA.pem {PROXY_DIR}/certs/bh-site-command-100.test.crt' + Then the command should succeed diff --git a/features/update-alias.feature b/features/update-alias.feature new file mode 100644 index 00000000..543a5b56 --- /dev/null +++ b/features/update-alias.feature @@ -0,0 +1,96 @@ +Feature: Update alias domains + `ee site update --add-alias-domains/--delete-alias-domains` validation, DB, proxy env and HTTP. + + Scenario: Add alias domains + Given the html site "bh-site-command-60.test" exists + When I run 'bin/ee site update bh-site-command-60.test --add-alias-domains=bh-site-command-60-a.test,b.bh-site-command-60.test' + Then the command should succeed + And STDOUT should contain "Alias domains updated on site bh-site-command-60.test." + And the "alias_domains" field of site "bh-site-command-60.test" in the database should be "bh-site-command-60.test,bh-site-command-60-a.test,b.bh-site-command-60.test" + And the service "nginx" of site "bh-site-command-60.test" should have the environment variable "VIRTUAL_HOST" containing "bh-site-command-60-a.test,b.bh-site-command-60.test" + When I wait up to 60 seconds for "http://bh-site-command-60-a.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + When I wait up to 60 seconds for "http://b.bh-site-command-60.test/" to return 200 + When I run 'bin/ee site info bh-site-command-60.test' + Then STDOUT should match "/\|\s*Alias Domains\s*\|\s*bh-site-command-60-a\.test,b\.bh-site-command-60\.test\s*\|/" + + Scenario: Adding an alias that is already present fails + Given the site "bh-site-command-61.test" exists, created with "--type=html --alias-domains=bh-site-command-61-a.test" + When I run 'bin/ee site update bh-site-command-61.test --add-alias-domains=bh-site-command-61-a.test' + Then the command should fail + And STDERR should contain "Alias domains: bh-site-command-61-a.test is/are already present on the site." + + Scenario: Adding present and new aliases skips the present ones with a warning + Given the site "bh-site-command-61.test" exists, created with "--type=html --alias-domains=bh-site-command-61-a.test" + When I run 'bin/ee site update bh-site-command-61.test --add-alias-domains=bh-site-command-61-a.test,bh-site-command-61-b.test' + Then the command should succeed + And STDERR should contain "Following domains: bh-site-command-61-a.test is/are already present on site, skipping addition of those." + And the "alias_domains" field of site "bh-site-command-61.test" in the database should be "bh-site-command-61.test,bh-site-command-61-a.test,bh-site-command-61-b.test" + + Scenario: Delete an alias domain + Given the site "bh-site-command-62.test" exists, created with "--type=html --alias-domains=bh-site-command-62-a.test,bh-site-command-62-b.test" + When I run 'bin/ee site update bh-site-command-62.test --delete-alias-domains=bh-site-command-62-b.test' + Then the command should succeed + And STDOUT should contain "Alias domains updated on site bh-site-command-62.test." + And the "alias_domains" field of site "bh-site-command-62.test" in the database should be "bh-site-command-62.test,bh-site-command-62-a.test" + And the service "nginx" of site "bh-site-command-62.test" should not have the environment variable "VIRTUAL_HOST" containing "bh-site-command-62-b.test" + # The site's nginx container is recreated, so the proxy briefly drops every domain of the site until docker-gen catches up. + When I wait up to 60 seconds for "http://bh-site-command-62-a.test/" to return 200 + And I wait up to 60 seconds for "http://bh-site-command-62-b.test/" to not return 200 + + Scenario: The primary domain cannot be deleted + Given the site "bh-site-command-62.test" exists, created with "--type=html --alias-domains=bh-site-command-62-a.test,bh-site-command-62-b.test" + When I run 'bin/ee site update bh-site-command-62.test --delete-alias-domains=bh-site-command-62.test' + Then the command should fail + And STDERR should contain "Primary site domain: `bh-site-command-62.test` can not be deleted." + + Scenario: Deleting an alias the site does not have fails + Given the site "bh-site-command-62.test" exists, created with "--type=html --alias-domains=bh-site-command-62-a.test,bh-site-command-62-b.test" + When I run 'bin/ee site update bh-site-command-62.test --delete-alias-domains=bh-site-command-62-zzz.test' + Then the command should fail + And STDERR should contain "Domains to delete is/are not a subset of already existing alias domains." + + Scenario: Another site cannot be added as an alias + Given the html site "bh-site-command-63.test" exists + And the html site "bh-site-command-64.test" exists + When I run 'bin/ee site update bh-site-command-63.test --add-alias-domains=bh-site-command-64.test' + Then the command should fail + And STDERR should contain "bh-site-command-64.test already exists as a site." + And the "alias_domains" field of site "bh-site-command-63.test" in the database should be "bh-site-command-63.test" + + Scenario: An alias of another site cannot be added + Given the html site "bh-site-command-63.test" exists + And the site "bh-site-command-65.test" exists, created with "--type=html --alias-domains=bh-site-command-65-a.test" + When I run 'bin/ee site update bh-site-command-63.test --add-alias-domains=bh-site-command-65-a.test' + Then the command should fail + And STDERR should contain "Site bh-site-command-65-a.test already exists as an alias domain for site: bh-site-command-65.test." + + Scenario: Add and delete aliases in one call + Given the site "bh-site-command-66.test" exists, created with "--type=html --alias-domains=bh-site-command-66-a.test" + When I run 'bin/ee site update bh-site-command-66.test --add-alias-domains=bh-site-command-66-b.test --delete-alias-domains=bh-site-command-66-a.test' + Then the command should succeed + And the "alias_domains" field of site "bh-site-command-66.test" in the database should be "bh-site-command-66.test,bh-site-command-66-b.test" + + Scenario: A wildcard alias serves every subdomain + Given the html site "bh-site-command-67.test" exists + When I run "bin/ee site update bh-site-command-67.test --add-alias-domains='*.bh-site-command-67.test'" + Then the command should succeed + And the "alias_domains" field of site "bh-site-command-67.test" in the database should be "bh-site-command-67.test,*.bh-site-command-67.test" + When I wait up to 60 seconds for "http://anything.bh-site-command-67.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + + Scenario: Changing aliases on a self-signed site keeps HTTPS + Given the site "bh-site-command-69.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site update bh-site-command-69.test --add-alias-domains=bh-site-command-69-a.test' + Then the command should succeed + And STDOUT should contain "No SSL certificate action needed for self SSL on alias domain change." + And the service "nginx" of site "bh-site-command-69.test" should not have the environment variable "HTTPS_METHOD" containing "nohttps" + When I wait up to 60 seconds for "https://bh-site-command-69.test/" to return 200 + Then the TLS certificate served for "bh-site-command-69.test" should cover "bh-site-command-69.test" within 30 seconds + + # Regression check (fixed by EasyEngine/site-command#502): alias labels starting with "_" could clobber a site's _wildcard proxy files. + Scenario: Alias domains with a label starting with an underscore are rejected + Given the html site "bh-site-command-68.test" exists + When I run 'bin/ee site update bh-site-command-68.test --add-alias-domains=_wildcard.bh-site-command-68.test' + Then the command should fail + And the "alias_domains" field of site "bh-site-command-68.test" in the database should be "bh-site-command-68.test" diff --git a/features/update-ssl-php.feature b/features/update-ssl-php.feature new file mode 100644 index 00000000..e2ec1ee6 --- /dev/null +++ b/features/update-ssl-php.feature @@ -0,0 +1,131 @@ +Feature: Update SSL and PHP of html sites + `ee site update --ssl[=...]`, `--wildcard` and `--php` on html sites. Let's Encrypt paths are in le.feature. + + Scenario: Enable self-signed SSL on a plain site + Given the html site "bh-site-command-70.test" exists + When I run 'bin/ee site update bh-site-command-70.test --ssl=self' + Then the command should succeed + And STDOUT should contain "Starting SSL update for: bh-site-command-70.test" + And STDOUT should contain "Enabled SSL for bh-site-command-70.test" + And the "site_ssl" field of site "bh-site-command-70.test" in the database should be "self" + And the file "{PROXY_DIR}/certs/bh-site-command-70.test.crt" should exist + And the file "{PROXY_DIR}/certs/bh-site-command-70.test.key" should exist + When I wait up to 60 seconds for "https://bh-site-command-70.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + And the TLS certificate served for "bh-site-command-70.test" should cover "bh-site-command-70.test" + When I send a GET request to "http://bh-site-command-70.test/" + Then the response code should be 301 + And the response header "Location" should be "https://bh-site-command-70.test/" + + Scenario: Enabling SSL twice fails + Given the html site "bh-site-command-72.test" exists + When I run 'bin/ee site update bh-site-command-72.test --ssl=self' + Then the command should succeed + When I run 'bin/ee site update bh-site-command-72.test --ssl=self' + Then the command should fail + And STDERR should contain "SSL is already enabled on bh-site-command-72.test" + + Scenario: Disable SSL + Given the html site "bh-site-command-73.test" exists + When I run 'bin/ee site update bh-site-command-73.test --ssl=self' + Then the command should succeed + When I run 'bin/ee site update bh-site-command-73.test --ssl=off' + Then the command should succeed + And STDOUT should contain "Disabled SSL for bh-site-command-73.test" + And the "site_ssl" field of site "bh-site-command-73.test" in the database should not be "self" + And the service "nginx" of site "bh-site-command-73.test" should have the environment variable "HTTPS_METHOD" containing "nohttps" + When I wait up to 60 seconds for "http://bh-site-command-73.test/" to return 200 + Then the response body should contain "Your html site with EasyEngine" + + Scenario: Disabling SSL on a plain site fails + Given the html site "bh-site-command-74.test" exists + When I run 'bin/ee site update bh-site-command-74.test --ssl=off' + Then the command should fail + And STDERR should contain "SSL is already disabled on bh-site-command-74.test" + + # Self-signed certificates are always wildcard (site-command 5c34be8, v3.8.1), so the refusal is checked with LE; it fails before any ACME call. + Scenario: Switching a normal site to wildcard SSL is refused + Given the html site "bh-site-command-74.test" exists + When I run 'bin/ee site update bh-site-command-74.test --ssl=le --wildcard' + Then the command should fail + And STDERR should contain "Update from normal SSL to wildcard SSL is not supported yet." + And the "site_ssl" field of site "bh-site-command-74.test" in the database should be "" + + Scenario: An unknown SSL type is rejected + Given the html site "bh-site-command-74.test" exists + When I run 'bin/ee site update bh-site-command-74.test --ssl=bh-bogus' + Then the command should fail + And STDERR should contain "Invalid value specified for 'ssl'" + + Scenario: --wildcard cannot be combined with --ssl=off + Given the site "bh-site-command-71.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site update bh-site-command-71.test --ssl=off --wildcard' + Then the command should fail + And STDERR should contain "You cannot use --wildcard flag with --ssl=off" + + Scenario: Inherit SSL from a self-signed parent through update + Given the site "bh-site-command-76.test" exists, created with "--type=html --ssl=self" + And the html site "sub.bh-site-command-76.test" exists + When I run 'bin/ee site update sub.bh-site-command-76.test --ssl=inherit' + Then the command should succeed + And the "site_ssl" field of site "sub.bh-site-command-76.test" in the database should be "inherit" + When I wait up to 60 seconds for "https://sub.bh-site-command-76.test/" to return 200 + Then the TLS certificate served for "sub.bh-site-command-76.test" should cover "sub.bh-site-command-76.test" + + # --ssl=off on the parent would remove the certificate that the child's www redirect loads, and nginx could not load its config. + Scenario: SSL can't be turned off on a parent whose certificate is inherited + Given the site "bh-site-command-76.test" exists, created with "--type=html --ssl=self" + And the html site "sub.bh-site-command-76.test" exists + When I run 'bin/ee site update sub.bh-site-command-76.test --ssl=inherit' + And I run 'bin/ee site update bh-site-command-76.test --ssl=off' + Then the command should fail + And STDERR should contain "Cannot disable SSL on bh-site-command-76.test: sub.bh-site-command-76.test inherit its certificate." + And the "site_ssl" field of site "bh-site-command-76.test" in the database should be "self" + And the file "{PROXY_DIR}/certs/bh-site-command-76.test.crt" should exist + + Scenario: Changing the PHP version of an html site fails and leaves it serving + Given the html site "bh-site-command-77.test" exists + When I run 'bin/ee site update bh-site-command-77.test --php=8.3' + Then the command should fail + And STDERR should contain "Error:" + And the "php_version" field of site "bh-site-command-77.test" in the database should be "" + When I wait up to 60 seconds for "http://bh-site-command-77.test/" to return 200 + + Scenario: An unsupported PHP version is rejected + Given the html site "bh-site-command-77.test" exists + When I run 'bin/ee site update bh-site-command-77.test --php=9.9' + Then the command should fail + And STDERR should contain "Invalid value specified for 'php'" + + # Regression check (fixed in v4.13.1, EasyEngine/site-command#505): --ssl=off was refused for sites stored with site_ssl_wildcard=1, which create --ssl=self sets. + # --ssl=off also removes the certificate (the www redirect's HTTPS block is only written when www resolves, so it isn't checked here). + Scenario: SSL can be disabled on a site created with self-signed SSL + Given the site "bh-site-command-71.test" exists, created with "--type=html --ssl=self" + When I run 'bin/ee site update bh-site-command-71.test --ssl=off' + Then the command should succeed + And STDOUT should contain "Disabled SSL for bh-site-command-71.test" + And the "site_ssl" field of site "bh-site-command-71.test" in the database should not be "self" + And the file "{PROXY_DIR}/certs/bh-site-command-71.test.crt" should not exist + And the file "{PROXY_DIR}/certs/bh-site-command-71.test.key" should not exist + When I run 'docker exec services_global-nginx-proxy_1 nginx -t' + Then the command should succeed + When I wait up to 60 seconds for "http://bh-site-command-71.test/" to return 200 + + # Regression check (fixed in v4.13.1, EasyEngine/site-command#505): create --ssl=self stores site_ssl_wildcard=1, update --ssl=self saves 0 (init_ssl() only changes the array copy). + Scenario: Enabling self-signed SSL through update records it as wildcard like create does + Given the html site "bh-site-command-75.test" exists + When I run 'bin/ee site update bh-site-command-75.test --ssl=self' + Then the command should succeed + And the "site_ssl_wildcard" field of site "bh-site-command-75.test" in the database should be "1" + + # Regression check (fixed in v4.13.1, EasyEngine/site-command#489): update has no --ssl-key/--ssl-crt, so --ssl=custom can only be set at create time. + Scenario: Enable custom SSL on an existing site + Given the html site "bh-site-command-108.test" exists + When I run "openssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout {TMP}/bh.key -out {TMP}/bh.crt -subj '/CN=bh-site-command-108.test/O=bh-site-command-custom' -addext 'subjectAltName=DNS:bh-site-command-108.test'" + Then the command should succeed + When I run 'bin/ee site update bh-site-command-108.test --ssl=custom --ssl-key={TMP}/bh.key --ssl-crt={TMP}/bh.crt' + Then the command should succeed + And the "site_ssl" field of site "bh-site-command-108.test" in the database should be "custom" + And the file "{PROXY_DIR}/certs/bh-site-command-108.test.crt" should be identical to the file "{TMP}/bh.crt" + When I wait up to 60 seconds for "https://bh-site-command-108.test/" to return 200 + Then the TLS certificate served for "bh-site-command-108.test" should be issued by "bh-site-command-custom"