diff --git a/README.md b/README.md index d9e9c3a..763ffa3 100644 --- a/README.md +++ b/README.md @@ -733,9 +733,11 @@ nginx-optimizer v0.10.0-beta - Conflict detection (warn if directive already exists) — **blocked on the AST parser** - Profile system (`--profile conservative|balanced|aggressive`) — not started, and independent of the parser work -- Partial rollback (undo single feature) — **partly built**: `feature_remove()` deletes - one template file and its include line. It cannot remove multi-template features, - hard-fails on template-less ones, and reverses no in-place edits. See ROADMAP.md. +- Partial rollback (undo single feature) — **mostly built**: `feature_remove()` splits + comma-joined `FEATURE_TEMPLATE` lists and removes every file + include line, and + `feature_remove_custom_*` hooks cover the template-less features (server-tuning, + php-fpm-tuning, redis). Remaining gap: in-place http3 `listen` edits are not + reversed. See ROADMAP.md. ### v1.0.0 - Production Release - Python crossplane integration for proper nginx config parsing diff --git a/ROADMAP.md b/ROADMAP.md index 3c205ac..47152c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,7 +36,7 @@ input validation, auto-rollback safety, and pre-flight checks. ## v0.10.x - Polish & Robustness ### Commands -- [ ] `remove` command - Cleanly uninstall optimizations +- [x] `remove` command - Cleanly uninstall optimizations - [ ] `diff` command - Show exact changes before applying - [x] `doctor` command - Diagnose common issues @@ -85,13 +85,14 @@ input validation, auto-rollback safety, and pre-flight checks. ### Features - [ ] Partial rollback (undo single feature). - **Partly built, further from done than it looks.** `cmd_remove()` delegates to - `feature_remove()`, which only deletes one template file and sed-drops lines - naming it. Three holes: no `feature_remove_custom_*` exists anywhere in the - tree; multi-template features are unremovable because `FEATURE_TEMPLATE` is a - comma-joined string that `feature_remove` never splits (security, - fastcgi-cache); template-less features hard-fail (server-tuning, - php-fpm-tuning, redis). It also reverses no in-place edits at all. + **Mostly built.** `feature_remove()` splits comma-joined `FEATURE_TEMPLATE` + lists (security, fastcgi-cache) and removes every file + include line, and + `feature_remove_custom_*` hooks cover the three template-less features: + server-tuning strips its `worker_*` lines (or restores an interrupted-apply + `.tuning-bak`), php-fpm-tuning restores `.before-tuning` or comments its + `pm.*` lines behind a fresh backup, redis drops the compose service and its + container. Remaining gap: in-place `listen ... quic` edits from http3 are + still not reversed. - [ ] Config diff visualization - [x] Missing core optimizations (worker_processes, open_file_cache, sendfile, etc.) — **shipped** as the `server-tuning` and `open-file-cache` features. diff --git a/docs/PRODUCTION-READINESS.md b/docs/PRODUCTION-READINESS.md index 5d85d8b..9d31642 100644 --- a/docs/PRODUCTION-READINESS.md +++ b/docs/PRODUCTION-READINESS.md @@ -245,10 +245,10 @@ each, printed on every `test-corpus.sh` run. - [ ] **`remove` command** - cleanly uninstall optimizations - [ ] **`doctor` command** - diagnose common issues - [ ] **`export` command** - export current config as template -- [ ] **Partial rollback** - undo just one feature. *Partly built:* `feature_remove()` deletes one - template file and its include line only. Cannot remove multi-template features (`FEATURE_TEMPLATE` - is comma-joined and never split), hard-fails on template-less ones (server-tuning, php-fpm-tuning, - redis), and reverses no in-place edits. No `feature_remove_custom_*` exists anywhere in the tree. +- [ ] **Partial rollback** - undo just one feature. *Mostly built:* `feature_remove()` splits + comma-joined `FEATURE_TEMPLATE` lists and removes every file + include line, and + `feature_remove_custom_*` hooks cover the template-less features (server-tuning, + php-fpm-tuning, redis). Remaining gap: in-place http3 `listen` edits are not reversed. ### 4.2 Output/Feedback Issues - [ ] No JSON output mode for tooling integration diff --git a/lib/features/php-fpm-tuning.sh b/lib/features/php-fpm-tuning.sh index bd84391..1d6a1ff 100644 --- a/lib/features/php-fpm-tuning.sh +++ b/lib/features/php-fpm-tuning.sh @@ -213,6 +213,110 @@ feature_apply_custom_php_fpm_tuning() { return 0 } +################################################################################ +# Custom Remove Logic +################################################################################ + +# Remove PHP-FPM tuning written by feature_apply_custom_php_fpm_tuning. +# +# Apply keeps a pristine copy at ${pool}.before-tuning — restoring it is the +# clean remove. If that backup is gone but the pool still carries our +# signature (uncommented pm.process_idle_timeout / pm.max_requests — stock +# www.conf only ships them commented), the pm.* lines this tool manages are +# commented back out so FPM falls back to its compiled defaults — and only +# after writing a fresh ${pool}.remove-bak. A live pool is never rewritten +# without a backup. +# Args: $1 = target_site (optional, ignored for global feature) +# Returns: 0 on success, 1 if nothing was applied +feature_remove_custom_php_fpm_tuning() { + # shellcheck disable=SC2034 # target_site reserved for API compatibility (global feature) + local target_site="${1:-}" + + local pool_file="" + if type -t _fpm_find_pool_config &>/dev/null; then + pool_file=$(_fpm_find_pool_config) + fi + + local before_bak="${pool_file}.before-tuning" + local has_before=false has_marker=false + if [[ -n "$pool_file" ]] && [[ -f "$before_bak" ]]; then + has_before=true + fi + if [[ -n "$pool_file" ]] && [[ -f "$pool_file" ]] && \ + grep -qE '^[[:space:]]*pm\.(process_idle_timeout|max_requests)[[:space:]]*=' "$pool_file" 2>/dev/null; then + has_marker=true + fi + + if [[ "$has_before" == false ]] && [[ "$has_marker" == false ]]; then + if [ "${DRY_RUN:-false}" = true ]; then + return 0 + fi + if type -t log_info &>/dev/null; then + log_info "PHP-FPM tuning not applied — nothing to remove" + fi + return 1 + fi + + if [ "${DRY_RUN:-false}" = true ]; then + if type -t ui_step_path &>/dev/null; then + if [[ "$has_before" == true ]]; then + ui_step_path "Would restore" "$before_bak" + else + ui_step_path "Would comment out pm.* tuning in" "$pool_file" + fi + fi + return 0 + fi + + local SUDO="" + [[ ! -w "$pool_file" ]] && SUDO="sudo" + + if [[ "$has_before" == true ]]; then + if $SUDO cp "$before_bak" "$pool_file"; then + $SUDO rm -f "$before_bak" + if type -t ui_step_path &>/dev/null; then + ui_step_path "Restored" "PHP-FPM pool from ${before_bak}" + fi + return 0 + fi + return 1 + fi + + # Backup missing but our signature is present: back up the live pool + # BEFORE touching it, then comment out the directives this tool manages. + if ! $SUDO cp "$pool_file" "${pool_file}.remove-bak"; then + return 1 + fi + $SUDO sed -i.rmback \ + -e 's|^[[:space:]]*pm[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.max_children[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.start_servers[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.min_spare_servers[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.max_spare_servers[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.process_idle_timeout[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.max_requests[[:space:]]*=|; &|' \ + "$pool_file" 2>/dev/null || \ + $SUDO sed -i '' \ + -e 's|^[[:space:]]*pm[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.max_children[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.start_servers[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.min_spare_servers[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.max_spare_servers[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.process_idle_timeout[[:space:]]*=|; &|' \ + -e 's|^[[:space:]]*pm\.max_requests[[:space:]]*=|; &|' \ + "$pool_file" 2>/dev/null + $SUDO rm -f "${pool_file}.rmback" 2>/dev/null + + if type -t ui_step_path &>/dev/null; then + ui_step_path "Reverted PHP-FPM tuning" "${pool_file} (backup: ${pool_file}.remove-bak)" + fi + if type -t log_to_file &>/dev/null; then + log_to_file "INFO" "PHP-FPM tuning commented out; pre-remove copy kept at ${pool_file}.remove-bak" + fi + + return 0 +} + ################################################################################ # Helper Functions ################################################################################ diff --git a/lib/features/redis.sh b/lib/features/redis.sh index 5e19db5..18ee07d 100644 --- a/lib/features/redis.sh +++ b/lib/features/redis.sh @@ -190,6 +190,129 @@ _redis_system_show_wp_config() { fi } +################################################################################ +# Custom Remove +################################################################################ + +# Remove the Redis pieces feature_apply_custom_redis wrote: +# wp-test — the `redis:` service block in each site's docker-compose.yml and +# the redis- container (named via container_name). +# system — left running on purpose: if redis-server was already up when +# apply ran it is not ours to stop, and no marker tells the two +# apart. Reported instead. +# Args: $1 = target_site (optional) +# Returns: 0 on success/dry-run, 1 if nothing was applied +feature_remove_custom_redis() { + local target_site="${1:-}" + + if type -t log_to_file &>/dev/null; then + log_to_file "INFO" "Removing Redis Object Cache..." + fi + + local removed=false + local wp_test_sites="${WP_TEST_SITES:-$HOME/.wp-test/sites}" + + if [ -d "$wp_test_sites" ]; then + if [ -n "$target_site" ] && [ -d "$wp_test_sites/$target_site" ]; then + _redis_remove_site "$target_site" && removed=true + elif [ -z "$target_site" ]; then + local site_dir site + for site_dir in "$wp_test_sites"/*; do + [ -d "$site_dir" ] || continue + site=$(basename "$site_dir") + _redis_remove_site "$site" && removed=true + done + fi + fi + + if command -v redis-cli &>/dev/null && redis-cli ping &>/dev/null 2>&1; then + if [ "${DRY_RUN:-false}" = true ]; then + if type -t ui_step &>/dev/null; then + ui_step "Would leave system Redis running (may predate this tool)" + fi + elif type -t log_info &>/dev/null; then + log_info "System Redis left running — stop manually if this tool installed it" + fi + fi + + if [ "${DRY_RUN:-false}" = true ]; then + return 0 + fi + [ "$removed" = true ] +} + +# Remove the redis service block from one wp-test site's docker-compose.yml +# and delete its container. +# Args: $1 = site name +# Returns: 0 if the service was present (removed, or would-be in dry-run) +_redis_remove_site() { + local site="$1" + local wp_test_sites="${WP_TEST_SITES:-$HOME/.wp-test/sites}" + local compose_file="$wp_test_sites/$site/docker-compose.yml" + + [ -f "$compose_file" ] || return 1 + grep -qE '^[[:space:]]*redis:[[:space:]]*$' "$compose_file" 2>/dev/null || return 1 + + if [ "${DRY_RUN:-false}" = true ]; then + if type -t ui_step_path &>/dev/null; then + ui_step_path "Would remove Redis service from" "$site/docker-compose.yml" + ui_step_path "Would remove container" "redis-${site}" + fi + return 0 + fi + + # Remove the container this tool added (container_name: redis-) + if command -v docker &>/dev/null; then + docker rm -f "redis-${site}" >/dev/null 2>&1 || true + fi + + cp "$compose_file" "${compose_file}.remove-bak" || return 1 + + # Drop the `redis:` service block: every following line indented deeper + # than the service key belongs to it. + local temp_file + temp_file=$(mktemp) + awk ' + /^[[:space:]]*redis:[[:space:]]*$/ { + bind = match($0, /[^[:space:]]/) - 1 + inblock = 1 + next + } + inblock { + if ($0 ~ /^[[:space:]]*$/) next + if (match($0, /[^[:space:]]/) - 1 > bind) next + inblock = 0 + } + { print } + ' "$compose_file" > "$temp_file" + + # Validate the resulting YAML when docker-compose is available, same as + # safe_add_docker_service does on the apply path + if command -v docker-compose &>/dev/null; then + if ! docker-compose -f "$temp_file" config -q >/dev/null 2>&1; then + rm -f "$temp_file" + if type -t log_error &>/dev/null; then + log_error "Compose validation failed after removing redis — kept ${compose_file}.remove-bak" + fi + return 1 + fi + fi + + if cp "$temp_file" "$compose_file"; then + rm -f "$temp_file" "${compose_file}.remove-bak" + if type -t ui_step_path &>/dev/null; then + ui_step_path "Removed Redis service from" "$site" + fi + if type -t log_to_file &>/dev/null; then + log_to_file "INFO" "Removed redis service from $site (wp-config.php Redis defines should be removed manually)" + fi + return 0 + fi + + rm -f "$temp_file" + return 1 +} + ################################################################################ # Helper Functions ################################################################################ diff --git a/lib/features/server-tuning.sh b/lib/features/server-tuning.sh index adc8e1e..27e1f34 100644 --- a/lib/features/server-tuning.sh +++ b/lib/features/server-tuning.sh @@ -243,6 +243,118 @@ feature_apply_custom_server_tuning() { return 0 } +################################################################################ +# Custom Remove Logic +################################################################################ + +# Remove server tuning written by feature_apply_custom_server_tuning. +# +# Apply rewrites nginx.conf in place: worker_processes -> auto plus a net-new +# worker_rlimit_nofile line after it, and worker_connections inside events{}. +# The pre-apply values are only in the safety backup cmd_remove() took, so +# removal strips the three tuned lines and lets nginx fall back to its own +# defaults. A leftover ${nginx_conf}.tuning-bak from an interrupted apply is +# a pristine pre-tuning copy and is restored whole instead. +# Args: $1 = target_site (optional, ignored for global feature) +# Returns: 0 on success, 1 if nothing was applied +feature_remove_custom_server_tuning() { + # shellcheck disable=SC2034 # target_site reserved for API compatibility (global feature) + local target_site="${1:-}" + + local nginx_conf="" + if type -t get_nginx_main_conf &>/dev/null; then + nginx_conf=$(get_nginx_main_conf) + fi + if [[ -z "$nginx_conf" ]]; then + for conf in /etc/nginx/nginx.conf /opt/homebrew/etc/nginx/nginx.conf /usr/local/etc/nginx/nginx.conf; do + if [[ -f "$conf" ]]; then + nginx_conf="$conf" + break + fi + done + fi + + # Signature of an applied (or interrupted) run: worker_processes auto in + # nginx.conf, or a .tuning-bak apply left behind when it was interrupted. + local tuning_bak="" + if [[ -n "$nginx_conf" ]] && [[ -f "${nginx_conf}.tuning-bak" ]]; then + tuning_bak="${nginx_conf}.tuning-bak" + fi + + if [[ -z "$tuning_bak" ]] && { [[ ! -f "$nginx_conf" ]] || \ + ! grep -qE '^[[:space:]]*worker_processes[[:space:]]+auto;' "$nginx_conf" 2>/dev/null; }; then + if [ "${DRY_RUN:-false}" = true ]; then + return 0 + fi + if type -t log_info &>/dev/null; then + log_info "Server tuning not applied — nothing to remove" + fi + return 1 + fi + + if [ "${DRY_RUN:-false}" = true ]; then + if type -t ui_step_path &>/dev/null; then + if [[ -n "$tuning_bak" ]]; then + ui_step_path "Would restore" "nginx.conf from ${tuning_bak}" + else + ui_step_path "Would revert" "$nginx_conf (worker_processes, worker_rlimit_nofile, worker_connections → nginx defaults)" + fi + fi + return 0 + fi + + local SUDO="" + [[ ! -w "$nginx_conf" ]] && SUDO="sudo" + + # Interrupted apply: .tuning-bak is the pristine copy — restore it whole. + if [[ -n "$tuning_bak" ]]; then + if $SUDO mv "$tuning_bak" "$nginx_conf"; then + if type -t ui_step_path &>/dev/null; then + ui_step_path "Restored" "nginx.conf from interrupted-apply backup" + fi + return 0 + fi + return 1 + fi + + if ! $SUDO cp "$nginx_conf" "${nginx_conf}.remove-bak"; then + return 1 + fi + + # worker_connections is only valid inside events{} — no context tracking + # needed to find the lines this feature manages. + $SUDO sed -i.rmback \ + -e '/^[[:space:]]*worker_processes[[:space:]][[:space:]]*auto[[:space:]]*;/d' \ + -e '/^[[:space:]]*worker_rlimit_nofile[[:space:]]/d' \ + -e '/^[[:space:]]*worker_connections[[:space:]]/d' \ + "$nginx_conf" 2>/dev/null || \ + $SUDO sed -i '' \ + -e '/^[[:space:]]*worker_processes[[:space:]][[:space:]]*auto[[:space:]]*;/d' \ + -e '/^[[:space:]]*worker_rlimit_nofile[[:space:]]/d' \ + -e '/^[[:space:]]*worker_connections[[:space:]]/d' \ + "$nginx_conf" 2>/dev/null + $SUDO rm -f "${nginx_conf}.rmback" 2>/dev/null + + # Validate, mirroring apply's rollback-on-failure + if command -v nginx &>/dev/null; then + if ! nginx -t 2>&1 | grep -q "test is successful\|syntax is ok"; then + $SUDO mv "${nginx_conf}.remove-bak" "$nginx_conf" + if type -t log_warn &>/dev/null; then + log_warn "nginx -t failed after removing tuning, rolled back" + fi + return 1 + fi + fi + + $SUDO rm -f "${nginx_conf}.remove-bak" + + if type -t ui_step_path &>/dev/null; then + ui_step_path "Reverted server tuning" "$nginx_conf → nginx defaults" + fi + + return 0 +} + ################################################################################ # Register Feature ################################################################################ diff --git a/lib/registry.sh b/lib/registry.sh index 371fb44..acb3c43 100644 --- a/lib/registry.sh +++ b/lib/registry.sh @@ -393,6 +393,28 @@ feature_remove() { return 1 fi + local removed=false + local tmpl + + # FEATURE_TEMPLATE is comma-joined for multi-template features (security, + # fastcgi-cache). Splitting on commas — word-splitting also trims any + # whitespace around each name — then run delete + include-strip per file. + for tmpl in ${template//,/ }; do + _feature_remove_template "$tmpl" && removed=true + done + + if [[ "$removed" == "true" ]] || [[ "${DRY_RUN:-false}" == "true" ]]; then + return 0 + fi + return 1 +} + +# _feature_remove_template - Delete one deployed template file and strip its +# include directives from site configs and wp-test vhost.d files. +# Args: $1 = template filename +# Returns: 0 if anything was removed, 1 otherwise +_feature_remove_template() { + local template="$1" local removed=false # Escape template name for safe use in sed/grep patterns @@ -435,10 +457,10 @@ feature_remove() { if [[ "${DRY_RUN:-false}" == "true" ]]; then echo "Would remove include from: $(basename "$site_conf")" >&2 else - local use_sudo="" - [[ ! -w "$site_conf" ]] && use_sudo="sudo" - $use_sudo sed -i.rmback "/${template_escaped}/d" "$site_conf" 2>/dev/null || \ - $use_sudo sed -i '' "/${template_escaped}/d" "$site_conf" 2>/dev/null + local SUDO="" + [[ ! -w "$site_conf" ]] && SUDO="sudo" + $SUDO sed -i.rmback "/${template_escaped}/d" "$site_conf" 2>/dev/null || \ + $SUDO sed -i '' "/${template_escaped}/d" "$site_conf" 2>/dev/null rm -f "${site_conf}.rmback" 2>/dev/null removed=true fi @@ -464,10 +486,7 @@ feature_remove() { done fi - if [[ "$removed" == "true" ]] || [[ "${DRY_RUN:-false}" == "true" ]]; then - return 0 - fi - return 1 + [[ "$removed" == "true" ]] } ################################################################################ diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 1569432..7e6e39b 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -1705,6 +1705,126 @@ for helper_fn in smart_copy smart_mkdir smart_write; do fi done +################################################################################ +# SECTION 26: feature_remove — multi-template + template-less holes (issue #16) +################################################################################ +log_section "feature_remove multi-template / template-less" + +# registry.sh + all features were sourced in SECTION 18; re-source defensively +# so this block survives section reordering. +if ! type -t feature_remove &>/dev/null || ! feature_exists "security" 2>/dev/null; then + # shellcheck source=/dev/null + source "${SCRIPT_DIR}/../lib/registry.sh" 2>/dev/null || true + for vf in "${SCRIPT_DIR}/../lib/features/"*.sh; do + # shellcheck source=/dev/null + source "$vf" 2>/dev/null || true + done +fi + +# (a) Multi-template split: security registers FEATURE_TEMPLATE as the +# comma-joined "security-headers.conf,security-http.conf". Remove must +# delete EACH file and strip EACH include line — on the old code the whole +# comma string was matched literally, so nothing was ever removed. +# +# Everything below runs against a fake tree. This machine has real copies of +# these files (conf.d, sites-enabled, ~/.wp-test) — the path helpers are +# stubbed to empty dirs so removal can only ever touch the fixtures. +REMOVE_FAKE=$(mktemp -d) +mkdir -p "${REMOVE_FAKE}/conf.d" "${REMOVE_FAKE}/sites" \ + "${REMOVE_FAKE}/wp-nginx/conf.d" "${REMOVE_FAKE}/wp-nginx/vhost.d" \ + "${REMOVE_FAKE}/empty-confd" "${REMOVE_FAKE}/empty-sites" + +echo "# headers" > "${REMOVE_FAKE}/conf.d/security-headers.conf" +echo "# rate limiting" > "${REMOVE_FAKE}/conf.d/security-http.conf" +echo "# headers" > "${REMOVE_FAKE}/wp-nginx/conf.d/security-headers.conf" +echo "# rate limiting" > "${REMOVE_FAKE}/wp-nginx/conf.d/security-http.conf" +cat > "${REMOVE_FAKE}/sites/fake-site.conf" <<'REOF' +server { + listen 443 ssl; + include /etc/nginx/conf.d/security-headers.conf; + include /etc/nginx/conf.d/security-http.conf; +} +REOF +cat > "${REMOVE_FAKE}/wp-nginx/vhost.d/fake.local" <<'REOF' +include /etc/nginx/conf.d/security-headers.conf; +include /etc/nginx/conf.d/security-http.conf; +REOF + +get_nginx_confd_dir() { echo "${REMOVE_FAKE}/conf.d"; } +get_nginx_sites_dir() { echo "${REMOVE_FAKE}/sites"; } +WP_TEST_NGINX_BAK="${WP_TEST_NGINX:-}" +WP_TEST_NGINX="${REMOVE_FAKE}/wp-nginx" + +if DRY_RUN=false feature_remove "security" >/dev/null 2>&1; then + remove_rc=0 +else + remove_rc=$? +fi + +# Restore the real resolvers before anything else can observe the stubs +if [ -n "$WP_TEST_NGINX_BAK" ]; then WP_TEST_NGINX="$WP_TEST_NGINX_BAK"; else unset WP_TEST_NGINX; fi +source "${SCRIPT_DIR}/../nginx-optimizer-lib/optimizer.sh" 2>/dev/null || true + +if [ "$remove_rc" -eq 0 ]; then + log_pass "feature_remove security returns 0 when files existed" +else + log_fail "feature_remove security returned $remove_rc (multi-template not split?)" +fi + +removed_files_ok=true +for f in "${REMOVE_FAKE}/conf.d/security-headers.conf" \ + "${REMOVE_FAKE}/conf.d/security-http.conf" \ + "${REMOVE_FAKE}/wp-nginx/conf.d/security-headers.conf" \ + "${REMOVE_FAKE}/wp-nginx/conf.d/security-http.conf"; do + [ -f "$f" ] && { removed_files_ok=false; echo " still present: $f"; } +done +if [ "$removed_files_ok" = true ]; then + log_pass "feature_remove deletes EVERY file of a comma-joined FEATURE_TEMPLATE" +else + log_fail "feature_remove left template files behind (comma-joined list never split)" +fi + +includes_gone=true +for v in "${REMOVE_FAKE}/sites/fake-site.conf" "${REMOVE_FAKE}/wp-nginx/vhost.d/fake.local"; do + if grep -q "security-headers.conf\|security-http.conf" "$v" 2>/dev/null; then + includes_gone=false + echo " include lines left in: $v" + fi +done +if [ "$includes_gone" = true ]; then + log_pass "feature_remove strips EVERY include line of a multi-template feature" +else + log_fail "feature_remove left include directives behind" +fi + +rm -rf "$REMOVE_FAKE" + +# (b) Template-less features must not hard-fail. redis / server-tuning / +# php-fpm-tuning register FEATURE_TEMPLATE="" and need +# feature_remove_custom_ hooks — normalised like the detect/apply +# hooks (php-fpm-tuning -> php_fpm_tuning). +for rfeat in server_tuning php_fpm_tuning redis; do + if declare -f "feature_remove_custom_${rfeat}" &>/dev/null; then + log_pass "feature_remove_custom_${rfeat} exists" + else + log_fail "feature_remove_custom_${rfeat} missing — template-less feature hard-fails" + fi +done + +# (c) End-to-end dry-run: exits 0 and never prints "has no template". +# A fake HOME keeps the whole run (lock file, logs, wp-test lookup) away +# from this machine's real config; --dry-run makes every hook write-safe. +REMOVE_FAKE_HOME=$(mktemp -d) +for rfeat in redis server-tuning php-fpm-tuning; do + r_out=$(HOME="$REMOVE_FAKE_HOME" "${OPTIMIZER}" remove --feature "$rfeat" --dry-run --no-color --force 2>&1) && r_rc=0 || r_rc=$? + if [ "$r_rc" -eq 0 ] && ! printf '%s' "$r_out" | grep -q "has no template"; then + log_pass "remove --feature $rfeat --dry-run exits 0, no 'has no template'" + else + log_fail "remove --feature $rfeat --dry-run rc=$r_rc $(printf '%s' "$r_out" | grep -m1 'has no template')" + fi +done +rm -rf "$REMOVE_FAKE_HOME" + ################################################################################ # Summary ################################################################################